diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fff1931ae..c697c699f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -387,7 +387,7 @@ jobs: - name: Check version sync run: | VERSION="${{ needs.check-release.outputs.version }}" - for pkg in packages/runtime packages/runtime-* packages/llvm-* packages/compiler packages/experimental packages/threads packages/types packages/cli packages/cli-*; do + for pkg in packages/runtime packages/runtime-* packages/llvm-* packages/compiler packages/experimental packages/node packages/threads packages/types packages/cli packages/cli-*; do if [ "$(node -p "require('./$pkg/package.json').private === true")" = "true" ]; then continue fi @@ -485,6 +485,7 @@ jobs: for pkg in packages/cli-*; do publish_dir "$pkg"; done publish_dir packages/compiler publish_dir packages/experimental + publish_dir packages/node publish_dir packages/threads publish_dir packages/types publish_dir packages/cli diff --git a/RELEASING.md b/RELEASING.md index 6cafe84a3..514f22429 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,11 +1,11 @@ # Releasing -Releases are manual, single-commit affairs. The maintainer controls the changelog voice and format. `@scriptc/runtime`, every platform helper/runtime pack, `@scriptc/compiler`, `@scriptc/experimental`, `@scriptc/threads`, and `scriptc` always publish together at the same version. +Releases are manual, single-commit affairs. The maintainer controls the changelog voice and format. `@scriptc/runtime`, every platform helper/runtime pack, `@scriptc/compiler`, `@scriptc/experimental`, `@scriptc/node`, `@scriptc/threads`, and `scriptc` always publish together at the same version. To prepare a release: 1. Bump the version in `packages/cli/package.json` -2. Run `node scripts/sync-versions.mjs` to stamp the same version into the runtime, helper, compiler, experimental, and threads packages, then `pnpm manifest` to restamp `packages/compiler/surface-manifest.json` with the new version, and commit both (the test suite's staleness guard fails on a version drift) +2. Run `node scripts/sync-versions.mjs` to stamp the same version into the runtime, helper, compiler, experimental, node, and threads packages, then `pnpm manifest` to restamp `packages/compiler/surface-manifest.json` with the new version, and commit both (the test suite's staleness guard fails on a version drift) 3. Fold the `## Unreleased` section of `CHANGELOG.md` into a new `## ` entry (newest first, below `## Unreleased`), and leave `## Unreleased` empty for the next cycle 4. Wrap the new entry in `` and `` markers; this marked block is also the GitHub release body 5. Remove the `` and `` markers from the previous release entry; only the latest release should have markers diff --git a/package.json b/package.json index ff4c84dc2..88709a137 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@scriptc/experimental": "workspace:*", + "@scriptc/node": "workspace:*", "@scriptc/threads": "workspace:*", "@scriptc/types": "workspace:*", "@types/node": "^24.0.0", diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index b5f99d682..6f13af366 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -59,6 +59,7 @@ import { import { dirname } from "node:path"; import { funcTypeFromParamShapes } from "../call-signatures.js"; import { threadsImportOf } from "../../threads-import.js"; +import { nodeApiBindingOf } from "../../node-api-import.js"; import { experimentalImportOf } from "../../experimental-import.js"; import { lowerWorkerMetadata } from "../builtins/workers.js"; import { esmImportBindingKind } from "../builtins/module-bindings.js"; @@ -460,6 +461,19 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: expr, "call publish(value) directly", ); + const nodeApi = nodeApiBindingOf(lowerer.program, expr); + if (nodeApi === "namespace") + lowerer.noLowering( + "the @scriptc/node namespace as a value", + expr, + "call its call and callSync members directly", + ); + if (nodeApi !== null) + lowerer.noLowering( + `${nodeApi} (@scriptc/node) as a value`, + expr, + `call ${nodeApi}(specifier, name, ...args) directly`, + ); if (experimentalImportOf(lowerer.program, expr) === "LifetimeRegion") lowerer.noLowering( "LifetimeRegion (@scriptc/experimental) as a value", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index d768cec9b..1f368e07f 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -14,6 +14,8 @@ import { lowerArrayCallbackCall } from "./containers/array-callback-call.js"; import { dynUndefinedExpr, nodeThrowExpr, countedFor, numLit, varRef } from "../../ir/build.js"; import { lowerBuiltinCall } from "./builtin-calls.js"; import { threadsImportOf } from "../threads-import.js"; +import { nodeApiCalleeOf } from "../node-api-import.js"; +import { lowerNodeApiCall } from "./lower-node-island.js"; import { adaptZeroArgTimerCallback, lowerTimersMemberCall, @@ -286,6 +288,12 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const codeGeneration = codeGenerationCalleeOf(lowerer, expr.expression); if (codeGeneration !== null) return lowerCodeGenerationCall(lowerer, expr, codeGeneration); } + // @scriptc/node call/callSync: a call into the Node island helper + // (lower-node-island.ts). + { + const nodeApi = nodeApiCalleeOf(lowerer.program, expr.expression); + if (nodeApi !== null) return lowerNodeApiCall(lowerer, expr, nodeApi); + } // Inline require() of a program module (cjs-inline-require.ts): a // member call over its export table resolves like a binding's member // call after the module's %init at this position; the require itself diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 6ca21ddaa..5bd0a2800 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1,4 +1,5 @@ import { THREADS_MODULE } from "../threads-import.js"; +import { NODE_API_MODULE } from "../node-api-import.js"; import { EXPERIMENTAL_MODULE } from "../experimental-import.js"; import { dynUndefinedExpr } from "../../ir/build.js"; import { everyStmtList, everyTypeChild } from "../../ir/traverse.js"; @@ -663,7 +664,12 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { // scriptc's own @scriptc/threads and @scriptc/experimental: their // exports lower at their use sites (threads-import.ts, // experimental-import.ts); their Node.js bodies never run. - if (npm?.packageName === THREADS_MODULE || npm?.packageName === EXPERIMENTAL_MODULE) continue; + if ( + npm?.packageName === THREADS_MODULE || + npm?.packageName === EXPERIMENTAL_MODULE || + npm?.packageName === NODE_API_MODULE + ) + continue; // An edge Node's RUNTIME resolution refuses at startup (types // resolved, but the exports target ships no JS — the types-only // package shape): preflight registered Node's startup crash for diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts new file mode 100644 index 000000000..080a6862c --- /dev/null +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -0,0 +1,129 @@ +/* Calls into Node islands. Milestone NI-1 of the scope-B plan has one kind: + * the explicit @scriptc/node API, `callSync(specifier, name, ...args)` and + * `call(...)`, which run an export of a module in Node.js loaded into the + * program (scr_node_island.c, libnode) and copy JSON data both ways. The + * libCalls are the only `nodeIsland.*` operations, so a program without + * them links none of that runtime (the `nodeIsland` runtime feature). + * + * Semantics, shared with packages/node/index.js (the Node implementation): + * the arguments are copied with JSON.stringify of the argument array; the + * module loads with require() from the calling module, so relative + * specifiers resolve from it; the result crosses as its JSON copy and is + * checked against the declared result type, like `JSON.parse(s) as T`; a + * thrown Error crosses as its nearest intrinsic class with its message and + * string `code`. */ +import { realpathSync } from "node:fs"; +import * as ts from "../ts7/adapter.js"; +import { locOf } from "../program.js"; +import type { NodeApiMember } from "../node-api-import.js"; +import { type Lowerer } from "./lowerer.js"; +import { DYN, STRING, type IrExpr, type IrType } from "../../ir/ir.js"; + +const JSON_HINT = + "only JSON data (numbers, strings, booleans, null, arrays, plain objects of those, and unknown) crosses into Node.js in this version"; + +/** The first part of `t` that is not JSON data, or null. `seen` holds the + * records and unions already being checked, so a recursive type (a JSON + * value type that contains arrays or records of itself) ends there. */ +function nonJsonPart(lowerer: Lowerer, t: IrType, seen: Set): IrType | null { + switch (t.kind) { + case "f64": + case "string": + case "bool": + case "nullT": + case "undefinedT": + case "dyn": + return null; + case "array": + return nonJsonPart(lowerer, t.elem, seen); + case "record": { + if (seen.has(`record:${t.shapeId}`)) return null; + seen.add(`record:${t.shapeId}`); + const shape = lowerer.shapes.get(t.shapeId); + if (shape === undefined) return t; + for (const field of shape.fields) { + const bad = nonJsonPart(lowerer, field.type, seen); + if (bad !== null) return bad; + } + return shape.indexValue === undefined ? null : nonJsonPart(lowerer, shape.indexValue, seen); + } + case "union": { + if (seen.has(`union:${t.unionId}`)) return null; + seen.add(`union:${t.unionId}`); + const def = lowerer.unions.get(t.unionId); + if (def === undefined) return t; + for (const arm of def.arms) { + const bad = nonJsonPart(lowerer, arm, seen); + if (bad !== null) return bad; + } + return null; + } + default: + return t; + } +} + +/** The calling module's real path: relative specifiers resolve from it, + * as Node's own loader resolves them from the real path of the module. */ +function callerPath(node: ts.Node): string { + const file = ts.getSourceFileOfNode(node).fileName; + try { + return realpathSync(file); + } catch { + return file; + } +} + +export function lowerNodeApiCall( + lowerer: Lowerer, + call: ts.CallExpression, + member: NodeApiMember, +): IrExpr { + const loc = locOf(call); + const surface = `${member} (@scriptc/node)`; + const argNodes = call.arguments.nodes; + if (call.questionDotToken || argNodes.length < 2 || argNodes.some(ts.isSpreadElement)) { + lowerer.noLowering( + `${surface} without a specifier and an export name, or with spread arguments`, + call, + "pass the module specifier, the export name and each argument explicitly", + ); + } + const specifier = lowerer.lowerExprExpecting(argNodes[0]!, STRING); + const name = lowerer.lowerExprExpecting(argNodes[1]!, STRING); + const values = argNodes.slice(2).map((arg) => { + const value = lowerer.lowerExpr(arg); + const bad = nonJsonPart(lowerer, value.type, new Set()); + if (bad !== null) + lowerer.noLowering(`passing '${lowerer.fmt(bad)}' values to ${surface}`, arg, JSON_HINT); + return lowerer.coerceToExpected(value, DYN); + }); + const declared = lowerer.mapTypeOf(lowerer.typeOf(call)); + const result = member === "call" && declared?.kind === "promise" ? declared.inner : declared; + if (declared === null || result === null || result === undefined) { + lowerer.noLowering(`${surface} with an unrepresentable result type`, call, JSON_HINT); + } + if (result.kind !== "void") { + const bad = nonJsonPart(lowerer, result, new Set()); + if (bad !== null) + lowerer.noLowering(`'${lowerer.fmt(bad)}' results of ${surface}`, call, JSON_HINT); + } + lowerer.usesNodeIsland = true; + const raw: IrExpr = { + kind: "libCall", + fn: member === "call" ? "nodeIsland.call" : "nodeIsland.callSync", + args: [ + { kind: "strLit", value: callerPath(call), type: STRING, loc }, + specifier, + name, + { kind: "dynArrLit", elems: values, type: DYN, loc }, + ], + type: member === "call" ? { kind: "promise", inner: DYN } : DYN, + loc, + }; + // A void result is discarded, unchecked like any void call's value. + if (declared.kind === "void") return raw; + // The JSON copy meets the declared type through the checked boundary of + // `JSON.parse(s) as T`: types don't lie, a mismatch throws. + return lowerer.coerceToExpected(raw, declared); +} diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 543149f83..cdb738617 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2937,6 +2937,9 @@ export class Lowerer { usesWasmCustomSections = false; /** The program constructs a LifetimeRegion (lifetime-regions.ts). */ usesLifetimeRegions = false; + /** The program calls into Node.js (@scriptc/node): the module carries + * the nodeIsland record, which the executable build fills in. */ + usesNodeIsland = false; /** Per-directory answers of experimental-import.ts's declaration-file test. */ readonly experimentalFiles = new Map(); /** Interned LifetimeRegion.run helpers, by callback type key. */ @@ -7285,6 +7288,7 @@ export class Lowerer { entry: ENTRY_NAME, ...(this.usesWorkers ? { workers: true } : {}), ...(this.usesLifetimeRegions ? { lifetimeRegions: true as const } : {}), + ...(this.usesNodeIsland ? { nodeIsland: { libnode: "", nodeVersion: "" } } : {}), ...(this.threadSlotKeys.size > 0 ? { threadSlotKeys: [...this.threadSlotKeys].sort() } : {}), diff --git a/packages/compiler/src/frontend/node-api-import.ts b/packages/compiler/src/frontend/node-api-import.ts new file mode 100644 index 000000000..04db2489b --- /dev/null +++ b/packages/compiler/src/frontend/node-api-import.ts @@ -0,0 +1,60 @@ +/* Provenance of the @scriptc/node exports. The package is scriptc's own + * (packages/node): Node.js runs its JavaScript implementation in-process, + * and scriptc builds lower its calls to calls into Node loaded into the + * program (lowering/lower-node-island.ts) and never compile its body. */ +import * as ts from "./ts7/adapter.js"; + +export const NODE_API_MODULE = "@scriptc/node"; + +export type NodeApiMember = "call" | "callSync"; + +function moduleSpecifierText(decl: ts.Node | undefined): string | null { + return ts.isImportDeclaration(decl) && ts.isStringLiteral(decl.moduleSpecifier) + ? decl.moduleSpecifier.text + : null; +} + +/** What an identifier binds from @scriptc/node: a named import of one of + * its functions, the namespace import (`import * as node`), or nothing. */ +export function nodeApiBindingOf( + program: ts.Program, + ident: ts.Identifier, +): NodeApiMember | "namespace" | null { + const symbol = program.getTypeChecker().getSymbolAtLocation(ident); + const decl = symbol ? symbol.declarations[0] : undefined; + if (decl === undefined) return null; + if (ts.isNamespaceImport(decl)) + return moduleSpecifierText(decl.parent?.parent) === NODE_API_MODULE ? "namespace" : null; + if (!ts.isImportSpecifier(decl)) return null; + if (moduleSpecifierText(decl.parent?.parent?.parent) !== NODE_API_MODULE) return null; + const member = decl.propertyName?.text ?? decl.name.text; + return member === "call" || member === "callSync" ? member : null; +} + +/** The @scriptc/node export an identifier is a named-import binding of. */ +export function nodeApiImportOf(program: ts.Program, ident: ts.Identifier): NodeApiMember | null { + const binding = nodeApiBindingOf(program, ident); + return binding === "namespace" ? null : binding; +} + +/** Whether an identifier is a namespace-import binding of @scriptc/node + * (`import * as node from "@scriptc/node"`). */ +export function isNodeApiNamespace(program: ts.Program, ident: ts.Identifier): boolean { + return nodeApiBindingOf(program, ident) === "namespace"; +} + +/** The @scriptc/node function a call's callee names: a named import + * (`callSync(...)`, also renamed) or a namespace member + * (`node.callSync(...)`). */ +export function nodeApiCalleeOf(program: ts.Program, callee: ts.Expression): NodeApiMember | null { + if (ts.isIdentifier(callee)) return nodeApiImportOf(program, callee); + if ( + !ts.isPropertyAccessExpression(callee) || + !ts.isIdentifier(callee.name) || + !ts.isIdentifier(callee.expression) + ) + return null; + const member = callee.name.text; + if (member !== "call" && member !== "callSync") return null; + return isNodeApiNamespace(program, callee.expression) ? member : null; +} diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 193e8adc0..cbd8c0cdd 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -44,6 +44,7 @@ import { import { moduleSourceCandidates } from "./module-source-candidates.js"; import { CONTRACT_TYPES_MODULE } from "./contract-types.js"; import { THREADS_MODULE } from "./threads-import.js"; +import { NODE_API_MODULE } from "./node-api-import.js"; import { erasedTypeOnlyImport } from "./type-only-imports.js"; import { EXPERIMENTAL_MODULE } from "./experimental-import.js"; @@ -251,7 +252,12 @@ function detectAutoPackages( // declarations only, and @scriptc/threads and @scriptc/experimental // exports lower at their use sites (threads-import.ts, // experimental-import.ts), typed by their declarations. - if (spec === CONTRACT_TYPES_MODULE || spec === THREADS_MODULE || spec === EXPERIMENTAL_MODULE) + if ( + spec === CONTRACT_TYPES_MODULE || + spec === THREADS_MODULE || + spec === EXPERIMENTAL_MODULE || + spec === NODE_API_MODULE + ) continue; // Attested entries already resolve to program sources, so the // published package's declaration eligibility does not apply. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6800-node-call-sync/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6800-node-call-sync/main.ts.json new file mode 100644 index 000000000..94cb098e3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6800-node-call-sync/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6800-node-call-sync/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6801-node-call-errors/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6801-node-call-errors/main.ts.json new file mode 100644 index 000000000..3ee43a3a7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6801-node-call-errors/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6801-node-call-errors/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6802-node-call-async/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6802-node-call-async/main.ts.json new file mode 100644 index 000000000..675680664 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6802-node-call-async/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6802-node-call-async/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6803-node-call-exit/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6803-node-call-exit/main.ts.json new file mode 100644 index 000000000..a835c1e1d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6803-node-call-exit/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6803-node-call-exit/main.ts" + ], + "diags": [] +} diff --git a/packages/node/README.md b/packages/node/README.md new file mode 100644 index 000000000..77002db49 --- /dev/null +++ b/packages/node/README.md @@ -0,0 +1,15 @@ +# @scriptc/node + +Call into Node.js from a scriptc program. `callSync` and `call` run a function exported by a JavaScript module and copy JSON data both ways. In scriptc builds the function runs in Node.js, which the program loads into its own process on the first call (libnode); under Node.js it runs directly, so the program prints the same either way. + +```ts +import { call, callSync } from "@scriptc/node"; + +const sum = callSync("./math.cjs", "add", 1, 2); +const config = await call<{ regions: string[] }>("config-parser", "parse", source); +``` + +- The module loads with `require()` from the calling module: relative specifiers resolve from its directory, bare ones from its `node_modules`. +- Arguments and results cross as JSON copies; scriptc checks the result against the declared type. +- A thrown `Error` crosses as its nearest intrinsic error class with its `message` and string `code`. +- The executable loads Node from the libnode pack recorded at build time (`SCRIPTC_LIBNODE` or `scriptc build --libnode=`; `--libnode=beside` copies the pack next to the executable). `SCRIPTC_LIBNODE` overrides it at run time. A program that never calls into Node never loads it. diff --git a/packages/node/index.d.ts b/packages/node/index.d.ts new file mode 100644 index 000000000..10642b098 --- /dev/null +++ b/packages/node/index.d.ts @@ -0,0 +1,36 @@ +/** Calls the function `name` exported by the module `specifier` and returns + * its result. + * + * In scriptc builds the call runs in Node.js loaded into the program on + * the first call (the libnode pack recorded at build time, or + * SCRIPTC_LIBNODE). Under Node.js it runs in the same process too. Either + * way: + * + * - the module loads with require() from the calling module, so a relative + * specifier resolves from the caller's directory and a bare one from its + * node_modules (ES modules load through require(esm)); + * - the arguments cross as a copy, `JSON.parse(JSON.stringify(args))`, and + * the function runs with the module namespace as `this`; + * - the result crosses as its JSON copy (undefined stays undefined), and + * scriptc checks it against `T` like `JSON.parse(text) as T`; + * - a thrown Error crosses as a new error of its nearest intrinsic class + * (Error, TypeError, RangeError, SyntaxError, ReferenceError, EvalError, + * URIError) with its message and string `code`; other thrown values cross + * as JSON copies. + * + * A function that returns a promise cannot be called synchronously: the + * call throws a TypeError. Use {@link call}. */ +export declare function callSync( + specifier: string, + name: string, + ...args: unknown[] +): T; + +/** Like {@link callSync}, asynchronously: the function may return a + * promise, whose result is copied when it settles. The returned promise + * rejects instead of throwing, also when the arguments cannot be copied. */ +export declare function call( + specifier: string, + name: string, + ...args: unknown[] +): Promise; diff --git a/packages/node/index.js b/packages/node/index.js new file mode 100644 index 000000000..ed5fd6870 --- /dev/null +++ b/packages/node/index.js @@ -0,0 +1,137 @@ +// @scriptc/node — the Node.js implementation. +// +// scriptc builds lower call() and callSync() to calls into Node.js loaded +// into the program (packages/runtime/src/scr_node_island.c and its +// bootstrap, scr_node_island_host.cjs). Under Node.js they run directly, +// with the same copies at the boundary, so a program prints the same +// either way. Keep the two in step: every message and copy rule here has +// its twin in the bootstrap. + +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +// Error classes that cross as copies, at the host's kind index. +const ERROR_CLASSES = [ + Error, + TypeError, + RangeError, + SyntaxError, + undefined, + ReferenceError, + EvalError, + URIError, +]; +const ERROR_KINDS = [ + [TypeError, 1], + [RangeError, 2], + [SyntaxError, 3], + [ReferenceError, 5], + [EvalError, 6], + [URIError, 7], +]; + +/** The file of the code that called `api`. */ +function callerFile(api) { + const savedPrepare = Error.prepareStackTrace; + const savedLimit = Error.stackTraceLimit; + let sites; + try { + Error.stackTraceLimit = 1; + Error.prepareStackTrace = (_error, callSites) => callSites; + const holder = {}; + Error.captureStackTrace(holder, api); + sites = holder.stack; + } finally { + Error.prepareStackTrace = savedPrepare; + Error.stackTraceLimit = savedLimit; + } + const name = Array.isArray(sites) && sites.length > 0 ? sites[0].getFileName() : undefined; + if (typeof name !== "string") + throw new Error("@scriptc/node: cannot determine the calling module"); + return name.startsWith("file:") ? fileURLToPath(name) : name; +} + +const requires = new Map(); + +function exportedFunction(file, specifier, name) { + let load = requires.get(file); + if (load === undefined) { + load = createRequire(file); + requires.set(file, load); + } + const namespace = load(specifier); + const fn = namespace === null || namespace === undefined ? undefined : namespace[name]; + if (typeof fn !== "function") + throw new TypeError(`'${name}' exported by '${specifier}' is not a function`); + return { fn, namespace }; +} + +function isThenable(value) { + return ( + value !== null && + (typeof value === "object" || typeof value === "function") && + typeof value.then === "function" + ); +} + +function errorKind(error) { + for (const [constructor, kind] of ERROR_KINDS) if (error instanceof constructor) return kind; + return 0; +} + +/** The copy of a thrown value that the caller sees. */ +function copyThrown(error) { + if (error instanceof Error) { + const copy = new ERROR_CLASSES[errorKind(error)](String(error.message)); + if (typeof error.code === "string") copy.code = error.code; + return copy; + } + let text; + try { + text = JSON.stringify(error); + } catch { + text = undefined; + } + return text === undefined ? undefined : JSON.parse(text); +} + +/** The JSON copy of a result; throws what JSON.stringify throws. */ +function copyResult(value) { + const text = JSON.stringify(value); + return text === undefined ? undefined : JSON.parse(text); +} + +function copyArgs(args) { + return JSON.parse(JSON.stringify(args)); +} + +export function callSync(specifier, name, ...args) { + const file = callerFile(callSync); + const copied = copyArgs(args); + let result; + try { + const { fn, namespace } = exportedFunction(file, specifier, name); + result = Reflect.apply(fn, namespace, copied); + if (isThenable(result)) { + // The promise is dropped: its rejection must not end the process. + Promise.resolve(result).catch(() => {}); + throw new TypeError( + `callSync: '${name}' exported by '${specifier}' returned a promise; use call() for asynchronous functions`, + ); + } + return copyResult(result); + } catch (error) { + throw copyThrown(error); + } +} + +export async function call(specifier, name, ...args) { + const file = callerFile(call); + const copied = copyArgs(args); + try { + const { fn, namespace } = exportedFunction(file, specifier, name); + return copyResult(await Reflect.apply(fn, namespace, copied)); + } catch (error) { + throw copyThrown(error); + } +} diff --git a/packages/node/package.json b/packages/node/package.json new file mode 100644 index 000000000..6f588445a --- /dev/null +++ b/packages/node/package.json @@ -0,0 +1,29 @@ +{ + "name": "@scriptc/node", + "version": "0.4.1", + "description": "Call into Node.js from scriptc programs: run an export of a JavaScript module in a Node.js helper process and copy JSON data both ways. Under Node.js the calls run in-process.", + "license": "Apache-2.0", + "homepage": "https://scriptc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/vercel-labs/scriptc.git", + "directory": "packages/node" + }, + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, + "files": [ + "index.js", + "index.d.ts", + "README.md" + ], + "engines": { + "node": ">=24.13.1" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3974f346e..b211b2e31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@scriptc/experimental': specifier: workspace:* version: link:packages/experimental + '@scriptc/node': + specifier: workspace:* + version: link:packages/node '@scriptc/threads': specifier: workspace:* version: link:packages/threads @@ -191,6 +194,8 @@ importers: packages/llvm-win32-x64-msvc: {} + packages/node: {} + packages/runtime: {} packages/runtime-android-arm64: {} diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs index 044f812f9..57d6c4ec0 100644 --- a/scripts/sync-versions.mjs +++ b/scripts/sync-versions.mjs @@ -31,6 +31,7 @@ for (const pkg of [ "runtime-android-arm64", "compiler", "experimental", + "node", "threads", "types", "cli-darwin-arm64", diff --git a/tests/corpus/6800-node-call-sync/main.ts b/tests/corpus/6800-node-call-sync/main.ts new file mode 100644 index 000000000..8f6f336ad --- /dev/null +++ b/tests/corpus/6800-node-call-sync/main.ts @@ -0,0 +1,39 @@ +// @requires-libnode +// Synchronous calls into Node.js through @scriptc/node: arguments and +// results cross as JSON copies, module state lives on between calls, and +// output from both sides interleaves in program order. +import { callSync } from "@scriptc/node"; +import * as node from "@scriptc/node"; +import { callSync as runInNode } from "@scriptc/node"; + +interface Point { + x: number; + y: number; + label: string; +} + +console.log("before"); +const sum = callSync("./math.cjs", "add", 2, 3); +console.log("sum", sum, sum + 1); +console.log(callSync("./math.cjs", "greet", "wörld ☃ 🌍")); +const p = callSync("./math.cjs", "point", 1.5, -2); +console.log(p.x + p.y, p.label, JSON.stringify(p)); +const squares = callSync("./math.cjs", "squares", 5); +console.log(squares.length, squares.join(",")); +const nothing = callSync("./math.cjs", "nothing"); +console.log(nothing === undefined); +const echoed = callSync( + "./math.cjs", + "echo", + { a: [1, "two", null, true], b: { c: "d" } }, + undefined, + [undefined, 0.1], +); +console.log(JSON.stringify(echoed)); +const flags = callSync("./math.cjs", "flags", 3); +console.log(flags.map((f) => (f ? "y" : "n")).join("")); +console.log(node.callSync("pkg-esm", "shout", "an es module, loaded with require")); +console.log(runInNode("./math.cjs", "add", 40, 2)); +console.log(callSync("./math.cjs", "count"), callSync("./math.cjs", "count")); +callSync("./math.cjs", "say", "from node"); +console.log("after"); diff --git a/tests/corpus/6800-node-call-sync/math.cjs b/tests/corpus/6800-node-call-sync/math.cjs new file mode 100644 index 000000000..dfdef5ffe --- /dev/null +++ b/tests/corpus/6800-node-call-sync/math.cjs @@ -0,0 +1,13 @@ +"use strict"; +let calls = 0; +exports.add = (a, b) => a + b; +exports.greet = (name) => `hello, ${name}`; +exports.point = (x, y) => ({ x, y, label: `(${x}, ${y})` }); +exports.squares = (n) => Array.from({ length: n }, (_, i) => i * i); +exports.nothing = () => {}; +exports.echo = (...args) => args; +exports.flags = (n) => Array.from({ length: n }, (_, i) => i % 2 === 0); +exports.count = () => ++calls; +exports.say = (text) => { + console.log(text); +}; diff --git a/tests/corpus/6800-node-call-sync/node_modules/pkg-esm/index.js b/tests/corpus/6800-node-call-sync/node_modules/pkg-esm/index.js new file mode 100644 index 000000000..bd890e132 --- /dev/null +++ b/tests/corpus/6800-node-call-sync/node_modules/pkg-esm/index.js @@ -0,0 +1,3 @@ +export function shout(text) { + return `${text.toUpperCase()}!`; +} diff --git a/tests/corpus/6800-node-call-sync/node_modules/pkg-esm/package.json b/tests/corpus/6800-node-call-sync/node_modules/pkg-esm/package.json new file mode 100644 index 000000000..b390c305d --- /dev/null +++ b/tests/corpus/6800-node-call-sync/node_modules/pkg-esm/package.json @@ -0,0 +1,6 @@ +{ + "name": "pkg-esm", + "version": "1.0.0", + "type": "module", + "exports": "./index.js" +} diff --git a/tests/corpus/6801-node-call-errors/main.ts b/tests/corpus/6801-node-call-errors/main.ts new file mode 100644 index 000000000..36064789b --- /dev/null +++ b/tests/corpus/6801-node-call-errors/main.ts @@ -0,0 +1,45 @@ +// @requires-libnode +// Errors thrown in Node.js cross into the program as copies: an Error as +// its nearest intrinsic class with its message and string code, any other +// thrown value as its JSON copy. +import { callSync } from "@scriptc/node"; + +function attempt(label: string, run: () => unknown): void { + try { + run(); + console.log(label, "returned"); + } catch (e) { + if (e instanceof Error) { + const code = (e as NodeJS.ErrnoException).code; + console.log( + label, + e.name, + e instanceof TypeError, + e instanceof RangeError, + JSON.stringify(e.message), + code ?? "-", + ); + } else { + console.log(label, "value", JSON.stringify(e)); + } + } +} + +attempt("type", () => callSync("./throws.cjs", "typeError")); +attempt("range", () => callSync("./throws.cjs", "rangeError")); +attempt("syntax", () => callSync("./throws.cjs", "syntaxError")); +attempt("coded", () => callSync("./throws.cjs", "coded")); +attempt("custom class", () => callSync("./throws.cjs", "custom")); +attempt("string", () => callSync("./throws.cjs", "string")); +attempt("object", () => callSync("./throws.cjs", "object")); +attempt("not a function", () => callSync("./throws.cjs", "notAFunction")); +attempt("missing export", () => callSync("./throws.cjs", "missing")); +attempt("promise", () => callSync("./throws.cjs", "later")); +attempt("bigint result", () => callSync("./throws.cjs", "big")); +attempt("returns", () => callSync("./throws.cjs", "fine")); +try { + callSync("./does-not-exist.cjs", "anything"); +} catch (e) { + console.log("missing module", (e as NodeJS.ErrnoException).code); +} +console.log("done"); diff --git a/tests/corpus/6801-node-call-errors/throws.cjs b/tests/corpus/6801-node-call-errors/throws.cjs new file mode 100644 index 000000000..98a3cf89f --- /dev/null +++ b/tests/corpus/6801-node-call-errors/throws.cjs @@ -0,0 +1,32 @@ +"use strict"; +class ValidationError extends Error { + constructor(message) { + super(message); + this.name = "ValidationError"; + } +} +exports.typeError = () => { + throw new TypeError("not a number: 'x'"); +}; +exports.rangeError = () => { + [].length = -1; +}; +exports.syntaxError = () => JSON.parse("{oops"); +exports.coded = () => { + const error = new Error("no such thing"); + error.code = "ENOTHING"; + throw error; +}; +exports.custom = () => { + throw new ValidationError("bad input"); +}; +exports.string = () => { + throw "boom"; +}; +exports.object = () => { + throw { reason: "nope", retry: false }; +}; +exports.notAFunction = 42; +exports.later = () => Promise.reject(new Error("never seen")); +exports.big = () => 10n; +exports.fine = () => "ok"; diff --git a/tests/corpus/6802-node-call-async/main.ts b/tests/corpus/6802-node-call-async/main.ts new file mode 100644 index 000000000..009d2da1f --- /dev/null +++ b/tests/corpus/6802-node-call-async/main.ts @@ -0,0 +1,48 @@ +// @requires-libnode +// Asynchronous calls into Node.js: results of promises cross when they +// settle, rejections reject, and synchronous calls work while asynchronous +// ones are outstanding and from promise reactions. +import { call, callSync } from "@scriptc/node"; + +interface Weather { + city: string; + temperature: number; + tags: string[]; +} + +async function main(): Promise { + console.log("start"); + const weather = await call("./service.mjs", "weather", "Berlin"); + console.log(weather.city, weather.temperature, weather.tags.join("+")); + console.log(await call("./service.mjs", "slow", 20)); + const doubled: number[] = await Promise.all([ + call("./service.mjs", "double", 1), + call("./service.mjs", "double", 2), + call("./service.mjs", "double", 3), + ]); + console.log(doubled.join(" ")); + const pending = call("./service.mjs", "slow", 30); + console.log("meanwhile", callSync("./service.mjs", "plain", 7)); + console.log(await pending); + await call("./service.mjs", "double", 21).then((n) => { + console.log("then", n, callSync("./service.mjs", "plain", n)); + }); + try { + await call("./service.mjs", "fails", "on purpose"); + } catch (e) { + if (e instanceof Error) + console.log("rejected", e.name, e.message, (e as NodeJS.ErrnoException).code ?? "-"); + } + try { + await call("./service.mjs", "missing"); + } catch (e) { + if (e instanceof Error) console.log("rejected", e.name, e.message); + } + const nothing = await call("./service.mjs", "nothing"); + console.log(nothing === undefined); + // Node's own pending work keeps the program alive after main returns. + callSync("./service.mjs", "later", 15); + console.log("end"); +} + +main(); diff --git a/tests/corpus/6802-node-call-async/service.mjs b/tests/corpus/6802-node-call-async/service.mjs new file mode 100644 index 000000000..568fa01a2 --- /dev/null +++ b/tests/corpus/6802-node-call-async/service.mjs @@ -0,0 +1,27 @@ +export function weather(city) { + return Promise.resolve({ city, temperature: 21.5, tags: ["sunny", "dry"] }); +} + +export function slow(ms) { + return new Promise((resolve) => setTimeout(() => resolve(`slept ${ms}`), ms)); +} + +export async function double(n) { + return n * 2; +} + +export function plain(n) { + return n + 1; +} + +export async function fails(why) { + const error = new RangeError(`failed ${why}`); + error.code = "ERR_FAILED"; + throw error; +} + +export async function nothing() {} + +export function later(ms) { + setTimeout(() => console.log(`island timer after ${ms} ms`), ms); +} diff --git a/tests/corpus/6803-node-call-exit/leave.cjs b/tests/corpus/6803-node-call-exit/leave.cjs new file mode 100644 index 000000000..283f09baa --- /dev/null +++ b/tests/corpus/6803-node-call-exit/leave.cjs @@ -0,0 +1,5 @@ +"use strict"; +exports.leave = (code) => { + console.log("leaving with", code); + process.exit(code); +}; diff --git a/tests/corpus/6803-node-call-exit/main.ts b/tests/corpus/6803-node-call-exit/main.ts new file mode 100644 index 000000000..4e19499be --- /dev/null +++ b/tests/corpus/6803-node-call-exit/main.ts @@ -0,0 +1,12 @@ +// @exit: 3 +// @requires-libnode +// process.exit in Node.js ends the whole program with its code, after the +// program's own exit listeners ran. +import { callSync } from "@scriptc/node"; + +process.on("exit", (code) => { + console.log("exit listener", code); +}); +console.log("before"); +callSync("./leave.cjs", "leave", 3); +console.log("not reached"); diff --git a/tests/fixtures/node-island/helper.cjs b/tests/fixtures/node-island/helper.cjs new file mode 100644 index 000000000..903532b0c --- /dev/null +++ b/tests/fixtures/node-island/helper.cjs @@ -0,0 +1,3 @@ +"use strict"; +exports.answer = () => 42; +exports.echo = (value) => value; diff --git a/tests/fixtures/node-island/lifecycle.ts b/tests/fixtures/node-island/lifecycle.ts new file mode 100644 index 000000000..f18ec640d --- /dev/null +++ b/tests/fixtures/node-island/lifecycle.ts @@ -0,0 +1,19 @@ +// Loading Node on first use (tests/harness/node-island.test.ts): the +// argument selects whether and how the program calls into Node. +import { readFileSync } from "node:fs"; +import { callSync } from "@scriptc/node"; + +const mode = process.argv[2] ?? "call"; +// Whether libnode is mapped into the process, from /proc/self/maps; other +// platforms report "unknown". +const mapped = (): boolean | string => + process.platform === "linux" + ? readFileSync("/proc/self/maps", "utf8").includes("libnode") + : "unknown"; +console.log("mapped before", mapped()); +if (mode !== "never") { + console.log(callSync("./helper.cjs", "answer")); + console.log("mapped after", mapped()); +} +if (mode === "exit") process.exit(5); +console.log("done"); diff --git a/tests/fixtures/node-island/package.json b/tests/fixtures/node-island/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/fixtures/node-island/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/fixtures/node-island/recursive-json.ts b/tests/fixtures/node-island/recursive-json.ts new file mode 100644 index 000000000..90d9c757f --- /dev/null +++ b/tests/fixtures/node-island/recursive-json.ts @@ -0,0 +1,9 @@ +// A recursive JSON value type as an argument and a result of a Node island +// call (tests/harness/node-island.test.ts). +import { callSync } from "@scriptc/node"; + +type Json = number | string | boolean | null | Json[] | { [key: string]: Json }; + +const input: Json = { list: [1, "two", [true, null]] }; +const output = callSync("./helper.cjs", "echo", input); +console.log(JSON.stringify(output)); diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index b70e59c86..3732f185e 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -23,6 +23,7 @@ import ts5 from "typescript"; import { compile } from "@scriptc/compiler"; import { corpusSelect, shardSuffix } from "./shard.js"; import { oracleEnvKey } from "./oracle-env.js"; +import { libnodePack, requiresLibnode } from "./libnode.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); @@ -405,7 +406,10 @@ describe(`differential corpus (${files.length} programs${sanitize ? ", sanitized test.for(files.map((f) => [f.slice(corpusDir.length + 1), f] as const))( "%s", { retry: 1 }, - async ([, file]) => { + async ([, file], { skip }) => { + // `// @requires-libnode`: the binary loads Node from a libnode pack, + // required in the Linux CI lanes (libnode.ts), else skipped without one. + if (requiresLibnode(file) && libnodePack() === null) skip(); const [nodeRes, nativeRes] = await Promise.all([runNode(file), compileAndRun(file)]); // Compare as bytes; decode only for the failure diff. if (!nodeRes.stdout.equals(nativeRes.stdout)) { diff --git a/tests/harness/linux-differential.test.ts b/tests/harness/linux-differential.test.ts index 9730623cd..24c86aed9 100644 --- a/tests/harness/linux-differential.test.ts +++ b/tests/harness/linux-differential.test.ts @@ -9,6 +9,7 @@ import { compile } from "@scriptc/compiler"; import { npmCases } from "./npm-cases.js"; import { normalizeNodeTestOutput } from "./node-test-normalize.js"; import { eventLoopCases, type StdinScript } from "./event-loop-cases.js"; +import { requiresLibnode } from "./libnode.js"; const execFileAsync = promisify(execFile); const enabled = process.env["SCRIPTC_LINUX"] === "1"; @@ -123,6 +124,9 @@ const files = enabled ]) .sort() .filter((f) => filter === undefined || new RegExp(filter).test(f)) + // Calls into Node load a libnode pack for the host, not for this + // cross target (libnode.ts). + .filter((f) => !requiresLibnode(f)) : []; if (enabled) { diff --git a/tests/harness/llvm-differential.test.ts b/tests/harness/llvm-differential.test.ts index 792fdb1d2..390c1116a 100644 --- a/tests/harness/llvm-differential.test.ts +++ b/tests/harness/llvm-differential.test.ts @@ -11,6 +11,7 @@ import { describe, expect, test } from "vitest"; import ts5 from "typescript"; import { compile } from "@scriptc/compiler"; import { corpusSelect, shardSuffix } from "./shard.js"; +import { libnodePack, requiresLibnode } from "./libnode.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); @@ -192,7 +193,9 @@ async function build(file: string, optimization: "release" | "dev") { describe(`llvm differential corpus (${files.length} programs, ${optimizationModes.join("+")}${sanitize ? ", sanitized" : ""}${shardSuffix()})`, () => { test.for(files.map((f) => [f.slice(corpusDir.length + 1), f] as const))( "%s", - async ([rel, file]) => { + async ([rel, file], { skip }) => { + // Programs that call into Node need a libnode pack (libnode.ts). + if (requiresLibnode(file) && libnodePack() === null) skip(); const oracle = await runBinary(process.execPath, nodeOracleArgs(file)); const expectedExit = expectedExitCode(file); expect(oracle.exitCode).toBe(expectedExit); diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts new file mode 100644 index 000000000..47a68e290 --- /dev/null +++ b/tests/harness/node-island.test.ts @@ -0,0 +1,208 @@ +/* Node islands loaded in-process (scope-B milestone NI-1), where it cannot + * be a differential corpus program: a run that never calls into Node never + * maps libnode, the beside packaging carries its own pack, a missing or + * mismatched pack fails the first call with a message, and the executable + * links no Node and exports nothing libnode could bind to. The corpus + * (6800-6803) pins the call semantics against Node. Needs a libnode pack + * in SCRIPTC_LIBNODE; required in the Linux x64 CI lanes (libnode.ts). */ +import { execFile } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { beforeAll, describe, expect, test } from "vitest"; +import { compile, deserializeModule } from "@scriptc/compiler"; +import { definedFunctionNames, nodeIslandSymbols } from "../../scripts/zero-cost-compare.mjs"; +import { libnodePack } from "./libnode.js"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const fixture = join(repoRoot, "tests/fixtures/node-island/lifecycle.ts"); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +// Required in the Linux x64 CI lanes, where a missing pack fails here. +const pack = libnodePack(); +const skip = pack === null; + +interface Run { + stdout: string; + stderr: string; + code: number | null; +} + +let binary = ""; + +// The fixture reads whether libnode is mapped from /proc/self/maps; other +// platforms print "unknown". +const before = `mapped before ${process.platform === "linux" ? "false" : "unknown"}`; +const after = `mapped after ${process.platform === "linux" ? "true" : "unknown"}`; + +async function run( + path: string, + args: string[], + env: NodeJS.ProcessEnv = process.env, +): Promise { + try { + const { stdout, stderr } = await execFileAsync(path, args, { env, encoding: "utf8" }); + return { stdout, stderr: comparable(stderr), code: 0 }; + } catch (err) { + const e = err as { stdout?: string; stderr?: string; code?: unknown }; + if (typeof e.code !== "number") throw err; + return { stdout: e.stdout ?? "", stderr: comparable(e.stderr ?? ""), code: e.code }; + } +} + +/** Linux ASan's once-per-process warning at the first context switch (the + * switch to Node's stack), dropped as differential.test.ts drops it. */ +function comparable(stderr: string): string { + if (!sanitize) return stderr; + return stderr + .split("\n") + .filter((l) => !/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext/.test(l)) + .join("\n"); +} + +async function build(name: string, libnode?: string): Promise { + const dir = join(outDir, name); + mkdirSync(dir, { recursive: true }); + const result = await compile(fixture, { + outPath: join(dir, "lifecycle"), + outDir: dir, + sanitize, + ...(libnode === undefined ? {} : { libnode }), + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return result.binaryPath; +} + +/** A bridge library that answers the ABI handshake and nothing else. */ +async function fakePack(name: string, abi: string, version: string): Promise { + const dir = join(outDir, name); + mkdirSync(dir, { recursive: true }); + const source = join(dir, "fake-bridge.c"); + writeFileSync( + source, + `#include "scr_node_bridge.h" +static const ScrNodeBridge bridge = {${abi}, "${version}", 127}; +__attribute__((visibility("default"))) const ScrNodeBridge *scr_node_bridge_v1(void) { return &bridge; } +`, + ); + const library = + process.platform === "darwin" ? "libscr_node_bridge.dylib" : "libscr_node_bridge.so"; + await execFileAsync("clang", [ + "-shared", + "-fPIC", + "-I", + join(repoRoot, "packages/runtime/src"), + source, + "-o", + join(dir, library), + ]); + return dir; +} + +describe.skipIf(skip)("Node islands in-process", () => { + beforeAll(async () => { + binary = await build("recorded"); + }, 300_000); + + test("loads Node on the first call, and only then", async () => { + expect(await run(binary, [])).toEqual({ + stdout: `${before}\n42\n${after}\ndone\n`, + stderr: "", + code: 0, + }); + expect(await run(binary, ["never"])).toEqual({ + stdout: `${before}\ndone\n`, + stderr: "", + code: 0, + }); + }); + + test("process.exit after loading Node keeps its code", async () => { + const result = await run(binary, ["exit"]); + expect(result.code).toBe(5); + expect(result.stdout).toBe(`${before}\n42\n${after}\n`); + }); + + test("beside packaging carries its own pack", async () => { + const beside = await build("beside", "beside"); + expect(existsSync(join(outDir, "beside/lifecycle.libnode/manifest.json"))).toBe(true); + const env = { ...process.env }; + delete env["SCRIPTC_LIBNODE"]; + const result = await run(beside, [], env); + expect(result.stdout).toBe(`${before}\n42\n${after}\ndone\n`); + expect(result.code).toBe(0); + }, 300_000); + + test("a missing pack fails the first call", async () => { + const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: "/nonexistent/pack" }); + expect(result.code).toBe(1); + expect(result.stdout).toBe(`${before}\n`); + expect(result.stderr).toMatch( + /^scriptc: cannot load Node\.js for this program's Node islands: .*\/nonexistent\/pack\/libscr_node_bridge\.(so|dylib).*\n$/, + ); + }); + + test("a pack of another Node version fails the first call", async () => { + const dir = await fakePack("old-node", "SCR_NODE_BRIDGE_ABI", "22.11.0"); + const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: dir }); + expect(result.code).toBe(1); + expect(result.stderr).toMatch( + new RegExp( + `^scriptc: this program's Node islands need libnode \\d+\\.\\d+\\.\\d+; found 22\\.11\\.0 \\(NODE_MODULE_VERSION 127\\) at ${dir}\\n$`, + ), + ); + }); + + test("a bridge of another ABI fails the first call", async () => { + const dir = await fakePack("other-abi", "999u", "24.22.0"); + const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: dir }); + expect(result.code).toBe(1); + expect(result.stderr).toBe( + `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 2)\n`, + ); + }); + + test("links no Node and exports nothing libnode binds to", async () => { + if (process.platform !== "linux" || sanitize) return; + const { stdout } = await execFileAsync("nm", [binary], { + encoding: "utf8", + maxBuffer: 1 << 28, + }); + const defined = definedFunctionNames(stdout); + expect(nodeIslandSymbols(defined)).toContain("scr_node_island_call_sync"); + expect(defined.filter((name) => /^(napi_|_ZN2v8|_ZN4node|uv_)/.test(name))).toEqual([]); + // libnode joins the global scope (RTLD_GLOBAL): the executable must not + // export a name it would bind to instead of its own. + const dynamic = await execFileAsync("nm", ["-D", "--defined-only", binary], { + encoding: "utf8", + }); + const conflicting = dynamic.stdout + .split("\n") + .map((line) => line.trim().split(/\s+/).at(-1) ?? "") + .filter((name) => + /^(napi_|node_|uv_|_ZN2v8|_ZN4node|SSL|EVP_|ERR_|deflate|inflate|crc32|adler32|mbedtls_|ares_|nghttp2_)/.test( + name, + ), + ); + expect(conflicting).toEqual([]); + }); +}); + +// Lowering checks every argument and result type for JSON data; IR output +// needs no libnode pack. +test("recursive JSON types cross into Node", async () => { + const dir = join(outDir, "recursive-json"); + mkdirSync(dir, { recursive: true }); + const outPath = join(dir, "main.ir.json"); + const result = await compile(join(repoRoot, "tests/fixtures/node-island/recursive-json.ts"), { + outPath, + outDir: dir, + outputKind: "ir", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const ir = JSON.stringify(deserializeModule(readFileSync(outPath, "utf8"))); + expect(ir).toContain("nodeIsland.callSync"); +}, 300_000); diff --git a/tests/harness/windows-differential.test.ts b/tests/harness/windows-differential.test.ts index 366820420..87b8b162a 100644 --- a/tests/harness/windows-differential.test.ts +++ b/tests/harness/windows-differential.test.ts @@ -52,6 +52,7 @@ import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import ts5 from "typescript"; import { compile } from "@scriptc/compiler"; +import { requiresLibnode } from "./libnode.js"; const execFileAsync = promisify(execFile); const enabled = process.env["SCRIPTC_WIN"] === "1"; @@ -265,6 +266,9 @@ const files = enabled ]) .sort() .filter((f) => filter === undefined || new RegExp(filter).test(f)) + // Calls into Node load a libnode pack for the host, not for this + // cross target (libnode.ts). + .filter((f) => !requiresLibnode(f)) : []; if (enabled) {