Embedding VJSX
September 10, 2026 ยท View on GitHub
This document focuses on the host-first embedding path:
- a V host exposes explicit capabilities to JS/TS
- JS/TS extensions call those host capabilities
- the V host loads JS/TS modules and plugins back through stable handles
This is the recommended direction when vjsx is embedded into another project.
It is intentionally narrower than "full Node compatibility".
Goal
For most embedders, the practical target is:
- create one managed session
- install a formal host API
- load one JS/TS file as an extension
- call lifecycle hooks or exported functions from the host
- close the session explicitly
That is enough for many extension systems. You do not need every lower-level helper on day one.
If you want a concrete minimal example in this repository, start with:
examples/embedding_extension.vexamples/js/host_extension.mjs
If you want the next step up, with a host module and manifest-defined custom hook names, see:
examples/embedding_extension_manifest.vexamples/js/host_extension_manifest.mjs
Recommended Layers
1. runtimejs.ExtensionSession
Use this as the default embedder entrypoint.
It combines:
- a bridge-aware runtime session
- your installed host API
- bound host-context calls back into JS/TS
For most hosts, this should be the primary abstraction.
2. runtimejs.ExtensionHandle
Use this when one JS/TS file should behave like one extension instance.
It combines:
- lifecycle hooks:
activate(...),handle(...),dispose(...) - regular export calls:
call_export(...),call_export_method(...),call_default_method(...)
If your host has a plugin model, this is usually the object you want to keep.
3. vjsx.RuntimeSession (lower level)
Use this infrastructure directly only when you need custom runtime/context ownership or module composition beyond the extension facade.
Recommended Stopping Point
If you want to avoid over-design, stop here:
ExtensionSessionfor embeddingExtensionHandlefor loaded extensionsRuntimeSessiononly for lower-level control
That gives you a clear mental model without turning vjsx into a full plugin
platform framework.
Long-Lived And Concurrent Hosts
For a service that keeps extensions alive or accepts work from several V threads, configure engine limits and transfer the session to a lane:
mut session := runtimejs.new_node_runtime_session(vjsx.ContextConfig{},
vjsx.NodeRuntimeConfig{})
session.configure_engine_limits(vjsx.RuntimeEngineLimits{
memory_limit_bytes: 128 * 1024 * 1024
default_turn_timeout_ms: 5_000
})
mut lane := runtimejs.new_session_lane(session, runtimejs.SessionLaneConfig{
max_queue: 64
admission_wait_ms: 1_000
})
defer {
lane.close()
}
result := lane.run_turn(vjsx.RuntimeSessionTurnOptions{
kind: 'request'
}, fn (ctx &vjsx.Context) !vjsx.Value {
return ctx.eval('handleRequest()')
})!
defer {
result.free()
}
The lane owns the session after construction. Do not use the old session copy.
Use lane.snapshot(), lane.debug_snapshot(), and lane.observations() for
admission, lifecycle, memory, timing, and dropped-observation reporting.
This layer deliberately does not provide distributed ownership, persistence or heap snapshots. Persistent application state remains a host capability.
For the full production-hosting model, including lifecycle transitions, resource semantics, capability hardening, observations, migration, and non-goals, see Managed Runtime Hosting.
API Surface Guidance
Not every public helper should be treated as the same-level entrypoint.
Default Host Path
These are the APIs that most embedders should reach for first:
runtimejs.new_node_extension_session(...)runtimejs.new_script_extension_session(...)ExtensionSession.load_extension(...)ExtensionHandle.activate(...)ExtensionHandle.handle(...)ExtensionHandle.dispose(...)ExtensionHandle.call_export(...)ExtensionHandle.call_export_method(...)ExtensionHandle.call_default_method(...)
If your host can stay inside this set, that is usually a good sign.
Core Building Blocks
These are still important, but they are better treated as lower-level infrastructure than as the default everyday API:
vjsx.RuntimeSessionRuntimeSession.import_module(...)RuntimeSession.load_plugin(...)ScriptModuleScriptPlugin
They are useful when the host needs tighter control, but they should not be the first abstraction most embedders see.
Advanced Host-Context Helpers
These helpers are valid, but they are more specialized:
import_module_with_host(...)load_plugin_with_host(...)call_module_export_with_host(...)call_module_method_with_host(...)call_default_export_method_with_host(...)
They exist mainly so higher-level abstractions can be built cleanly.
For a host-facing embedding API, prefer ExtensionSession and
ExtensionHandle over exposing these directly.
Optional Metadata Layer
These are useful only when the host genuinely benefits from discovery or richer contracts:
ExtensionSession.describe_extension(...)- JS/TS
export const extension = { ... } - manifest
services ExtensionHandle.call_service(...)
This layer should stay optional. It is easy to over-invest in it too early.
Stability Notes
The embedding-related API surface is not all equally important.
Likely Long-Term Stable
These are the APIs that currently look like the right long-term host-facing surface:
runtimejs.new_node_extension_session(...)runtimejs.new_script_extension_session(...)ExtensionSessionExtensionHandlevjsx.HostApiConfigvjsx.host_value(...)vjsx.host_object(...)vjsx.host_module_exports(...)vjsx.host_module_object(...)
If another project embeds vjsx, this is the layer that should feel safest to
build around.
Stable As Infrastructure
These are important and should remain solid, but they are better understood as foundational building blocks than as the primary embedder API:
vjsx.RuntimeSessionScriptModuleScriptPluginRuntimeSession.import_module(...)RuntimeSession.load_plugin(...)RuntimeSession.bind_plugin(...)
Hosts may still use them directly, especially for custom integration work, but they are not the best first abstraction for most projects.
Public But Better De-Emphasized
These are useful internal composition helpers, yet they probably should not be promoted as the main embedding path:
import_module_with_host(...)load_plugin_with_host(...)call_module_export_with_host(...)call_module_method_with_host(...)call_default_export_method_with_host(...)
They are helpful because higher-level layers can be built on top of them cleanly. That does not necessarily mean embedders should treat them as the default API.
Optional Contract Features
These features can be valuable, but they should stay clearly optional:
ExtensionSession.describe_extension(...)- JS/TS manifest metadata
- manifest
services ExtensionHandle.call_service(...)
They are worth using when a host truly benefits from discovery and structured contracts. They should not become mandatory ceremony for simple embedding.
Suggested Documentation Posture
When documenting embedding, prefer this order:
ExtensionSessionExtensionHandleHostApiConfigand host buildersRuntimeSessionfor lower-level control- manifest and
servicesonly as optional enhancements
That ordering keeps the main story simple while still leaving room for advanced integrations.
Convergence Checklist
This is a practical cleanup checklist for future work. It is intentionally about convergence, not feature expansion.
Keep And Invest In
These are worth treating as the primary embedding surface:
runtimejs.new_node_extension_session(...)runtimejs.new_script_extension_session(...)runtimejs.ExtensionSessionruntimejs.ExtensionHandlevjsx.HostApiConfigvjsx.host_value(...)vjsx.host_object(...)vjsx.host_module_exports(...)vjsx.host_module_object(...)
For these APIs, the preferred work is:
- improve examples
- improve docs
- tighten lifecycle guarantees
- add focused tests around real embedder flows
Keep But Lower The Visibility
These are still useful, but they should gradually move out of the "main story" in docs and examples:
vjsx.RuntimeSessionScriptModuleScriptPluginRuntimeSession.import_module(...)RuntimeSession.load_plugin(...)RuntimeSession.bind_plugin(...)
For these APIs, the preferred work is:
- keep them correct
- keep them tested
- mention them as lower-level tools
- avoid making them the first thing new embedders see
Treat As Composition Helpers
These APIs are useful for internal layering, but they should not become the recommended path unless a host has a specific need:
import_module_with_host(...)load_plugin_with_host(...)call_module_export_with_host(...)call_module_method_with_host(...)call_default_export_method_with_host(...)
For these APIs, the preferred work is:
- preserve behavior
- avoid expanding the family unless clearly needed
- document them as advanced helpers, not the default surface
Keep Optional
These features should remain opt-in:
ExtensionSession.describe_extension(...)- JS/TS manifest metadata
- manifest
services ExtensionHandle.call_service(...)
For these APIs, the preferred work is:
- keep syntax lightweight
- avoid mandatory ceremony
- resist turning them into a full declarative plugin framework too early
Avoid For Now
These directions are the easiest ways to make the design too heavy too early:
- adding more parallel wrapper types for the same concepts
- making manifest metadata required for ordinary extension loading
- expanding
servicesinto a large schema system before real host demand exists - documenting low-level helpers and high-level helpers as if they had equal priority
- treating Node compatibility as the architecture instead of as support tooling
Suggested Next Code Cleanup
If code cleanup starts later, the likely order should be:
- keep the current API surface stable
- strengthen examples around
ExtensionSessionandExtensionHandle - reduce documentation emphasis on lower-level duplicate paths
- only then consider renames, moves, or soft deprecation notes
That sequence keeps current users safe while gradually making the public story simpler.
Host API Shape
Prefer exposing a small, explicit host API instead of many globals.
Useful building blocks:
vjsx.host_value(...)vjsx.host_object(...)vjsx.host_module_exports(...)vjsx.host_module_object(...)ctx.install_host_api(...)
Typical pattern:
import runtimejs
import vjsx
fn host_api() vjsx.HostValueBuilder {
return vjsx.host_object(
vjsx.HostObjectField{
name: 'app'
value: vjsx.host_object(
vjsx.HostObjectField{
name: 'name'
value: vjsx.host_value('demo-host')
},
)
},
vjsx.HostObjectField{
name: 'logger'
value: vjsx.host_object(
vjsx.HostObjectField{
name: 'prefix'
value: vjsx.host_value('log')
},
)
},
)
}
fn host_config() vjsx.HostApiConfig {
return vjsx.HostApiConfig{
globals: [
vjsx.HostGlobalBinding{
name: 'appName'
value: vjsx.host_value('demo-host')
},
]
}
}
fn main() {
mut extension_session := runtimejs.new_node_extension_session(
vjsx.ContextConfig{},
vjsx.NodeRuntimeConfig{
process_args: ['inline.js']
policy: vjsx.host_policy_safe()
},
host_config(),
host_api(),
)
defer {
extension_session.close()
}
}
ExtensionSession is the recommended path for hosted extensions. Its Node
runtime configuration carries HostPolicy all the way to the process and
child_process implementations. Use vjsx.host_policy_safe() for extensions
unless their source and every loaded bytecode artifact are trusted as much as
the host process.
The policy presets deliberately separate secure policy construction from backwards compatibility:
HostPolicy{}andhost_policy_safe()deny environment access, filesystem/network access, subprocesses, shell execution, process directory changes, and process exit.host_policy_trusted()explicitly enables the historical full surface.NodeRuntimeConfig{},NodeCompatConfig{}, and the legacyHostConfig{}retain the trusted policy by default so existing embedders keep their prior behavior. New extension hosts should override that default explicitly.
ScriptRuntimeConfig{} also carries the same policy. Its legacy
allow_env_write field remains as a one-way restriction for compatibility.
Partial HostPolicy{...} literals are deny-by-default; derive from
host_policy_trusted() when migrating a trusted host that only removes one
grant.
The node/script runtime profile identifies the installed JavaScript API and
bytecode compatibility contract; it is not a security boundary. HostPolicy
is the capability policy, and trusted bytecode can still exercise every
capability enabled by that policy.
Loading Extensions
The main host flow is:
mut extension := extension_session.load_extension('./extensions/search.mjs',
vjsx.ScriptPluginHooks{}) or { panic(err) }
defer {
extension.close()
}
activate := extension.activate('boot') or { panic(err) }
activate.free()
result := extension.call_export('search', 'hello') or { panic(err) }
defer {
result.free()
}
This keeps the host-facing API simple:
- the host owns one session
- each loaded file becomes one extension handle
- the host can use lifecycle hooks and regular exports from the same handle
JS/TS Manifest
Manifest support is optional.
An extension can declare metadata directly in JS/TS:
export const extension = {
name: "search-extension",
capabilities: ["search", "index"],
hooks: {
activate: "boot",
handle: "run",
dispose: "teardown",
},
};
Host-side behavior:
extension_session.describe_extension(path)reads the manifestextension_session.load_extension(path, vjsx.ScriptPluginHooks{})uses the declared hook names automatically- V-side
ScriptPluginHooksstill work as overrides
This is useful metadata, but it should stay lightweight.
Services
Manifest services support exists, but treat it as optional.
It is useful when:
- the host wants stable service names
- you do not want raw export names spread across the embedding code
It is not required for a healthy first embedding design. If plain
call_export(...) and call_export_method(...) are enough, keep using those.
Long-Lived Async Host Work
For I/O that outlives one JS call, start a session-owned operation while on the owner lane:
operation := lane.start_host_async_operation(vjsx.RuntimeHostAsyncOptions{
kind: 'search'
timeout_ms: 5_000
})!
// Return or attach operation.promise while still on the lane. A worker receives
// only these two plain-V handles:
completion := operation.completion
cancel := operation.cancel
The worker periodically checks cancel.is_cancelled() and finishes with
completion.resolve_text(...), resolve_bytes(...), reject(...), or
cancel(...). It must never receive or use the lane's Context or a Value.
Configure async_ready_fn to enqueue a host task that calls
lane.deliver_wakeup(0). Scheduled timer wakeups should pass their generation to
the same method so stale callbacks are harmless.
If JS supplied an AbortSignal, call operation.bind_abort_signal(signal) before
transferring the Promise. Timeout, AbortSignal, worker completion, and session
close are deterministic races: the first terminal event wins and later submits
return false.
For streaming producers, put frames through a
new_runtime_host_stream_mailbox(capacity). Stop reading the upstream source
when try_push() returns false; resume only after the lane drains frames. This
keeps memory bounded without requiring a fetch or child-process rewrite.
Set runtime_owned_timers: true when the host can deliver scheduled wakeups to
the lane. Existing embedders may leave it false and retain the QuickJS timer
path unchanged.
Practical Guidance
Prefer:
- explicit host APIs
- explicit
session.close() - one extension file -> one
ExtensionHandle - a small manifest with only the metadata you actually need
Be careful about:
- leaking runtimes or contexts in long-lived processes
- adding too many parallel abstractions before the host's real needs are clear
- turning Node-compat helpers into the main architecture
Current Recommendation
If you are embedding vjsx into another project today, start with:
runtimejs.new_node_extension_session(...)orruntimejs.new_script_extension_session(...)ctx.install_host_api(...)throughHostApiConfigextension_session.load_extension(...)ExtensionHandle.activate(...)/call_export(...)/dispose(...)
Only add manifest-level services or other richer contracts when the host
really benefits from them.