Call into Node.js from compiled programs with @scriptc/node (#1686)

This commit is contained in:
Chris Tate
2026-10-21 12:13:04 -05:00
committed by GitHub
39 changed files with 970 additions and 7 deletions
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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 `## <version>` entry (newest first, below `## Unreleased`), and leave `## Unreleased` empty for the next cycle
4. Wrap the new entry in `<!-- release:start -->` and `<!-- release:end -->` markers; this marked block is also the GitHub release body
5. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry; only the latest release should have markers
+1
View File
@@ -35,6 +35,7 @@
},
"devDependencies": {
"@scriptc/experimental": "workspace:*",
"@scriptc/node": "workspace:*",
"@scriptc/threads": "workspace:*",
"@scriptc/types": "workspace:*",
"@types/node": "^24.0.0",
@@ -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",
@@ -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
@@ -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
@@ -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<string>): 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);
}
@@ -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<string, boolean>();
/** 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() }
: {}),
@@ -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;
}
+7 -1
View File
@@ -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.
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6800-node-call-sync/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6801-node-call-errors/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6802-node-call-async/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6803-node-call-exit/main.ts"
],
"diags": []
}
+15
View File
@@ -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<number>("./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=<pack directory>`; `--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.
+36
View File
@@ -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<T = unknown>(
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<T = unknown>(
specifier: string,
name: string,
...args: unknown[]
): Promise<T>;
+137
View File
@@ -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);
}
}
+29
View File
@@ -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"
}
}
+5
View File
@@ -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: {}
+1
View File
@@ -31,6 +31,7 @@ for (const pkg of [
"runtime-android-arm64",
"compiler",
"experimental",
"node",
"threads",
"types",
"cli-darwin-arm64",
+39
View File
@@ -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<number>("./math.cjs", "add", 2, 3);
console.log("sum", sum, sum + 1);
console.log(callSync<string>("./math.cjs", "greet", "wörld ☃ 🌍"));
const p = callSync<Point>("./math.cjs", "point", 1.5, -2);
console.log(p.x + p.y, p.label, JSON.stringify(p));
const squares = callSync<number[]>("./math.cjs", "squares", 5);
console.log(squares.length, squares.join(","));
const nothing = callSync<undefined>("./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<boolean[]>("./math.cjs", "flags", 3);
console.log(flags.map((f) => (f ? "y" : "n")).join(""));
console.log(node.callSync<string>("pkg-esm", "shout", "an es module, loaded with require"));
console.log(runInNode<number>("./math.cjs", "add", 40, 2));
console.log(callSync<number>("./math.cjs", "count"), callSync<number>("./math.cjs", "count"));
callSync("./math.cjs", "say", "from node");
console.log("after");
+13
View File
@@ -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);
};
+3
View File
@@ -0,0 +1,3 @@
export function shout(text) {
return `${text.toUpperCase()}!`;
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "pkg-esm",
"version": "1.0.0",
"type": "module",
"exports": "./index.js"
}
@@ -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");
@@ -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";
+48
View File
@@ -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<void> {
console.log("start");
const weather = await call<Weather>("./service.mjs", "weather", "Berlin");
console.log(weather.city, weather.temperature, weather.tags.join("+"));
console.log(await call<string>("./service.mjs", "slow", 20));
const doubled: number[] = await Promise.all([
call<number>("./service.mjs", "double", 1),
call<number>("./service.mjs", "double", 2),
call<number>("./service.mjs", "double", 3),
]);
console.log(doubled.join(" "));
const pending = call<string>("./service.mjs", "slow", 30);
console.log("meanwhile", callSync<number>("./service.mjs", "plain", 7));
console.log(await pending);
await call<number>("./service.mjs", "double", 21).then((n) => {
console.log("then", n, callSync<number>("./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<undefined>("./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();
@@ -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);
}
@@ -0,0 +1,5 @@
"use strict";
exports.leave = (code) => {
console.log("leaving with", code);
process.exit(code);
};
+12
View File
@@ -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");
+3
View File
@@ -0,0 +1,3 @@
"use strict";
exports.answer = () => 42;
exports.echo = (value) => value;
+19
View File
@@ -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<number>("./helper.cjs", "answer"));
console.log("mapped after", mapped());
}
if (mode === "exit") process.exit(5);
console.log("done");
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+9
View File
@@ -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<Json>("./helper.cjs", "echo", input);
console.log(JSON.stringify(output));
+5 -1
View File
@@ -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)) {
+4
View File
@@ -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) {
+4 -1
View File
@@ -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);
+208
View File
@@ -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<Run> {
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<string> {
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<string> {
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);
@@ -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) {