Files
scriptc/packages/node/index.js
T
Malte UblandClaude Opus 5.5 51e9812493 Call into Node.js from compiled programs with @scriptc/node
Scope-B milestone NI-1, API side: the explicit call API that replaces the
build container's hand-written node subprocess (api patch 09).

- The new package @scriptc/node exports callSync(specifier, name, ...args)
  and call(...). The module loads with require() from the calling module;
  arguments and results cross as JSON copies; a thrown Error crosses as its
  nearest intrinsic class with its message and string code, other thrown
  values as JSON copies; callSync refuses a function that returns a
  promise. Its Node implementation runs the calls in-process with the same
  copies, so a program prints the same under Node and compiled.
- The compiler lowers calls to the named or namespace imports
  (node-api-import.ts, lower-node-island.ts) to the nodeIsland.callSync
  and nodeIsland.call libCalls: the calling module's real path, the
  specifier, the export name and the boxed argument array, with the result
  checked against the declared type like `JSON.parse(s) as T`. Arguments
  and results whose types are not JSON data are refused at build time, and
  the functions and the namespace refuse as values.
- Corpus programs 6800-6803 (sync and async results, errors, process.exit
  from Node with the program's exit listeners, Node's pending timers
  keeping the program alive) run under Node and as binaries that load
  libnode; `// @requires-libnode` skips them where SCRIPTC_LIBNODE names no
  pack (until the pack CI matrix publishes packs). The node-island harness
  covers loading Node only on the first call, the beside packaging, a
  missing pack, a pack of another Node version or bridge ABI, and the
  executable's symbols.
- Release tooling publishes @scriptc/node with the other packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:59:55 -07:00

138 lines
4.0 KiB
JavaScript

// @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);
}
}