vjsx Runtime Contract
May 15, 2026 ยท View on GitHub
This document records the current embedding contract for vjsx as a stable JS
runtime platform. It focuses on ownership boundaries: what QuickJS owns, what
RuntimeSession owns, and what the host must still provide.
Ownership Boundary
QuickJS owns engine internals:
- JS heap and garbage collection
- stack limits and memory limits
- Promise jobs and microtasks
- the underlying
qjs:ostimer queue - pending job execution through
JS_ExecutePendingJob
vjsx owns session-level platform state:
RuntimeSessionlifecycle and idempotent close- event-loop facade state
- timer wakeup hints for host scheduling
- diagnostic records and diagnostic handlers
- profile metadata and installed module registry
- optional limits for
vjsxfacade state
The host owns platform scheduling and I/O:
- choosing where a session runs
- delivering scheduled wakeups
- calling
pump_once(),drain_ready_tasks(), orpump_until_idle()on the session-owning lane/thread - implementing host I/O such as HTTP, DB, filesystem, sockets, and queues
The important rule is: do not reimplement QuickJS queues in vjsx or the
host. Reuse QuickJS for JS jobs and timers; use RuntimeSession only to
express host-facing wakeup and diagnostic state.
QuickJS FFI Ownership Contract
The V/QuickJS boundary must treat ownership explicitly. Most pointers exposed
by V strings and QuickJS values are borrowed, not heap blocks owned by vjsx.
Rules for string pointers:
some_v_string.stris a borrowed pointer into V-managed storage. Do not callfree(...)on it.- If a C API needs a stable or mutable buffer beyond the immediate call, copy the bytes on the C side and free that C-owned copy there.
- Only free memory that was allocated by the matching allocator and whose ownership was explicitly transferred to the caller.
- QuickJS strings returned by
JS_ToCString(...)must be released withJS_FreeCString(...), not with Vfree(...).
Rules for QuickJS values:
- A returned
JSValueis owned by the caller and must eventually be released withJS_FreeValue(...), normally throughValue.free(). - A
JSValueConstis borrowed. Do not free it unless it has first been duplicated withJS_DupValue(...)or otherwise documented as caller-owned. - When a QuickJS API consumes a value, such as
JS_SetProperty*, do not free the consumed value again unless the API contract says it was not consumed. - Keep value ownership visible at wrapper boundaries. Prefer returning
vjsx.Valueonly when the wrapper clearly owns the underlyingJSValue.
Windows and MSVC are the strictest proving ground for this contract. The
quickjs-ng JSValue representation is ABI-sensitive on 64-bit platforms, and
MSVC heap checks catch borrowed-pointer frees that may appear to work on macOS.
For new FFI calls that return or transport JSValue, prefer a tiny C wrapper
with an out parameter when there is any ABI doubt, and cover it with a Windows
smoke test.
Do not paper over FFI crashes by permanently skipping host capabilities on one platform. Temporary platform guards are acceptable while isolating a native crash, but the final fix must either restore the capability or document a real unsupported platform boundary.
Event Loop Contract
RuntimeSession.configure_event_loop(...) defines the host/runtime boundary:
session_ididentifies the runtime to the host.now_fnprovides the time source.wake_fnasks the host to schedule a future wakeup.cancel_wake_fncancels a pending host wakeup.runtime_owned_timersis metadata for future runtime-owned timer work; it does not meanvjsxcurrently replaces QuickJS timers.
configure_event_loop(...) also installs the timer wakeup bridge used by the
JS timer wrapper. Hosts should not call install_timer_wakeup_bridge() directly.
Wakeup requests include a monotonically increasing generation. Hosts should
store both wake_at_ms and generation and ignore stale wakeups whose pair no
longer matches the latest pending request.
For lane-owned runtimes, the caller thread should not touch the lane-owned
RuntimeSession directly. The host should enqueue work back to the owning lane
and pump the session there.
Timer Contract
Global callback timers keep standard callback semantics:
setTimeout(callback, delay, ...args)
vjsx does not treat callback timer arguments as options. In particular,
setTimeout(cb, delay, { signal }) is not a supported cancellation API because
the third argument belongs to ...args.
Node-compatible promise timers are exposed through:
import { setTimeout } from "node:timers/promises";
await setTimeout(1000, "value", { signal });
node:timers/promises supports AbortSignal. It is implemented on top of the
existing global setTimeout / clearTimeout, so QuickJS still owns the real
timer queue.
Timer wakeup hints are not timers. They are vjsx facade state that lets the
host schedule an efficient lane/session wakeup instead of polling.
Diagnostics Contract
RuntimeSession records runtime diagnostics at facade boundaries such as:
pump_once()QuickJS job execution failuresresolve_value()rejected Promise resolutioncall()failurescall_global()missing global functionsvjsxfacade limit violations
Diagnostics are retained in a bounded ring buffer. The default maximum is
default_runtime_session_max_diagnostics.
Hosts may subscribe with:
session.set_diagnostic_handler(fn (diagnostic vjsx.RuntimeSessionDiagnostic) {
// log, metric, event, or host-specific error reporting
})
The handler is called synchronously after the diagnostic is recorded. It does not swallow the original error; facade APIs continue returning errors to their callers.
debug_snapshot() exposes lightweight diagnostic state, including error count,
last error message, dropped diagnostic count, wakeup state, and timer wakeup
hint state.
Limits Contract
RuntimeSessionLimits only limits vjsx facade state. It does not limit the
QuickJS heap, stack, Promise job queue, or underlying timer queue.
Currently supported limits:
max_diagnostics: maximum retained diagnostics. Older diagnostics are dropped when the ring buffer is full.max_timer_wakeup_hints: maximum retained timer wakeup hints.0means unlimited and is the default.
QuickJS resource controls remain the source of truth for engine-level limits, such as stack size and memory limits.
Profile Contract
Runtime capabilities are layered:
install_runtime_globals(...)installs reusable globals such asBuffer,URL,EventTarget, andAbortController.install_node_compat(...)installs Node-like host capabilities and modules.install_script_runtime(...)is a lightweight script profile.install_node_runtime(...)is the fuller Node-style profile.
runtime_profile_snapshot(ctx) returns the actual installed capability state.
It detects globals with typeof and modules through the Context module
registry. It does not import modules for detection.
The module registry is updated when ctx.js_module(name).create() succeeds.
Use:
snapshot := vjsx.runtime_profile_snapshot(ctx)
snapshot.matches(.node)
snapshot.missing_for(.node)
snapshot.infer_kind()
ctx.runtime_modules()
Profile kind inference is intended for diagnostics and tests. Hosts should still install the profile they need explicitly.
Runtime Asset Contract
Runtime JavaScript and TypeScript support files are vjsx implementation
details. Embedders should depend on the public vjsx / runtimejs APIs, not on
the repository layout or a copied thirdparty tree.
The loading boundary is:
- Source ownership lives in
vjsx. - Release binaries embed the runtime assets they need to run JS/TS entries.
VJSX_ASSET_ROOTandContextConfig.asset_rootare development override hooks only. They may replace an asset while developing, but production must not require them.- If an override file is absent, the embedded asset is the source of truth.
The embedded runtime asset set includes the Web/Node compatibility files under
web/js/ and the TypeScript runtime files:
thirdparty/typescript/lib/typescript.jsthirdparty/typescript/lib/vjs_ts_bootstrap.jsthirdparty/typescript/lib/vjs_ts_scan.jsthirdparty/typescript/lib/vjs_ts_commonjs.jsthirdparty/typescript/lib/vjs_ts_resolver.js
thirdparty/typescript/lib/typescript.js.gz is a generated binary-size helper
for embedding. It is not a separate runtime contract; callers still request the
logical asset path thirdparty/typescript/lib/typescript.js.
Third-party license and version records must remain in the vendored source tree:
thirdparty/typescript/package.jsonthirdparty/typescript/LICENSE.txtthirdparty/typescript/VERSION
Tests should cover both the asset registry and the release-style behavior where
asset_root points at an empty or incomplete directory and .ts / .mts
entries still run from embedded assets.
Host Integration Guidance
Hosts should:
- keep one clear owner for each
RuntimeSession - call runtime pump APIs only from the owning lane/thread
- use wakeup
generationto ignore stale scheduled wakeups - log or emit
RuntimeSessionDiagnosticthroughset_diagnostic_handler - inspect
debug_snapshot()when reporting session health - use
runtime_profile_snapshot(ctx)to verify installed capabilities
Hosts should not:
- maintain a second JS job queue
- treat timer wakeup hints as the source of timer truth
- call lane-owned sessions from arbitrary caller threads
- change global
setTimeoutsemantics to accept non-standard options - rely on dynamic imports to probe installed modules