Embedding the Engine
For Pascal developers who want to embed the GocciaScript engine in their own applications.
Executive Summary
- Quick start —
TGocciaRuntime.Create(...)creates the runtime layer for file loading and runtime extension installation; apply a runtime profile or install concrete runtime extensions for runtime globals andgoccia:runtime modules.TGocciaEngine.Create(...)remains available for core-language-only embedders and requires an explicit executor (TGocciaInterpreterExecutorfor tree-walk orTGocciaBytecodeExecutorfor bytecode VM) - Sandboxing — Choose runtime extensions and tool-specific runtime APIs explicitly; inject custom globals via
DefineLexicalBinding; enforce execution limits via timeout or instruction cap - Module resolution — Pluggable resolver with extensionless imports, import maps, custom content providers, virtual modules, and host modules
- Transparent GC — Mark-and-sweep GC initializes automatically; FPU exceptions are masked for IEEE 754 semantics
Native application embedding is an important secondary GocciaScript goal. TGocciaRuntime is the shared Pascal embedding entry point for the runtime layer: filesystem module content loading, runtime module dispatch, and extension installation. Runtime globals such as console, fetch, and URL, plus import-only modules such as goccia:json5, goccia:toml, goccia:yaml, goccia:csv, goccia:tsv, goccia:jsonl, goccia:semver, and goccia:test, usually come from ApplyLoaderRuntimeProfile. TGocciaEngine remains available through Runtime.Engine and as a core-language-only API for embedders that intentionally do not want runtime globals or runtime modules.
Quick Start
The simplest way to run a script with the loader runtime surface:
Source := TStringList.Create;Source.Text := 'console.log("hello from GocciaScript");';Runtime := TGocciaRuntime.Create('<inline>', Source);tryApplyLoaderRuntimeProfile(Runtime.Core);Runtime.Execute;finallyRuntime.Free;Source.Free;end;
For files, load app.js into the Source list, pass the real filename to TGocciaRuntime.Create('app.js', Source), and apply the runtime profile or runtime extensions your host needs before executing. Use TGocciaEngine.RunScript* only for core-language scripts that do not need runtime file loading, runtime globals, or goccia: runtime modules.
Engine API
Class Methods (One-Shot Execution)
These helpers create, execute, and clean up in a single call. The TGocciaEngine methods create a core-language-only engine. The TGocciaRuntime methods attach the runtime layer for file loading, but they do not apply a runtime profile; install runtime extensions explicitly when scripts need runtime globals such as console, fetch, and URL, or import-only modules such as goccia:json5, goccia:toml, goccia:yaml, goccia:csv, goccia:tsv, goccia:jsonl, or goccia:semver.
| Method | Description |
|---|---|
TGocciaEngine.RunScript(Source) | Execute source text with core language built-ins only |
TGocciaEngine.RunScript(Source, FileName) | Execute source text with a diagnostic filename |
TGocciaEngine.RunScriptFromStringList(Source, FileName) | Execute from a caller-provided TStringList |
TGocciaRuntime.RunScript(Source, FileName) | Execute source text through the runtime layer |
TGocciaRuntime.RunScriptFromFile(FileName) | Load and execute a file through the runtime layer |
TGocciaRuntime.RunScriptFromStringList(Source, FileName) | Execute from a TStringList through the runtime layer |
All methods return TGocciaScriptResult — a record containing the result value, per-phase timing (in microseconds), and the filename. |
SourceType is an engine-level language option, not a runtime option. Set Engine.SourceType (or use the CLI --source-type=script|module) to choose script source or module source for the entry file. File names ending in .mjs or .mts infer module source unless an explicit source type is provided. File loading is separate: TGocciaEngine one-shot helpers accept source text or caller-provided TStringList instances only, while TGocciaRuntime.RunScriptFromFile is the runtime convenience API for loading an entry file.
TGocciaEngine.Execute remains the public "run the whole source pipeline and execute" API for embedders. Hosts that need parse artifacts without executing can call TGocciaSourcePipeline.Parse directly; its result object owns the AST and source map until the caller frees the result or transfers ownership with TakeProgramNode / TakeSourceMap. Hosts should use the source-pipeline entry points for module source, dynamic Function validation, and expression fragments rather than constructing TGocciaParser directly; this keeps parser policy in one place.
Instance Usage (Long-Lived Engine)
For interactive sessions (REPL, editor integration) or when you need to execute multiple scripts in the same scope, create an engine instance directly:
usesClasses,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Values.Primitives;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;Source: TStringList;ScriptResult: TGocciaScriptResult;beginSource := TStringList.Create;Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('session', Source, Executor);try// First execution — defines a variableSource.Text := 'const x = 42;';Engine.Execute;// Second execution — uses the variable from the firstSource.Text := 'x + 1;';ScriptResult := Engine.Execute;WriteLn(ScriptResult.Result.ToStringLiteral.Value); // 43finallyEngine.Free;end;finallyExecutor.Free;Source.Free;end;end;
The TStringList is passed by reference — update its contents and call Execute again to run new code in the same global scope. Variables, functions, and classes defined in previous executions remain available.
Engine Constructor and Executor Ownership
Callers must pass an explicit executor — TGocciaInterpreterExecutor (in Goccia.Executor.Interpreter) for tree-walk or TGocciaBytecodeExecutor (in Goccia.Executor.Bytecode) for the bytecode VM. The engine does not own the executor; the caller frees it after the engine. TGocciaRuntime's file/source convenience overloads handle this internally for embedders who want the default interpreter setup.
Automatic Semicolon Insertion
ASI is disabled by default. To enable ECMAScript-compliant automatic semicolon insertion (ES2026 §12.10), include cfASI in the engine compatibility set after creating the engine:
Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);Engine.Compatibility := [cfASI]; // Semicolons are now optional per ES2026 rulesEngine.Execute;finallyEngine.Free;Executor.Free;end;
When enabled, the parser inserts virtual semicolons at newline boundaries, before }, and at EOF. Restricted productions (return, throw, break) follow the [no LineTerminator here] rules.
Timing
All RunScript* methods and the Execute method return a TGocciaScriptResult record that includes nanosecond-precision timing for each pipeline phase:
usesTimingUtils,Goccia.Engine;varScriptResult: TGocciaScriptResult;beginScriptResult := TGocciaEngine.RunScript(Source, 'bench.js');WriteLn('Lex: ', FormatDuration(ScriptResult.LexTimeNanoseconds));WriteLn('Parse: ', FormatDuration(ScriptResult.ParseTimeNanoseconds));WriteLn('Execute: ', FormatDuration(ScriptResult.ExecuteTimeNanoseconds));WriteLn('Total: ', FormatDuration(ScriptResult.TotalTimeNanoseconds));end;
FormatDuration (from TimingUtils) automatically selects the appropriate unit: ns for values below 0.5μs, μs for values below 0.5ms, ms with two decimal places for values up to 10s, and s above that.
TimingUtils provides three clock functions: GetNanoseconds and GetMilliseconds for monotonic duration timing (clock_gettime(CLOCK_MONOTONIC) on Unix/macOS, QueryPerformanceCounter on Windows), and GetEpochNanoseconds for wall-clock epoch time (clock_gettime(CLOCK_REALTIME) on Unix/macOS, GetSystemTimeAsFileTime on Windows).
Host-Controlled Time And Randomness
Before attaching runtime extensions, use the engine-owned host environment; see Host Environment for deterministic setup and providers.
Engine Lifecycle & Realm Isolation
Every TGocciaEngine owns an initial TGocciaRealm (Goccia.Realm.pas) — the engine's ECMA-262 Realm Record for mutable intrinsic prototype objects (Array.prototype, Object.prototype, Map.prototype, every error prototype, every Temporal prototype, and so on), global state links, the realm [[TemplateMap]], loaded-module host state, and host-defined data. The realm is created in the engine constructor and torn down in the engine destructor; tear-down unpins every prototype and cached template object the realm owns so the GC can collect them before the next engine starts up.
This is the strongest isolation boundary the engine provides. Two engines created back-to-back on the same thread see pristine intrinsics — userland mutations on one engine's Array.prototype cannot leak into the next engine's Array.prototype, even if the mutation added a non-configurable property that JS-level cleanup cannot reverse.
When this matters for embedders
- Test runners and conformance harnesses — Per-file engine recreation is the cleanest way to give each test file fresh intrinsics.
GocciaTestRunnerdoes this automatically; if you build a custom runner, free the engine between files rather than reusing it. - Sandboxes evaluating untrusted scripts — Each script can mutate
Object.prototype. Recreating the engine between scripts is the only reliable way to guarantee the next script starts from a clean slate. - REPLs and long-lived sessions — These intentionally share intrinsics across
Executecalls (so users can mutateArray.prototypefrom one line and observe the change on the next). Reuse the same engine instance.
What you do not need to do
- Do not call
SetCurrentRealmdirectly. Engine, interpreter, and bytecode entry points make the realm current throughTGocciaExecutionContextStack;CurrentRealmremains only as the lookup facade used by value units. - Do not pin or unpin prototype objects manually after engine startup. The realm pins everything stored in a slot via
SetSlot, and unpins them all at tear-down. - Do not cache prototype object pointers in long-lived Pascal state. Those objects are realm-scoped — they become invalid the moment the engine that owns them is freed. If you need the current
Array.prototype, look it up live (e.g. viaEngine.Interpreter.GlobalScope).
Threading
CurrentRealm is backed by thread-local execution-context state. Each worker thread that executes an engine gets its own realm stack. The thread pool used by GocciaTestRunner --jobs=N relies on this: each worker creates, enters, and destroys engines on its own thread, and realm tear-down on one worker has no effect on intrinsics seen by the others.
Module Resolution
The engine uses a pluggable module resolver (TGocciaModuleResolver) that supports extensionless imports, import-map-style aliases, and index file resolution.
Extension-Free Imports
Import paths can omit file extensions. The engine's own list comes first, in order: .js, .jsx, .ts, .tsx, .mjs, .mts, .json, .txt, .md. Installed runtime extensions append the structured-data extensions they own, so a host that applies the loader runtime profile also resolves .json5, .jsonc, .jsonl, .toml, .yaml, .yml, .csv, and .tsv:
// These all resolve through the shared module extension list:import { add } from "./math-utils.js"; // explicit extensionimport { add } from "./math-utils"; // extension resolved automaticallyimport { content } from "./notes"; // resolves to ./notes.txt or ./notes.md
Directory imports resolve to index files:
import { setup } from "./utils"; // resolves to ./utils/index.js (or .ts, .jsx, etc.)
Path Aliases
Aliases follow WHATWG import map matching rules:
Engine.AddAlias('lodash', 'vendor/lodash/index.js'); // exact match onlyEngine.AddAlias('@/', 'src/'); // prefix matchEngine.AddAlias('@/components/', 'ui/lib/'); // more specific prefix
For an embedded engine, the alias target is resolved relative to the resolver's base directory, which defaults to the entry file's directory. In the shared CLI hosts, relative --alias targets use the invocation directory, while aliases loaded from configuration use the active project configuration file's directory, including aliases inherited through extends.
Exact vs prefix matching: A key without a trailing / is an exact match only. A key with a trailing / is a prefix match and appends the unmatched suffix to the target. This means lodash matches import "lodash" but not import "lodash/fp", while @/ matches @/utils/math.
Longest-prefix matching: When multiple prefix aliases overlap (e.g., @/ and @/components/), the resolver always picks the longest matching key. This means @/components/Button uses the @/components/ alias, not @/.
Scripts can then import using the alias:
import { formatDate } from "@/utils/dates";
TGocciaModuleResolver also exposes LoadImportMap(path) and DiscoverProjectConfig(startDirectory) helpers for browser-style import map JSON and goccia.json project configuration files. The shared CLI hosts (GocciaScriptLoader, GocciaTestRunner, and GocciaBenchmarkRunner) all use Goccia.Modules.Configuration.ConfigureModuleResolver(...) on top of this resolver surface.
Config file discovery is automatic for CLI apps — TGocciaCLIApplication discovers goccia.toml / goccia.json5 / goccia.json (priority order: TOML > JSON5 > JSON) from the entry file's directory upward and applies config values before execution. When embedding the engine directly, this does not happen automatically. To replicate it, use the general-purpose CLI.ConfigFile unit (DiscoverConfigFile, ApplyConfigFile). Note that ApplyConfigFile only handles .json by default — to support .json5 and .toml, register their parsers first via RegisterConfigParser (see Goccia.CLI.Application.pas for the pattern). For import-map resolution only, use TGocciaModuleResolver.DiscoverProjectConfig and LoadImportMap. See Configuration File for the full reference.
Custom Resolver
For advanced resolution logic (e.g., node_modules lookup, URL imports, or in-memory modules), subclass TGocciaModuleResolver and override the Resolve method:
usesGoccia.Engine,Goccia.Executor.Interpreter,Goccia.Modules.Resolver;typeTMyResolver = class(TGocciaModuleResolver)publicfunction Resolve(const AModulePath, AImportingFilePath: string): string; override;end;function TMyResolver.Resolve(const AModulePath, AImportingFilePath: string): string;begin// Custom logic here — fall back to inherited for standard resolutionResult := inherited Resolve(AModulePath, AImportingFilePath);end;varResolver: TMyResolver;Engine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;beginResolver := TMyResolver.Create('/path/to/project');Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Resolver, Executor);tryEngine.Execute;finallyEngine.Free;end;finallyExecutor.Free;Resolver.Free; // caller owns the resolver when injectedend;end;
When no custom resolver is provided, the engine creates a default TGocciaModuleResolver whose base directory is the entry file's directory.
Custom Content Provider
Resolution and content loading are separate concerns. A custom resolver decides which module path to load; a custom content provider decides how the module text or JSON is retrieved once that path is resolved.
Use TGocciaModuleContentProvider when your modules come from memory, an archive, a database, or some other non-filesystem source:
usesClasses,SysUtils,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Modules.ContentProvider,Goccia.Modules.Loader,Goccia.Modules.Resolver;typeTMemoryResolver = class(TGocciaModuleResolver)publicfunction Resolve(const AModulePath, AImportingFilePath: string): string; override;end;TMemoryContentProvider = class(TGocciaModuleContentProvider)publicfunction Exists(const APath: string): Boolean; override;function LoadContent(const APath: string): TGocciaModuleContent; override;function TryGetLastModified(const APath: string;out ALastModified: TDateTime): Boolean; override;end;function TMemoryResolver.Resolve(const AModulePath,AImportingFilePath: string): string;beginResult := AModulePath;end;function TMemoryContentProvider.Exists(const APath: string): Boolean;beginResult := APath = 'memory:/dep.js';end;function TMemoryContentProvider.LoadContent(const APath: string): TGocciaModuleContent;beginif APath = 'memory:/dep.js' thenExit(TGocciaModuleContent.Create('export const value = 42;', 0));raise Exception.Create('Module content not found: ' + APath);end;function TMemoryContentProvider.TryGetLastModified(const APath: string;out ALastModified: TDateTime): Boolean;beginALastModified := 0;Result := False;end;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;ModuleLoader: TGocciaModuleLoader;Resolver: TMemoryResolver;Provider: TMemoryContentProvider;beginResolver := TMemoryResolver.Create;Provider := TMemoryContentProvider.Create;Executor := TGocciaInterpreterExecutor.Create;tryModuleLoader := TGocciaModuleLoader.Create('memory:/app.js', Resolver,Provider);tryEngine := TGocciaEngine.Create('memory:/app.js', Source,ModuleLoader, Executor);tryEngine.Execute;finallyEngine.Free;end;finallyModuleLoader.Free; // caller owns injected module loadersend;finallyExecutor.Free;Provider.Free; // caller owns injected providersResolver.Free;end;end;
TGocciaEngine also accepts an injected module loader via its constructor. When no loader is supplied, it creates a default TGocciaModuleLoader with the standard resolver but no filesystem content provider. TGocciaRuntime installs the filesystem provider when attached unless AttachRuntime(Engine, False) is used. Untrusted-source hosts should pass False and supply virtual modules, host modules, or a bounded custom content provider explicitly. Core-language-only embedders that need imports should inject their own provider. With no provider installed, a module load that gets as far as retrieval is refused with a script-catchable Error carrying code === "ERR_MODULE_LOADING_UNSUPPORTED" rather than a Pascal exception. That covers retrieval only: resolution runs first, so a specifier the resolver rejects — with the default resolver, one whose file is absent from the host filesystem — fails before the provider is consulted, carries no code, and raises TGocciaRuntimeError across the engine boundary for a static import. Keep guarding the boundary; see Module loading errors.
Virtual Modules
Prefer virtual modules for host configuration consumed through ordinary ES imports. See Virtual Module Configuration for their schema and behavior.
Host Modules
Host modules expose values created programmatically by an embedder. They are checked before filesystem resolution:
usesGoccia.Modules;varModule: TGocciaModule;beginModule := TGocciaModule.Create('my-lib');Module.ExportsTable.Add('version', TGocciaStringLiteralValue.Create('1.0.0'));Engine.RegisterHostModule('my-lib', Module);end;
Scripts can then import from the host module by name:
import { version } from "my-lib";console.log(version); // "1.0.0"
RegisterHostModuleProvider is lazy; former global module names remain supported without warnings.
Console Output Capture
By default, console.log and friends write directly to stdout. The OutputCallback property lets you intercept all console output programmatically:
usesClasses,Goccia.Builtins.Console,Goccia.Runtime;typeTMyLogger = classprocedure OnConsoleOutput(const AMethod, ALine: string);end;procedure TMyLogger.OnConsoleOutput(const AMethod, ALine: string);begin// AMethod is 'log', 'warn', 'error', 'info', 'debug', 'dir',// 'assert', 'count', 'timeEnd', 'timeLog', 'trace', 'table', 'group'// ALine is the fully formatted output stringWriteLn('[', AMethod, '] ', ALine);end;varConsoleExtension: TGocciaConsoleRuntimeExtension;Runtime: TGocciaRuntime;Source: TStringList;Logger: TMyLogger;beginLogger := TMyLogger.Create;Source := TStringList.Create;Source.Text := 'console.log("hello"); console.warn("careful");';Runtime := TGocciaRuntime.Create('app.js', Source);tryApplyLoaderRuntimeProfile(Runtime.Core);ConsoleExtension := TGocciaConsoleRuntimeExtension(Runtime.FindRuntimeExtension(TGocciaConsoleRuntimeExtension));ConsoleExtension.BuiltinConsole.OutputCallback := Logger.OnConsoleOutput;Runtime.Execute;// Output:// [log] hello// [warn] Warning: carefulfinallyRuntime.Free;Source.Free;Logger.Free;end;end;
The callback type is:
TGocciaConsoleOutputCallback = procedure(const AMethod, ALine: string) of object;
When OutputCallback is assigned, it takes priority over both OutputLines (the TStrings capture property) and the default WriteLn path. When not assigned, existing behavior is unchanged.
LogCallback (Independent Logging Channel)
LogCallback fires on every console call regardless of the Enabled flag, independent of the primary output path. This makes it safe for worker threads where Enabled is False to suppress stdout:
ConsoleExtension.BuiltinConsole.LogCallback := MyHandler.OnLog;ConsoleExtension.BuiltinConsole.Enabled := False; // no stdout, but LogCallback still fires
The CLI hosts based on TGocciaCLIApplication (ScriptLoader, TestRunner, BenchmarkRunner, and REPL) apply a runtime profile and use LogCallback internally for --log=<file>, which captures console output to a log file in [method] line format. The TestRunner silences workers via Enabled := False (not by replacing JS methods), so LogCallback fires on every console call even in parallel mode. File writes are serialized with a critical section so --log is thread-safe even with --jobs=N.
Built-in Registration
Core language built-ins (Math, Object, Array, JSON, Promise, Temporal, typed arrays, etc.) are registered by TGocciaEngine. Runtime globals that are not part of the language core (Console, TextEncoder/TextDecoder, URL, fetch, performance, etc.) and import-only runtime modules (goccia:csv, goccia:json5, goccia:jsonl, goccia:toml, goccia:tsv, goccia:yaml, goccia:semver, goccia:test) are provided by concrete runtime units. Hosts can call ApplyLoaderRuntimeProfile for the ordinary CLI runtime surface or install only the extension classes they need for a smaller runtime surface. ApplyLoaderRuntimeProfile(ARuntime, False) suppresses the goccia:test registration for a host that installs TGocciaTestingLibraryRuntimeExtension itself — the testing globals are opt-in through that extension and are never part of the profile. The goccia: modules expose named exports only; callers can use namespace imports such as import * as CSV from "goccia:csv" when they want namespace-style access.
When you already have an engine, pass it to the runtime constructor:
Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);Runtime := TGocciaRuntime.Create(Engine);tryApplyLoaderRuntimeProfile(Runtime.Core);Runtime.Execute;finallyRuntime.Free;Engine.Free;end;finallyExecutor.Free;end;
Passing an engine does not transfer ownership by default. Use TGocciaRuntime.Create(Engine, True) when the runtime should free the engine. The executor is always owned by the caller — free it after the engine.
Runtime Extensions
Runtime extensions are ordinary Pascal classes installed on TGocciaRuntimeCore:
| Extension/profile | Provides | Notes |
|---|---|---|
ApplyLoaderRuntimeProfile | ordinary CLI runtime surface: console, goccia: data-format/SemVer modules, text assets, performance, text encoding, URL/fetch, and related runtime globals | Used by ScriptLoader and REPL |
TGocciaTestingLibraryRuntimeExtension | describe, test, expect | Testing framework; TestRunner installs this through ApplyTestRunnerRuntimeProfile |
TGocciaBenchmarkRuntimeExtension | suite, bench | Benchmark framework; BenchmarkRunner installs this through ApplyBenchmarkRunnerRuntimeProfile |
TGocciaFFIRuntimeExtension | FFI.open, FFILibrary, FFIPointer | Native shared-library FFI; CLI tools install this for --unsafe-ffi or "unsafe-ffi": true in config |
When embedding, install TGocciaFFIRuntimeExtension to enable the FFI global. CLI tools (ScriptLoader, REPL, TestRunner, BenchmarkRunner, Bundler) expose this as the --unsafe-ffi flag and matching "unsafe-ffi" config key.
To add the test framework for a custom test runner:
Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('tests/my-test.js', Source, Executor);Runtime := TGocciaRuntime.Create(Engine, True);tryApplyTestRunnerRuntimeProfile(Runtime.Core);Runtime.Execute;finallyRuntime.Free;end;finallyExecutor.Free;end;
Runtime globals and runtime modules can be reduced by installing only concrete extensions, for example Runtime.Install(TGocciaConsoleRuntimeExtension.Create).
Preprocessors and Compatibility
| System | Type | Default | Purpose |
|---|---|---|---|
Preprocessors | TGocciaPreprocessors | [ppJSX] | Source transformations before parsing |
Compatibility | TGocciaCompatibilityFlags | [] | ECMAScript conformance and legacy-behavior toggles; leave empty for the recommended defaults |
WarningUnsupportedFeatures | Boolean | False | Parser diagnostic policy for disabled syntax; True restores warning/no-op recovery without enabling compatibility semantics |
SourceType | TGocciaSourceType | stScript | Load entry as script source (default) or module source; .mjs and .mts file names infer stModule |
StrictTypes | Boolean | False | Runtime enforcement of type annotations (works in both interpreter and bytecode); setter propagates to the active executor and interpreter scope |
Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);Engine.Preprocessors := []; // Disable JSXEngine.Compatibility := [cfASI,cfNonStrictMode,cfArgumentsObject,cfLabel,cfTraditionalFor,cfForIn,cfWhileLoops,cfExperimentalJSModuleSource]; // Enable selected source-pipeline flagsEngine.WarningUnsupportedFeatures := True; // Optional migration mode for disabled syntax diagnosticsEngine.SourceType := stModule; // Run entry as module source (top-level this is undefined; import.meta resolves)Engine.StrictTypes := True; // Enforce type annotations in both execution modesfinallyExecutor.Free;end;
When SourceType is stModule, Execute runs the entry program in a fresh module scope (skModule) with this = undefined, mirroring the semantics imported modules already receive from the module loader (ES2026 §16.2.1.6.4). The CLI surface for this is --source-type=script|module and the matching goccia.json key "source-type". Without an explicit source type, .mjs and .mts entry files are loaded as module source.
Top-level binding persistence differs between source types. With SourceType = stScript, the engine reuses Interpreter.GlobalScope across every call to Execute, which is what makes the long-lived engine pattern above work: const x = 42 defined in one Execute is visible to the next. With SourceType = stModule, each Execute allocates a brand-new skModule child scope, so top-level let/const/class declarations live and die with that single call. If callers need cross-Execute persistence, keep SourceType at stScript (the default) or expose the desired symbols through the global scope (e.g. Engine.RegisterGlobal(...) or Engine.Interpreter.GlobalScope.DefineLexicalBinding(...)).
Injecting Custom Globals
You can inject Pascal functions and values into the script's global scope by working with the engine's interpreter scope directly.
Injecting a Value
usesClasses,SysUtils,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Scope,Goccia.Values.Primitives;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;Source: TStringList;beginSource := TStringList.Create;Source.Text := 'APP_VERSION;';Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);try// Inject a constant into the global scopeEngine.Interpreter.GlobalScope.DefineLexicalBinding('APP_VERSION',TGocciaStringLiteralValue.Create('1.2.3'),dtConst);Engine.Execute; // evaluates "1.2.3"finallyEngine.Free;end;finallyExecutor.Free;Source.Free;end;end;
Injecting a Native Function
Native functions are Pascal methods exposed to GocciaScript. They receive arguments and a this value, and return a TGocciaValue.
usesClasses,DateUtils,SysUtils,Goccia.Arguments.Collection,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Scope,Goccia.Values.NativeFunction,Goccia.Values.Primitives;typeTMyHost = classfunction GetTimestamp(AArgs: TGocciaArgumentsCollection;AThisValue: TGocciaValue): TGocciaValue;end;function TMyHost.GetTimestamp(AArgs: TGocciaArgumentsCollection;AThisValue: TGocciaValue): TGocciaValue;beginResult := TGocciaNumberLiteralValue.Create(DateTimeToUnix(Now));end;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;Source: TStringList;Host: TMyHost;Func: TGocciaNativeFunctionValue;beginHost := TMyHost.Create;Source := TStringList.Create;Source.Text := 'getTimestamp();';Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);try// Create a native function: callback, name, arity (-1 for variadic)Func := TGocciaNativeFunctionValue.Create(Host.GetTimestamp, 'getTimestamp', 0);Engine.Interpreter.GlobalScope.DefineLexicalBinding('getTimestamp', Func, dtConst);Engine.Execute;finallyEngine.Free;end;finallyExecutor.Free;Source.Free;Host.Free;end;end;
The callback signature is:
TGocciaNativeFunctionCallback = function(Args: TGocciaArgumentsCollection;ThisValue: TGocciaValue): TGocciaValue of object;
Args— The arguments collection. UseArgs.LengthandArgs.GetElement(I)to access arguments.ThisValue— Thethisbinding (relevant for method calls).- Return value — Must be a
TGocciaValue. UseTGocciaUndefinedLiteralValue.UndefinedValuefor void functions.
Injecting a Native Object with Methods
For a more structured API, create a TGocciaObjectValue and register native methods on it:
usesClasses,SysUtils,Goccia.Arguments.Collection,Goccia.Engine,Goccia.Executor.Interpreter,Goccia.Scope,Goccia.Values.NativeFunction,Goccia.Values.ObjectValue,Goccia.Values.Primitives;typeTFileSystemAPI = classfunction ReadFile(AArgs: TGocciaArgumentsCollection;AThisValue: TGocciaValue): TGocciaValue;function Exists(AArgs: TGocciaArgumentsCollection;AThisValue: TGocciaValue): TGocciaValue;end;function TFileSystemAPI.ReadFile(AArgs: TGocciaArgumentsCollection;AThisValue: TGocciaValue): TGocciaValue;varPath: string;Content: TStringList;beginPath := AArgs.GetElement(0).ToStringLiteral.Value;Content := TStringList.Create;tryContent.LoadFromFile(Path);Result := TGocciaStringLiteralValue.Create(Content.Text);finallyContent.Free;end;end;function TFileSystemAPI.Exists(AArgs: TGocciaArgumentsCollection;AThisValue: TGocciaValue): TGocciaValue;beginResult := TGocciaBooleanLiteralValue.Create(FileExists(AArgs.GetElement(0).ToStringLiteral.Value));end;varEngine: TGocciaEngine;Executor: TGocciaInterpreterExecutor;Source: TStringList;API: TFileSystemAPI;FSObject: TGocciaObjectValue;beginAPI := TFileSystemAPI.Create;Source := TStringList.Create;Source.Text := 'fs.readFile("data.txt");';Executor := TGocciaInterpreterExecutor.Create;tryEngine := TGocciaEngine.Create('app.js', Source, Executor);tryFSObject := TGocciaObjectValue.Create;FSObject.RegisterNativeMethod(TGocciaNativeFunctionValue.Create(API.ReadFile, 'readFile', 1));FSObject.RegisterNativeMethod(TGocciaNativeFunctionValue.Create(API.Exists, 'exists', 1));Engine.Interpreter.GlobalScope.DefineLexicalBinding('fs', FSObject, dtConst);Engine.Execute;finallyEngine.Free;end;finallyExecutor.Free;Source.Free;API.Free;end;end;
Scripts can then call fs.readFile("path") and fs.exists("path") as if they were built-in.
Reading Return Values
TGocciaEngine.RunScript, TGocciaRuntime.RunScript, and Execute return a TGocciaScriptResult record. Its Result field holds the last evaluated expression as a TGocciaValue; use the type conversion methods on that value to extract Pascal values:
varScriptResult: TGocciaScriptResult;beginScriptResult := TGocciaEngine.RunScript('40 + 2;');// Type checkingif ScriptResult.Result is TGocciaNumberLiteralValue thenWriteLn(TGocciaNumberLiteralValue(ScriptResult.Result).Value); // 42.0// Generic conversion methods (available on all TGocciaValue)WriteLn(ScriptResult.Result.ToStringLiteral.Value); // "42"WriteLn(ScriptResult.Result.ToNumberLiteral.Value); // 42.0WriteLn(ScriptResult.Result.ToBooleanLiteral.Value); // TrueWriteLn(ScriptResult.Result.TypeOf); // "number"end;
Value Type Hierarchy
| Pascal Class | JavaScript Type | Key Properties |
|---|---|---|
TGocciaNumberLiteralValue | number | .Value: Double, .IsNaN, .IsInfinity |
TGocciaStringLiteralValue | string | .Value: string |
TGocciaBooleanLiteralValue | boolean | .Value: Boolean |
TGocciaNullLiteralValue | null | Singleton via .NullValue |
TGocciaUndefinedLiteralValue | undefined | Singleton via .UndefinedValue |
TGocciaObjectValue | object | .GetProperty(Name), .AssignProperty(Name, Value) |
TGocciaArrayValue | object (array) | .Elements: TGocciaValueList |
TGocciaFunctionValue | function | .Call(Args, ThisValue) |
TGocciaInstanceValue | object (class instance) | .ClassValue, .GetProperty(Name) |
Error Handling
GocciaScript errors surface as Pascal exceptions. See Errors for the JavaScript-side error types, display format, and JSON output envelope. On the Pascal side, wrap execution in try...except:
usesSysUtils,Goccia.Engine,Goccia.Error;tryTGocciaEngine.RunScript('undeclaredVariable;');excepton E: TGocciaRuntimeError doWriteLn('Runtime error: ', E.Message);on E: TGocciaLexerError doWriteLn('Lexer error: ', E.Message);on E: TGocciaSyntaxError doWriteLn('Syntax error: ', E.Message);on E: Exception doWriteLn('Unexpected error: ', E.Message);end;
TGocciaLexerError inherits from TGocciaSyntaxError, so catching TGocciaSyntaxError alone covers both lexer and parser errors. Catch TGocciaLexerError first only when you need to distinguish them for diagnostics.
| Exception Class | When |
|---|---|
TGocciaSyntaxError | All early errors (base class for lexer and parser failures) |
TGocciaLexerError | Invalid tokens (unterminated strings, invalid characters) — subclass of TGocciaSyntaxError |
TGocciaRuntimeError | Execution errors (type errors, reference errors, throw statements) |
TGocciaTypeError | Type-specific runtime error |
TGocciaReferenceError | Undefined variable access |
TGocciaThrowValue | JavaScript throw — wraps any thrown value including RangeError |
EObjectCheck, EAccessViolation, EInvalidPointer, EDivByZero, EPrivilege, EExternalException | Engine-integrity faults. The engine re-raises these past every guest catch, so they escape Engine.Execute even while guest code is running: they mean a pointer, a mapping, or the heap is no longer trustworthy. Report and exit — do not resume the process, and do not treat one as a script failure. EOutOfMemory is deliberately not one of them and stays catchable. See ADR 0109 |
Execution Limits
Two mechanisms prevent runaway scripts: wall-clock timeouts and instruction limits. Both use thread-local storage and are safe with parallel workers — each thread gets its own independent counter.
Timeout
StartExecutionTimeout arms a wall-clock deadline. ClearExecutionTimeout disarms it. The check is sampled (every 1024th call site) to minimize overhead.
usesGoccia.Timeout;StartExecutionTimeout(5000); // 5 000 mstryEngine.Execute;finallyClearExecutionTimeout;end;
Raises TGocciaTimeoutError when the deadline is exceeded. A value of zero disables the timeout.
Instruction Limit
StartInstructionLimit caps the number of execution steps. In bytecode mode the counter increments on every dispatched instruction (exact). In interpreter mode it increments at function-call and loop-iteration checkpoints (approximate).
usesGoccia.InstructionLimit;StartInstructionLimit(1000000); // 1 000 000 stepstryEngine.Execute;finallyClearInstructionLimit;end;
Raises TGocciaInstructionLimitError when the limit is reached. A value of zero (the default) skips all counter increments and limit comparisons — only the guard read of GMaxInstructions remains on the hot path.
Call Stack Depth Limit
SetMaxStackDepth caps the number of nested function calls. Exceeding the limit throws a JavaScript RangeError with the message "Maximum call stack size exceeded" (matching V8 convention). The default is 2 900 frames. A value of zero disables the limit entirely.
In bytecode mode the VM uses a trampoline: bytecode-to-bytecode calls are dispatched iteratively via an explicit frame stack, so the Pascal call stack stays flat regardless of JS call depth. The interpreter mode uses Pascal recursion and relies on the depth check to prevent overflow.
Native re-entries into the bytecode VM — generator resume, host eval, and native callbacks such as Array iteration methods or sort comparators — run the bytecode loop on a fresh Pascal stack frame instead of the trampoline. These are bounded separately by a fixed native re-entry cap (MAX_NATIVE_REENTRY_DEPTH in Goccia.StackLimit), which throws the same RangeError well before the native stack can overflow. This is independent of SetMaxStackDepth/--stack-size, which bounds the much cheaper trampolined frames; it ensures that, for example, infinite recursion mediated by a generator throws rather than crashing the engine.
usesGoccia.StackLimit;SetMaxStackDepth(5000); // custom limit// SetMaxStackDepth(0); // no limitEngine.Execute;
The CLI tools expose all three limits as options; see Build System — Run Commands for usage.
FPU Exception Mask
FPU exceptions — divide-by-zero, overflow, underflow, invalid operation, denormalized operand, and precision loss — are hardware signals raised by the floating-point unit when an operation produces a special result. By default, FreePascal leaves some of these unmasked, which causes runtime exceptions on operations like 0.0 / 0.0 instead of returning NaN.
Both TGocciaEngine and TGocciaVM mask all FPU exceptions on creation (via SetExceptionMask) to enable IEEE 754 semantics (NaN, Infinity, -0). The previous mask is saved in the constructor and restored in the destructor, so the host application's FPU state is not permanently altered. This is transparent for one-shot execution (RunScript), but embedders creating long-lived engine instances should be aware that FPU exceptions are suppressed while the engine exists. If the host application depends on FPU exception handlers for its own error handling, those handlers will not fire while a TGocciaEngine or TGocciaVM instance is alive.
Microtask Queue (Promises)
The engine initializes a singleton microtask queue (TGocciaMicrotaskQueue) alongside the GC. Promise .then() callbacks are enqueued as microtasks and drained automatically after each Execute or ExecuteProgram call. Fetch completions are also pumped before these calls return, then their Promise reactions drain through the same microtask queue. Embedders do not need to drain either queue manually.
This means:
- All synchronous code in the script runs to completion first.
- All pending
.then()callbacks fire after the script finishes. - Chained
.then()handlers are processed in the same drain cycle. - On successful execution, pending
fetch()requests complete beforeExecutereturns; if execution throws, pending fetches are detached and late completions are discarded. The microtask queue is still only used for Promise reactions, not for network I/O.
The execution ordering follows ECMAScript specification semantics — the script is one macrotask, and microtasks drain after it completes. Thenable adoption is deferred via a microtask per the spec's PromiseResolveThenableJob.
// Promises work automatically — no manual queue management neededSource.Text := 'Promise.resolve(42).then((v) => { globalThis.answer = v; });';Engine.Execute; // microtasks drain before Execute returns
For long-lived engines (REPL-style), each Execute call drains its own microtasks. Promise callbacks from one execution will not leak into the next — even if the script throws an exception, the engine clears any pending microtasks in a finally block.
Garbage Collector
The engine initializes a mark-and-sweep garbage collector (TGarbageCollector) automatically. In most embedding scenarios, no manual GC interaction is needed. The GC collects unreachable values during execution.
For long-running engines (REPL-style), the GC runs automatically. If you need to trigger collection manually between script executions:
usesGoccia.GarbageCollector;TGarbageCollector.Instance.Collect;
Memory ceiling: The GC auto-detects physical memory and defaults to half of RAM, capped at 8 GB on 64-bit or 700 MB on 32-bit (512 MB fallback when detection fails). Override with MaxBytes to impose a custom limit. Allocations exceeding it raise a JavaScript RangeError:
TGarbageCollector.Instance.MaxBytes := 10 * 1024 * 1024; // 10 MB limit
Temporary roots: If your Pascal code holds references to TGocciaValue objects outside of any GocciaScript scope (e.g., in a Pascal list while the engine runs), protect them from collection:
TGarbageCollector.Instance.AddTempRoot(MyValue);tryEngine.Execute; // MyValue won't be collected during executionfinallyTGarbageCollector.Instance.RemoveTempRoot(MyValue);end;
Existing Embeddings
The repository includes five embedding examples:
| Program | File | Description |
|---|---|---|
GocciaScriptLoader | source/app/GocciaScriptLoader.dpr | Executes source files (.js, .jsx, .ts, .tsx, .mjs, .mts) from disk or stdin, with optional JSON output, injected globals, and execution timeouts for one-shot automation |
GocciaREPL | source/app/GocciaREPL.dpr | Interactive read-eval-print loop (long-lived engine) |
GocciaTestRunner | source/app/GocciaTestRunner.dpr | Runs test suites with the test-runner runtime profile |
GocciaBenchmarkRunner | source/app/GocciaBenchmarkRunner.dpr | Runs benchmarks with the benchmark-runner runtime profile from files or stdin |
GocciaBundler | source/app/GocciaBundler.dpr | Bundler CLI host — compiles source files to .gbc without execution |
These serve as reference implementations for the patterns described above.
Application Base Class
TGocciaApplication (Goccia.Application.pas) provides the standard lifecycle for any GocciaScript host application. It manages GC initialization/shutdown and unified error handling, with no CLI dependency.
typeTMyApp = class(TGocciaApplication)protectedprocedure Execute; override;end;procedure TMyApp.Execute;begin// Your application logic here// GC is already initialized; errors are caught by HandleErrorend;beginExitCode := TGocciaApplication.RunApplication(TMyApp, 'MyApp');end.
What the base class handles:
TGarbageCollector.Initialize/Shutdownlifecycle- Exception dispatch via virtual
HandleError(supportsTGocciaError,TGocciaThrowValue,EGocciaBytecodeThrowwith full source context and colored output) - Exit code management (0 = success, 1 = error)
Overridable hooks:
Execute(abstract) — your application logicHandleError(AException)— customize error display (e.g., JSON output)
For CLI tools, use TGocciaCLIApplication instead, which adds argument parsing, help generation, singleton lifecycle management, and a ConfigureCreatedEngine hook where tools attach TGocciaRuntimeCore and apply their chosen runtime profile or runtime extensions.
Minimal Embedding Checklist
- Add
source/units/andsource/shared/to your FreePascal unit search path (or useconfig.cfg) uses Goccia.Runtime, Goccia.Values.Primitives;- Create
TGocciaRuntime.Create(...)for the runtime layer and file loading - Use
Runtime.Enginefor engine-level options such as ASI, source type, parser diagnostic policy, and compatibility flags - Choose your runtime surface, runtime globals, and
goccia:modules via runtime profiles or runtime extensions - Inject custom globals via
Runtime.Engine.Interpreter.GlobalScope.DefineLexicalBinding(...) - Handle exceptions from
Goccia.Error - Free the runtime when done; it owns and frees the engine only when it created the engine itself, or when you used an ownership-transfer overload such as
TGocciaRuntime.Create(Engine, True).TGocciaRuntime.Create(Engine)is non-owning by default, so embedders wrapping an existing engine must also free that engine.
Prefer virtual modules; global injection remains supported without warnings.