.ts and .js modules from compiled Go at runtime. The compiler type-checks calls against a JavaScript/TypeScript index; at run time, a Node child process loads your exports over a closed RPC channel.
To call Forst from Node instead, see Call Forst from Node. For shared types only, see Generate client types.
Opt-in imports
TypeScript is never imported implicitly. Use theimport node modifier — aligned with Go syntax, distinct from import "strconv":
Namespace binding is always used (all exports on the module). There is no
import * as … from or // forst:node directive.
Wire opted-in modules in source and call them from main:
.ts. If ./payment.ts exists but you wrote a plain import "./payment", the compiler reports that TypeScript requires import node or import node alias.
Your legacy TypeScript stays ordinary source — no Forst annotations in .ts files:
Project boundary (ftconfig.json)
Node interop uses the same ftconfig.json boundary as forst dev and forst generate. Include .ts / .tsx in discovery so the indexer can see legacy modules:
Install
@forst/node-runtime in your project:
packages/node-runtime in forst-lang/forst.
needsNodeRuntime and deploy without Node
The compiler computes needsNodeRuntime from the whole linked program:
When
needsNodeRuntime is true, the compiler emits two Go files in the same package:
The Go runtime package is
forst/nodert in the forst-lang/forst repository. Generated companion files import forst/nodert. Add the forst Go module to your go.mod so that import resolves when you go build.
If no file in your build graph uses import node, the output is a normal Go binary with zero Node dependency — suitable for container images that must not ship Node.
Opt-in is per import, but Node requirement is whole-program: if package auth imports TS and main imports auth, the final binary still needs Node even when main.ft has no direct TS imports.
Build and run:
Runtime modes: bootstrap vs host
Node interop supports two runtime transports. Both use the same RPC protocol and manifest allowlist; only the spawn target and transport differ.Bootstrap mode (default)
Go spawns a dedicated Node child that runs only the Forst bootstrap script. RPC uses a local socket (default.forst/node-bootstrap.sock); child stdout and stderr are forwarded as logs. This is what task example:node-interop-sync and other bootstrap examples use.
Bootstrap process layout:
Host mode (shared memory)
Use host mode when the legacy app must share module cache, globals, and instrumentation state with Forst RPC (e.g. Prisma singletons, Remix server bundles). Readiness model: the host socket may listen before the app is safe for RPC. The ready file usesphase: "app"; Go dials only after app readiness is signaled.
Default (third-party shims like remix-serve): Go auto-injects a blocking register.mjs preload when hostMode is enabled (hostAutoRegister, default true). The preload is passed as a node --import flag on the direct shim spawn only — not via NODE_OPTIONS — so Vite/Remix worker processes do not each try to bind the host socket. For shims that do not need a custom init hook, omit hostAppReadyModule — register.mjs signals readiness as soon as the host socket is listening. Only set hostAppReadyModule when you need to run setup code before RPC (must be a tiny, side-effect-free module — never your full server bundle):
signalForstAppReady():
startForstNodeHost({ deferAppReady: true }) runs via auto-injected register.mjs before your entry module.)
Manual preload (advanced; opt out with hostAutoRegister: false):
Socket RPC environment variables
Go sets the following on Node children for both bootstrap and host mode. Bootstrap uses@forst/node-runtime/dist/bootstrap.js; host mode uses @forst/node-runtime/host and host/register.mjs. Do not set these manually in normal workflows.
Dev reload host ownership
Withnode.hostMode and forst dev watch reload:
forst dev(parent) spawns and owns the Node/Vite host before starting the compiledgo runchild.- Each reload
go runchild inheritsFORST_NODE_ATTACH_ONLY=1and dials the existing host socket — it never spawns a second host. - Reload stop uses process-group SIGTERM/SIGKILL on
go runonly; the host is not a descendant of that child, so Vite does not cold-start on every.ftsave. forst devexit always stops the host (whether the parent spawned it or reattached to an existing marker).
forst run with hostMode is unchanged: the compiled binary may spawn the host on first GetClient() when attach-only is unset.
Typical spawn layout:
FORST_NODE_SOCKET after FORST_NODE_HOST_READY reports phase: "app".
Shared memory semantics: RPC dispatcher, require/import cache, and globalThis state in the app process are visible to legacy TypeScript modules loaded via RPC. A separate bootstrap child would reload modules in an isolated process.
Failure modes:
Compile-time indexing always uses bootstrap mode — the indexer runs
forst-node-index CLI, never the app shim.
Security model
Node interop treats the Go↔Node boundary as a closed, allowlisted RPC surface.Compile-time manifest
WhenneedsNodeRuntime is true, Forst embeds forst-node-manifest-v1 in the generated Go binary. The manifest lists every (moduleId, exportName, kind) the bridge may invoke — for example legacy/payment.ts / create / function.
The Go bridge only emits calls for entries it compiled. The Node bootstrap re-checks every RPC against the same manifest before loading modules or calling exports.
Fixed bootstrap entrypoint (bootstrap mode)
In bootstrap mode (hostMode: false), Go spawns Node with a Forst-owned bootstrap script only — never a user .ts file as the process entry:
Typical spawn command:
import() inside the bootstrap, triggered by an allowed RPC. Bootstrap RPC uses a local socket (FORST_NODE_SOCKET / FORST_NODE_HOST_READY, default .forst/node-bootstrap.sock); runtime logs go to stderr and child stdout/stderr are forwarded to the Forst parent.
In host mode, Go spawns your app shim (node.binary + node.args) and connects via a local socket. The app must import @forst/node-runtime/host so RPC runs in-process. Manifest and path rules are unchanged.
Bootstrap resolution order:
FORST_NODE_BOOTSTRAPenvironment variable (absolute path)node_modules/@forst/node-runtime/dist/bootstrap.jsunder the boundary root- Checkout or vendor path when developing from the forst-lang/forst repository:
packages/node-runtime/dist/bootstrap.js
Closed RPC method set
The wire protocol negotiates onforst.node/initialize:
Only documented methods are accepted (
forst.node/initialize, forst.node/call, forst.node/callAsync, forst.node/genOpen, forst.node/genNext, forst.node/genNextBatch, forst.node/genClose, forst.node/shutdown, …). Unknown methods or manifest entries return forst.node/forbidden (-32001) before any user code runs.
Path rules reject .., absolute paths, and modules outside the boundaryRoot.
This boundary is for same-machine, same-trust-zone calls. For cross-service or remote clients, use the HTTP sidecar instead of widening the Node RPC surface.
Sync, async, and generators
The TypeScript indexer records exportkind. Forst lowers calls to the matching RPC:
Sync calls
Node function exports returnResult(T, Error) — the outer layer for RPC transport failures, Promise rejections, and manifest errors. Use ensure … is Ok() (or if … is Err()) before using the success value.
Sync call from Forst:
Async TypeScript from sync Forst
Phase 3callAsync: call an async function export from ordinary Forst code. The Go runtime awaits the Promise on the Node side before returning. The Forst type is still Result(T, Error) where T is the Promise element type.
The call site looks the same:
Two layers of failure
If a TypeScript
async function resolves to a tagged union or Result-like value, the indexer maps that as T; the outer call is Result(T, Error).
Multi-module async and generators (blocking)
Combine async RPC and async generator iteration in ordinary sync Forst — noasync func / await required:
Generators and Seq[T]
Generator exports open to Result(Seq[T], Error) — the same outer error model as point calls. Use ensure seq is Ok() before for range.
Seq[T] is the general pull-stream type at the Forst boundary (not Iterator / AsyncIterator). The indexed export kind (generator vs asyncGenerator) is compile-time only; iteration always uses blocking pull on the Node side.
Pull values from a generator export:
nodert.OpenSeq via companion wrappers. for range lowers to batched NextBatch pulls to cut RPC round trips.
Compile-time indexing
At compile time, Forst runs theforst-node-index CLI (@forst/node-runtime) and keeps the result in an in-memory IndexV1 — no .forst-index-v1.json sidecar files are written or required. The indexer records export kind, parameters, and return/yield types; the typechecker maps them to Forst types and builds the runtime allowlist manifest embedded in generated Go.
Requires Node and a built @forst/node-runtime at compile time when opted-in TS imports are present. Install from npm, or build from packages/node-runtime in the repository.
Type mapping from the index
The TypeScript indexer emitsforst-index-v1 type nodes; the Forst compiler maps them to Forst types for compile-time checking:
Generated Go uses thin
forst_node_* wrappers in the companion file; those call nodert.CallSync / nodert.CallAsync (or CallSyncArgs / CallAsyncArgs when call arguments are compile-time literals). for range over Seq[T] lowers to batched NextBatch pulls on the wrapper iterator type.
Union members that cannot be mapped, or unions with no members, widen to Object — the compiler emits a warning when this happens; narrow the TypeScript export or add explicit fields.
Troubleshooting
Enable debug logging on the Forst side to trace spawn, RPC, and forwarded Node stderr:
Migration from importPolicy: "implicit"
Older experiments may have:
implicit, relative imports can resolve .ts without a directive. That widens the compile graph and sets needsNodeRuntime whenever any reachable .ts module exists — even if authors did not intend Node at deploy time.
Recommended migration:
- Set
"importPolicy": "explicit"inftconfig.json. - Add
import node "./…"(orimport node alias "./…") to every TypeScript import. - Rebuild and confirm which binaries report Node required vs not required.
- Remove
implicitonce all imports are explicit.
Caveats
Node runtime interop is experimental. Pin@forst/node-runtime and verify examples before production use.
forst/nodert dependency
The Go bridge package is forst/nodert in forst-lang/forst. Your go.mod must resolve import "forst/nodert" before go build succeeds on binaries with needsNodeRuntime.
Whole program Node requirement
needsNodeRuntime is computed from the whole linked program. One opted-in import node anywhere in the compile graph means the final binary needs Node on PATH, even when main.ft has no direct TypeScript imports.
Closed allowlist RPC
The embedded manifest lists every(moduleId, exportName, kind) the bridge may call. Unknown exports return forst.node/forbidden before user code runs.
Bootstrap vs host mode
Bootstrap mode spawns an isolated Node child with socket RPC (default.forst/node-bootstrap.sock). Host mode runs RPC inside your app process over a socket (default .forst/node.sock) so module cache and globals stay shared.
Type mapping limits
TypeScript unions that cannot be mapped widen toObject. The compiler emits a warning. Narrow the export type or add explicit fields.
Related
Generate client types
Emit
.d.ts from Forst for Node clients.Mix with Go packages
Import Go packages and mix
.ft with .go.Call Forst from Node
Invoke Forst from Node — dev server or built-in HTTP server.
Dev server
forst dev HTTP contract for local iteration.CLI reference
run, build, -root, and compile flags.