mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-03 07:54:40 +08:00
Call into Node.js from compiled programs with @scriptc/node (#1686)
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6800-node-call-sync/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6801-node-call-errors/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6802-node-call-async/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6803-node-call-exit/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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.
|
||||
Vendored
+36
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+5
@@ -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: {}
|
||||
|
||||
@@ -31,6 +31,7 @@ for (const pkg of [
|
||||
"runtime-android-arm64",
|
||||
"compiler",
|
||||
"experimental",
|
||||
"node",
|
||||
"threads",
|
||||
"types",
|
||||
"cli-darwin-arm64",
|
||||
|
||||
@@ -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");
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
export function shout(text) {
|
||||
return `${text.toUpperCase()}!`;
|
||||
}
|
||||
+6
@@ -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";
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
"use strict";
|
||||
exports.answer = () => 42;
|
||||
exports.echo = (value) => value;
|
||||
+19
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user