Run npm packages in Node islands: object handles and compiler routing (NI-4) (#1899)

This commit is contained in:
Chris Tate
2026-10-27 22:42:29 -05:00
committed by GitHub
101 changed files with 5024 additions and 115 deletions
@@ -1045,6 +1045,17 @@ export const LIB_FN_SYMS: Record<string, string> = {
"json.stringifyValue": "scr_json_stringify_value",
"nodeIsland.callSync": "scr_node_island_call_sync",
"nodeIsland.call": "scr_node_island_call",
"nodeIsland.require": "scr_ni_require",
"nodeIsland.import": "scr_ni_import",
"nodeIsland.get": "scr_node_island_get",
"nodeIsland.getKey": "scr_ni_get",
"nodeIsland.set": "scr_node_island_set",
"nodeIsland.setKey": "scr_node_island_set_key",
"nodeIsland.invoke": "scr_node_island_invoke",
"nodeIsland.apply": "scr_node_island_apply",
"nodeIsland.applyTo": "scr_node_island_apply_to",
"nodeIsland.construct": "scr_node_island_construct",
"nodeIsland.instanceof": "scr_node_island_instanceof",
"dyn.keySet": "scr_dyn_key_set",
"dyn.keySetComputed": "scr_dyn_key_set_computed",
"dyn.keyDelete": "scr_dyn_key_delete",
@@ -2022,4 +2033,15 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
// drives (scr_node_island.c) before the program exits.
"nodeIsland.call",
"nodeIsland.callSync",
"nodeIsland.require",
"nodeIsland.import",
"nodeIsland.get",
"nodeIsland.getKey",
"nodeIsland.set",
"nodeIsland.setKey",
"nodeIsland.invoke",
"nodeIsland.apply",
"nodeIsland.applyTo",
"nodeIsland.construct",
"nodeIsland.instanceof",
]);
@@ -209,7 +209,9 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
u.opts.workers ||
u.opts.dc ||
u.opts.fileHandle ||
u.nativeFetch,
u.nativeFetch ||
// Island promises cross as dyn promise boxes (scr_node_island_handle.c).
u.opts.nodeIsland === true,
"scr_async_dyn.c",
),
// The performance timeline (every perf_hooks user-timing call selects
@@ -369,7 +371,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unitArgs((u) => (u.tlsCa && targetPlatform(u.driver) === "win32" ? ["-lcrypt32"] : [])),
// Fetch uses the native network, TLS, and HTTP runtime.
unit((u) => u.nativeFetch, "scr_fetch.c"),
unit((u) => u.opts.nodeIsland === true, "scr_node_island.c"),
unit((u) => u.opts.nodeIsland === true, "scr_node_island.c", "scr_node_island_handle.c"),
// Readable.fromWeb reads a web ReadableStream through its reader.
unit((u) => u.nativeFetch && u.opts.stream, "scr_stream_from_web.c"),
];
+1
View File
@@ -665,6 +665,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
? {}
: { profileUse: resolve(values["profile-use"]) }),
...(npmStatic !== undefined ? { npmStatic } : {}),
...(nodeIslands !== undefined ? { nodeIslands } : {}),
...(values["shape-overlay"] === false ? { shapeOverlay: false } : {}),
...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}),
...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}),
+3
View File
@@ -11,6 +11,9 @@ import type { NodeIslandOptions } from "./node-islands/policy.js";
export type CompileOutputKind = "ir" | "llvm" | "asm" | "obj" | "exe";
export interface CompileBaseOptions {
/** The Node island policy's command-line half (`--node-islands`,
* `--node-island`, `--native`): which npm packages run in Node islands. */
nodeIslands?: NodeIslandOptions;
/** Primary artifact path. The CLI supplies the output-kind default. */
outPath: string;
/** Where generated intermediates and compatibility side artifacts land. */
+2 -2
View File
@@ -107,11 +107,13 @@ export const DIAGNOSTIC_CODES = {
SC7006: "Acyclic contract in an unchecked position",
SC7007: "Acyclic type can lie on a reference cycle",
SC7008: "ProgramLifetime class extends another class",
SC8001: "package or module runs in a Node island (note)",
SC8002: "Node islands off for a package or module outside the native subset",
SC8003: "package marked native is outside the native subset",
SC8004: "unknown scriptc.packages or scriptc.nodeIslands configuration",
SC8005: "no usable libnode pack for a program that calls into Node.js",
SC8006: "Node island modules the build cannot collect",
SC8010: "value crossing a Node island boundary in a form without an exact copy",
SC8030: "type patch: no range covers the installed copy",
SC8031: "type patch: selector matches nothing",
SC8032: "type patch: declaration already has the type",
@@ -149,8 +151,6 @@ export const RETIRED_DIAGNOSTIC_CODES = {
* that work lands it moves its codes into DIAGNOSTIC_CODES; when it is
* abandoned, delete them here. */
export const RESERVED_DIAGNOSTIC_CODES = {
SC8001: "Node islands (ni/* branches)",
SC8010: "Node islands (ni/* branches)",
SC8011: "Node islands (ni/* branches)",
SC8012: "Node islands (ni/* branches)",
SC8020: "Node islands (ni/* branches)",
@@ -116,6 +116,11 @@ export interface ScrDiagnostic {
* alone does not say it (a computed require(), a native addon, `with`).
* Report-only: never part of the rendered message. */
reason?: string;
/** Runtime fences only: the fenced site is in a module's top-level code
* (outside every function body), so it runs, and throws, when the module
* evaluates. Island selection treats such a fence like one that needs the
* runtime (scope-B NI-4). */
moduleInit?: true;
/** A runtime fence in a module's top-level code, outside every function
* and class member body: it throws when the module initializes, which is
* program startup for a module the entry imports. */
@@ -357,6 +362,34 @@ export function nodeIslandsOffModuleDiag(
};
}
/** SC8001 — a package runs in a Node island (a note, once per island in
* the build output). */
export function nodeIslandNoteDiag(
pkg: string,
version: string | null,
klass: string,
reason: string,
loc: SrcLoc,
): ScrDiagnostic {
return {
code: "SC8001",
message: `package '${version === null ? pkg : `${pkg}@${version}`}' runs in a Node island: ${klass}: ${reason}`,
loc,
hint: "it runs in Node.js loaded into the program on first use; --node-islands=off makes this an error",
};
}
/** SC8010 — a value crosses an island boundary in a way NI-4 cannot keep
* exact yet (callbacks, native objects, remote promises). */
export function nodeIslandBoundaryDiag(what: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC8010",
message: `${what} cannot cross into or out of a Node island yet`,
loc,
hint: "pass primitives, strings, island objects or fresh object and array literals of those; callbacks, native objects and promises cross from NI-5 on",
};
}
/** SC8030–SC8033 — a type patch (`scriptc.typePatches`) that did not
* apply as written: no range covers the installed copy (SC8030), its
* selector matches nothing (SC8031), the declaration already has the type
@@ -185,6 +185,8 @@ export interface EarlyExecutableCacheOptions {
/** Omitted unless SCRIPTC_HEAP_CENSUS is set (backend/llvm/heap-census.ts). */
heapCensus?: true;
npmStatic: readonly string[] | "auto" | null;
/** Omitted for the default Node island policy; else nodeIslandOptionsKey. */
nodeIslands?: string;
/** Omitted unless shape inference's overlay compiles the packages
* (frontend/shape-inference). */
shapeOverlay?: true;
@@ -341,6 +343,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...(options.functionLayout !== undefined ? [`layout:${options.functionLayout}`] : []),
...(options.checker !== undefined ? [`checker:${options.checker}`] : []),
...(options.youngNursery ? ["young-nursery"] : []),
...(options.nodeIslands !== undefined ? [`node-islands:${options.nodeIslands}`] : []),
...(options.heapCensus ? ["heap-census"] : []),
options.npmStatic === null
? "<npm-static-off>"
@@ -389,6 +392,7 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
if (options.functionLayout !== undefined) hash.update(`layout:${options.functionLayout}\0`);
if (options.checker !== undefined) hash.update(`checker:${options.checker}\0`);
if (options.youngNursery) hash.update("young-nursery\0");
if (options.nodeIslands !== undefined) hash.update(`node-islands:${options.nodeIslands}\0`);
if (options.heapCensus) hash.update("heap-census\0");
hash
.update(
+348 -7
View File
@@ -3,6 +3,7 @@ import type { FfiProfile } from "../ffi/ffi-manifest.js";
import { frontendDisposed } from "../frontend/dispose-hook.js";
import {
checkerPanicDiag,
nodeIslandNoteDiag,
npmStaticRefusalDiag,
iceDiag,
isCheckerPanic,
@@ -19,6 +20,16 @@ import {
import type { LowerResult, LowerStats } from "../frontend/lowering/lowerer.js";
import type { FrontendFactory } from "../frontend/pipeline.js";
import type { CompilationTiming } from "../timing.js";
import { readProjectConfig } from "../project-config.js";
import { nodeIslandPolicy } from "../node-islands/policy.js";
import { buildNodeIslandReport } from "../node-islands/report.js";
import type { IslandPackage, NodeIslandSet } from "../node-islands/boundary.js";
import { classifyDiagnostic, isIslandReasonCode } from "../node-islands/classify.js";
import { packageDirOf, packageVersionOf } from "../node-islands/origin.js";
import { describe } from "../node-islands/summary.js";
import { resolveBareModule } from "../frontend/resolve.js";
import { isEsmFile } from "../frontend/lowering/lower-node-island.js";
import type { PackageEdge } from "../node-islands/scan.js";
export interface PreparedExecutableModule {
ok: true;
@@ -38,11 +49,30 @@ export function prepareExecutableModule(
buildPlatform: string,
createFrontend: FrontendFactory,
timing: CompilationTiming = () => {},
lowerIslands: ReadonlyMap<string, IslandPackage> = new Map(),
retryFences = true,
): PreparedExecutableModule | CompileFailure {
const fe = createFrontend(entryPath, opts.npmStatic);
// The island decision reads the detector's scan unless islands are off.
const islandScan =
nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands).mode !== "off";
let fe = createFrontend(entryPath, opts.npmStatic, undefined, undefined, { islandScan });
timing("frontend-load");
// Node islands (scope-B NI-4): packages refused natively run in Node,
// unless the policy says otherwise. The frontend reloads without them,
// so their declarations type the program.
const islandDecision = decideNodeIslands(entryPath, opts, fe, lowerIslands);
if (islandDecision !== null) {
fe.dispose();
fe = createFrontend(entryPath, opts.npmStatic, undefined, undefined, {
islandPackages: new Set(islandDecision.packages.keys()),
islandScan,
});
timing("frontend-island-reload");
}
let lowered: LowerResult;
let fenceEdges: readonly PackageEdge[] | undefined;
let sourceTexts: Map<string, string>;
let disposed = false;
// The frontend (and its tsgo server) is released as soon as lowering
// ends — clang and the link never hold it open.
try {
@@ -65,10 +95,32 @@ export function prepareExecutableModule(
),
);
}
if (fe.preflight.length > 0) return fail(fe.preflight);
if (fe.preflight.length > 0) {
// An import of a natively compiled package that preflight refuses
// in the importer (a CommonJS namespace import) is that package's
// refusal: it runs in a Node island instead.
const grown = preflightIslands(entryPath, opts, fe, lowerIslands);
if (grown !== null) {
fe.dispose();
frontendDisposed();
disposed = true;
return prepareExecutableModule(
entryPath,
opts,
ffi,
buildPlatform,
createFrontend,
timing,
grown,
retryFences,
);
}
return fail(fe.preflight);
}
try {
lowered = fe.lower({
...(islandDecision !== null ? { nodeIslands: islandDecision } : {}),
targetPlatform: buildPlatform,
...(ffi !== null ? { ffiImports: ffi.functions } : {}),
...(opts.tier !== undefined ? { tierMode: opts.tier } : {}),
@@ -83,7 +135,35 @@ export function prepareExecutableModule(
checkerPanicDiag(e.message.split("\n", 1)[0]!, { file: entryPath, start: 0, end: 0 }),
]);
}
if (lowered.module === null) return fail(lowered.diagnostics);
if (lowered.module === null) {
// Packages whose own modules refused to lower run in Node islands
// too (the authoritative lowering, DESIGN.md §1.4): build again with
// them as islands.
const grown = loweringIslands(
entryPath,
opts,
fe,
lowered.diagnostics,
lowerIslands,
fe.nodeIslandScan?.({ userJs: false }).edges,
);
if (grown !== null) {
fe.dispose();
frontendDisposed();
disposed = true;
return prepareExecutableModule(
entryPath,
opts,
ffi,
buildPlatform,
createFrontend,
timing,
grown,
retryFences,
);
}
return fail(lowered.diagnostics);
}
const validation = validateModule(lowered.module);
timing("ir-validate");
@@ -104,6 +184,10 @@ export function prepareExecutableModule(
return fail([targetRefusalDiag("wasm32-wasi", unavailable.surface, unavailable.loc)]);
}
}
// The package import edges island selection reads after lowering, taken
// while the frontend is open.
if (retryFences && lowered.runtimeFences.some(sureFence))
fenceEdges = fe.nodeIslandScan?.({ userJs: false }).edges;
sourceTexts = fe.sourceTexts();
if (lowered.contracts) {
const checked = checkContracts(lowered.module, lowered.contracts, sourceTexts);
@@ -112,20 +196,277 @@ export function prepareExecutableModule(
if (checked.contracts) lowered.module.contracts = checked.contracts;
}
} finally {
fe.dispose();
frontendDisposed();
timing("frontend-dispose");
if (!disposed) {
fe.dispose();
frontendDisposed();
timing("frontend-dispose");
}
}
return {
const prepared: PreparedExecutableModule = {
ok: true,
mod: lowered.module!,
sourceTexts,
stats: lowered.stats,
warnings: [
...(islandDecision?.notes ?? []),
...(fe.warnings ?? []),
...(lowered.divergences ?? []),
...(lowered.performance ?? []),
],
};
// Runtime fences in a compiled package that surely fire: fences that need
// the JavaScript runtime itself (R1: code from strings, computed
// import()/require(), addons, loader hooks), and fences in top-level
// module code, which throw when the module evaluates. Build again with
// those packages as islands, and keep this build when that one cannot
// compile. A gap fence inside a function body may never run, so it stays
// a deferred refusal.
const runtimeFences = lowered.runtimeFences.filter(sureFence);
if (retryFences && runtimeFences.length > 0) {
const grown = loweringIslands(entryPath, opts, fe, runtimeFences, lowerIslands, fenceEdges);
if (grown !== null) {
const islanded = prepareExecutableModule(
entryPath,
opts,
ffi,
buildPlatform,
createFrontend,
timing,
grown,
false,
);
if (islanded.ok) return islanded;
}
}
return prepared;
}
/** A run-time fence that surely fires: it needs the JavaScript runtime
* itself, or it is in top-level module code. */
function sureFence(d: ScrDiagnostic): boolean {
return d.moduleInit === true || classifyDiagnostic(d, "fence").class === "runtime";
}
/** The packages a build runs in Node islands, or null for none: under
* `--node-islands=auto` (the default) every npm package the native
* pipeline refused, plus packages marked `node`; under `explicit` only the
* marked ones; under `off`, none (refusals stay SC2013). Each island is
* reported once as an SC8001 note. */
function decideNodeIslands(
entryPath: string,
opts: CompileRequestOptions,
fe: ReturnType<FrontendFactory>,
lowerIslands: ReadonlyMap<string, IslandPackage>,
): (NodeIslandSet & { notes: ScrDiagnostic[] }) | null {
const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands);
if (policy.mode === "off" || fe.nodeIslandScan === undefined) return null;
// A package marked native stays refused: the build reports it rather
// than running it in Node.
const refused = new Set(
policy.mode === "auto"
? fe.npmStatic
.filter((e) => e.status === "refused" && !policy.forcedNative.has(e.package))
.map((e) => e.package)
: [],
);
for (const name of lowerIslands.keys()) refused.add(name);
if (refused.size === 0 && policy.forcedNode.size === 0) return null;
const report = buildNodeIslandReport({
entry: entryPath,
scan: fe.nodeIslandScan({ userJs: false }),
npm: fe.npmStatic,
diagnostics: [],
fences: [],
policy,
});
const packages = new Map<string, IslandPackage>();
const notes: ScrDiagnostic[] = [];
const seen = new Set<string>();
for (const island of report.islands) {
if (island.path === null) continue;
seen.add(island.package);
if (!refused.has(island.package) && !policy.forcedNode.has(island.package)) continue;
packages.set(island.package, {
name: island.package,
version: island.version,
dir: island.path,
class: island.class,
reason: island.reason,
});
notes.push(
nodeIslandNoteDiag(
island.package,
island.version,
island.class,
island.reason,
island.importedFrom[0] ??
fe.npmImportSites.get(island.package) ?? { file: entryPath, start: 0, end: 0 },
),
);
}
// Refused packages the report has no installed directory for (refused
// before they joined the program): their directory from the runtime
// entry Node resolves.
for (const name of refused) {
if (packages.has(name) || lowerIslands.has(name)) continue;
const site = fe.npmImportSites.get(name) ?? { file: entryPath, start: 0, end: 0 };
const js =
resolveBareModule(site.file, name, "js-only") ??
resolveBareModule(site.file, name, "js-only", "require");
const at = js === null ? null : packageDirOf(js.typesFile);
if (at === null) continue;
const status = fe.npmStatic.find((e) => e.package === name);
packages.set(name, {
name,
version: packageVersionOf(at.dir),
dir: at.dir,
class: "packaging",
reason: status?.detail ?? "its static compilation was refused",
});
notes.push(
nodeIslandNoteDiag(name, packageVersionOf(at.dir), "packaging", status?.detail ?? "", site),
);
}
// Packages islanded by their lowering refusals keep their first reason
// (the reload no longer compiles them, so the scan has no reason).
for (const [name, island] of lowerIslands) {
if (seen.has(name) && packages.has(name)) {
const kept = packages.get(name)!;
if (kept.reason === "" || kept.class === "") packages.set(name, island);
continue;
}
packages.set(name, island);
notes.push(
nodeIslandNoteDiag(
island.name,
island.version,
island.class,
island.reason,
fe.npmImportSites.get(name) ?? { file: entryPath, start: 0, end: 0 },
),
);
}
return packages.size === 0 ? null : { packages, notes };
}
/** The island set grown by the npm packages whose own modules refused to
* lower (`--node-islands=auto` only), or null when there are none. */
function loweringIslands(
entryPath: string,
opts: CompileRequestOptions,
fe: ReturnType<FrontendFactory>,
diagnostics: readonly ScrDiagnostic[],
known: ReadonlyMap<string, IslandPackage>,
edges: readonly PackageEdge[] | undefined,
): Map<string, IslandPackage> | null {
const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands);
if (policy.mode !== "auto" || known.size > 16) return null;
const compiled = new Set(fe.npmStatic.filter((e) => e.status === "static").map((e) => e.package));
const grown = new Map(known);
for (const d of diagnostics) {
// A use of a package outside the compiled module graph (an import()
// the frontend did not compile): that package runs in an island.
const named = d.code === "SC2013" ? /npm package '([^']+)'/.exec(d.message) : null;
if (named !== null) {
const name = named[1]!;
if (grown.has(name) || policy.forcedNative.has(name) || policy.unloaded.has(name)) continue;
const js =
resolveBareModule(d.loc.file, name, "js-only") ??
resolveBareModule(d.loc.file, name, "js-only", "require");
const at = js === null ? null : packageDirOf(js.typesFile);
if (at === null || at.name !== name) continue;
grown.set(name, {
name,
version: packageVersionOf(at.dir),
dir: at.dir,
class: "packaging",
reason: `${d.code}: ${d.message}`,
});
continue;
}
if (!isIslandReasonCode(d.code)) continue;
const at = packageDirOf(d.loc.file);
if (at === null || !compiled.has(at.name) || grown.has(at.name)) continue;
if (policy.forcedNative.has(at.name) || policy.unloaded.has(at.name)) continue;
const reason = classifyDiagnostic(d, "refusal");
grown.set(at.name, {
name: at.name,
version: packageVersionOf(at.dir),
dir: at.dir,
class: reason.class,
reason: describe(reason),
});
}
// A native ES module that imports an island CommonJS module would read
// its bindings from the module's exports object, where Node gives it the
// namespace its CommonJS facade builds (the lexed names, module.exports
// as the default): the importing package runs in the island too.
for (let added = edges !== undefined; added;) {
added = false;
for (const edge of edges!) {
const name = edge.to;
if (!grown.has(name) || edge.fromDir === null || edge.shim) continue;
const at = packageDirOf(edge.fromFile);
if (at === null || grown.has(at.name) || !compiled.has(at.name)) continue;
if (policy.forcedNative.has(at.name) || policy.unloaded.has(at.name)) continue;
if (!isEsmFile(edge.fromFile)) continue;
const target =
resolveBareModule(edge.fromFile, name, "js-only") ??
resolveBareModule(edge.fromFile, name, "js-only", "require");
if (target === null || isEsmFile(target.typesFile)) continue;
grown.set(at.name, {
name: at.name,
version: packageVersionOf(at.dir),
dir: at.dir,
class: "packaging",
reason: `an ES module of the package imports the CommonJS island package '${name}'`,
});
added = true;
}
}
return grown.size === known.size ? null : grown;
}
/** Preflight refusals at import statements of natively compiled npm
* packages (`--node-islands=auto` only): those packages become islands. */
function preflightIslands(
entryPath: string,
opts: CompileRequestOptions,
fe: ReturnType<FrontendFactory>,
known: ReadonlyMap<string, IslandPackage>,
): Map<string, IslandPackage> | null {
const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands);
if (policy.mode !== "auto" || known.size > 16) return null;
const compiled = new Set(fe.npmStatic.filter((e) => e.status === "static").map((e) => e.package));
if (compiled.size === 0) return null;
const texts = fe.sourceTexts();
const grown = new Map(known);
for (const d of fe.preflight) {
if (d.code !== "SC1013") continue;
const text = texts.get(d.loc.file);
if (text === undefined) continue;
// The import statement around the diagnostic: its module specifier.
const start = text.lastIndexOf("import", d.loc.start);
const end = text.indexOf(";", d.loc.end);
const stmt = text.slice(start < 0 ? d.loc.start : start, end < 0 ? text.length : end);
const m = /\bfrom\s*["']([^"']+)["']/.exec(stmt);
if (m === null) continue;
const spec = m[1]!;
const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]!;
if (!compiled.has(name) || grown.has(name)) continue;
if (policy.forcedNative.has(name) || policy.unloaded.has(name)) continue;
const site = fe.npmImportSites.get(name);
const jsEntry = site === undefined ? null : resolveBareModule(site.file, spec, "js-only");
const at = jsEntry === null ? null : packageDirOf(jsEntry.typesFile);
if (at === null) continue;
grown.set(name, {
name,
version: packageVersionOf(at.dir),
dir: at.dir,
class: "gap",
reason: `${d.code}: ${d.message}`,
});
}
return grown.size === known.size ? null : grown;
}
@@ -194,9 +194,13 @@ function createRequireFence(
surface: string,
node: ts.Node,
hint: string,
reason?: string,
): IrStmt & { kind: "runtimeFence" } {
const loc = locOf(node);
const d = noLoweringDiag(surface, loc, hint);
// The Node island reason the refusal would carry (a computed require is
// dynamic module loading), so island selection classifies the fence.
if (reason !== undefined) d.reason = reason;
lowerer.runtimeFences.push(d);
const line = ts.getLineAndCharacterOfPosition(ts.getSourceFileOfNode(node), loc.start).line + 1;
return {
@@ -299,6 +303,7 @@ function deferredCreateRequireThrow(
surface: string,
hint: string,
loc: SrcLoc,
reason?: string,
): IrExpr {
const stmts: IrStmt[] = call.arguments.nodes.map((arg) => ({
kind: "exprStmt",
@@ -310,7 +315,7 @@ function deferredCreateRequireThrow(
slot.mutable = true;
stmts.push(
{ kind: "varDecl", localId: slot.id, init: null, loc },
createRequireFence(lowerer, surface, call, hint),
createRequireFence(lowerer, surface, call, hint, reason),
);
return {
kind: "seqExpr",
@@ -339,7 +344,7 @@ export function lowerCreateRequireCall(
// Spread arguments have no run-time resolution: they run, then the
// call throws the fence.
if (args.some((a) => ts.isSpreadElement(a)))
return deferredCreateRequireThrow(lowerer, call, surface, hint, loc);
return deferredCreateRequireThrow(lowerer, call, surface, hint, loc, "dynamic-require");
// Node evaluates every argument, then requires the first.
const stmts: IrStmt[] = [];
let id = args.length > 0 ? lowerer.lowerExprExpecting(args[0]!, DYN) : dynUndefinedExpr(loc);
@@ -12,6 +12,7 @@ import {
lowerNewTargetName,
} from "./new-target.js";
import { lowerWebAssemblyValue, refuseWebAssemblyUse } from "./builtins/webassembly.js";
import { lowerIslandExpr } from "./lower-node-island.js";
import { lowerAwaitedUniformPromiseAll } from "./builtins/promises.js";
import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js";
import { InternalCompilerError } from "../../errors.js";
@@ -289,6 +290,13 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
const chainRecv = lowerer.chainRecvByNode.get(expr);
if (chainRecv) return { ...chainRecv, loc };
// Node islands (scope-B NI-4): member operations on island values and
// island import bindings. Programs without islands skip it.
if (lowerer.nodeIslands !== null) {
const island = lowerIslandExpr(lowerer, expr);
if (island !== null) return island;
}
// --external-types supplies CHECKER truth only. A value rooted in one
// of those declarations has no runtime implementation in scriptc, so
// every use fences at its expression instead of accidentally lowering
@@ -1,6 +1,7 @@
import { computedGlobalReadOf, globalValuesInstall } from "./global-values.js";
import type { TypeFailureSlot } from "../../diagnostics/diagnostic.js";
import { THREADS_MODULE } from "../threads-import.js";
import { collectIslandImport } from "./lower-node-island.js";
import { NODE_API_MODULE } from "../node-api-import.js";
import { positionTag } from "./structural-positions.js";
import { EXPERIMENTAL_MODULE } from "../experimental-import.js";
@@ -653,7 +654,13 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void {
// graph: resolveImport follows its node_modules link to the loaded
// TypeScript entry. Its internal `.js` spellings have already been
// resolved to `.ts` by that graph.
if (!isRelativeSpecifier(spec) && resolveImport(lowerer.program, fp.sf, spec) !== null) {
const projectDep = isRelativeSpecifier(spec)
? null
: resolveImport(lowerer.program, fp.sf, spec);
if (projectDep !== null) {
// A Node island package that tsconfig `paths` maps to its installed
// directory resolves to its declarations: it loads in Node.
if (projectDep.isDeclarationFile) collectIslandImport(lowerer, fp.sf, stmt, spec);
continue;
}
const npm = resolveNpmImport(fp.sf.fileName, spec);
@@ -705,6 +712,9 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void {
// it, %main throws before any init runs, and the bindings never
if (npm !== null && probeNodeImportRefusal(fp.sf.fileName, spec) !== null) continue;
// A Node island package (scope-B NI-4): loaded in Node at this
// module's position, its bindings are island values.
if (collectIslandImport(lowerer, fp.sf, stmt, spec)) continue;
{
lowerer.pushDiag(unsupportedPackageImportDiag(npm?.packageName ?? relPkg!, locOf(stmt)));
continue;
@@ -41,6 +41,7 @@ import * as ts from "../ts7/adapter.js";
import { isPackageShimFile, isShimIntrinsicName, packageShimFor } from "../package-shims.js";
import type { Lowerer } from "./lowerer.js";
import { boundIdentifiersOf } from "./lowerer.js";
import { isIslandImportBinding } from "./lower-node-island.js";
import type { FileParts } from "./lower-modules.js";
import { isCjsJsFile, isJsSourceFile, locOf, resolveImport } from "../program.js";
import { F64, type IrExpr, type IrStmt, type IrType, STRING } from "../../ir/ir.js";
@@ -686,6 +687,9 @@ export function ambientUndefinedVarSymbolOf(
lowerer: Lowerer,
ident: ts.Identifier,
): ts.Symbol | null {
// An island package's `declare const` is defined by its JavaScript, which
// runs in the island.
if (isIslandImportBinding(lowerer, ident)) return null;
const sym = lowerer.resolveValueSymbol(ident);
const decl = sym ? sym.declarations[0] : undefined;
if (
@@ -16,14 +16,35 @@
* 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 { isAbsolute } from "node:path";
import { readFileSync, realpathSync } from "node:fs";
import { resolveFrom } from "../../node-islands/resolve.js";
import { dirname, isAbsolute, join } from "node:path";
import * as ts from "../ts7/adapter.js";
import { locOf } from "../program.js";
import type { NodeApiMember } from "../node-api-import.js";
import { PoisonError, type Lowerer } from "./lowerer.js";
import { nodeIslandModuleDiag } from "../../diagnostics/diagnostic.js";
import { DYN, STRING, type IrExpr, type IrType } from "../../ir/ir.js";
import {
BOOL,
DYN,
FOREIGN,
STRING,
type IrExpr,
type IrGlobal,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../ir/ir.js";
import { varRef } from "../../ir/build.js";
import { resolveBareModule } from "../resolve.js";
import { nodeIslandBoundaryDiag, nodeIslandModuleDiag } from "../../diagnostics/diagnostic.js";
import {
crossingOf,
isForeignType,
islandPackageOfFile,
type NodeIslandSet,
} from "../../node-islands/boundary.js";
import { stripTypeWrappers } from "./typed-arguments.js";
import { thenReader } from "./checked-promise-all.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";
@@ -88,6 +109,7 @@ export function lowerNodeApiCall(
const loc = locOf(call);
const surface = `${member} (@scriptc/node)`;
const argNodes = call.arguments.nodes;
if (member === "requireModule") return lowerRequireModule(lowerer, call, surface);
if (call.questionDotToken || argNodes.length < 2 || argNodes.some(ts.isSpreadElement)) {
lowerer.noLowering(
`${surface} without a specifier and an export name, or with spread arguments`,
@@ -155,3 +177,766 @@ export function lowerNodeApiCall(
// `JSON.parse(s) as T`: types don't lie, a mismatch throws.
return lowerer.coerceToExpected(raw, declared);
}
/** requireModule(specifier): Node's require() from the calling module, run
* in the island; the exports cross by reference (scope-B NI-4: an island
* handle the program uses as an `any` value). The specifier must be a
* string literal: it resolves at build time exactly as require.resolve
* resolves it from the caller, and the island loads that file, shipped
* with the program (islandLoadPath). */
function lowerRequireModule(lowerer: Lowerer, call: ts.CallExpression, surface: string): IrExpr {
const loc = locOf(call);
const argNodes = call.arguments.nodes;
const arg = argNodes[0];
if (
call.questionDotToken ||
argNodes.length !== 1 ||
arg === undefined ||
!ts.isStringLiteral(arg)
) {
lowerer.noLowering(
`${surface} without exactly one string-literal specifier`,
call,
"pass the module specifier as a string literal",
);
}
const from = callerPath(call);
let path: string;
try {
path = resolveFrom(from, arg.text);
} catch {
lowerer.noLowering(
`${surface} of '${arg.text}', which does not resolve from ${from}`,
call,
"install the module or fix the specifier",
);
}
lowerer.usesNodeIsland = true;
const raw: IrExpr = {
kind: "libCall",
fn: "nodeIsland.require",
args: [islandLoadPath(lowerer, path, loc)],
type: DYN,
loc,
};
const declared = lowerer.mapTypeOf(lowerer.typeOf(call));
if (declared === null || declared === undefined || declared.kind === "dyn") return raw;
return lowerer.coerceToExpected(raw, declared);
}
/* ── compiler-managed Node islands (scope-B NI-4) ─────────────────────────
*
* A package the build runs in a Node island is never compiled. Its static
* imports load the island module at the importing module's position
* (eager evaluation: `nodeIsland.require` in the module's %init prelude,
* before the body), and their bindings read island values: a named import
* of an ES module reads the live namespace member at each use; a CommonJS
* module's named imports are read once after it evaluates, as Node's
* namespace for CommonJS snapshots them; a default import of CommonJS is
* module.exports. `require()` of an island package anywhere and `import()`
* load the same way at the call.
*
* Island values are typed by the package's declarations: values of
* island-declared object types are island handles (FOREIGN, see
* node-islands/boundary.ts), and member reads, writes, calls and `new` on
* them become bridge calls whose results meet the declared type through
* the checked dyn conversion (types don't lie: a mismatch throws). */
/** One island module load: the module global that holds what the import
* form sees (module.exports for CommonJS, the namespace for ESM). */
export interface IslandModuleBinding {
global: IrGlobal;
/** The JavaScript file Node loads. */
path: string;
esm: boolean;
/** CommonJS named imports, snapshotted after evaluation: name -> global. */
snapshots: Map<string, IrGlobal>;
}
function boundaryFail(lowerer: Lowerer, what: string, node: ts.Node): never {
lowerer.pushDiag(nodeIslandBoundaryDiag(what, locOf(node)));
throw new PoisonError();
}
/** Whether the JavaScript file is an ES module by Node's rules. */
export function isEsmFile(file: string): boolean {
if (file.endsWith(".mjs")) return true;
if (file.endsWith(".cjs")) return false;
for (let dir = dirname(file); ;) {
try {
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
type?: unknown;
};
return pkg.type === "module";
} catch {
/* no package.json here */
}
const parent = dirname(dir);
if (parent === dir) return false;
dir = parent;
}
}
/** The island module file `spec` names from `fromFile`, or null when the
* specifier does not resolve into an island package. */
export function islandModuleFile(
islands: NodeIslandSet,
fromFile: string,
spec: string,
kind: "import" | "require",
): string | null {
const js = resolveBareModule(fromFile, spec, "js-only", kind);
if (js === null) return null;
if (islandPackageOfFile(islands, js.typesFile) === null) return null;
try {
return realpathSync(js.typesFile);
} catch {
return js.typesFile;
}
}
/** The module global of the island module at `path`, created on first use. */
function islandModuleGlobal(lowerer: Lowerer, path: string): IslandModuleBinding {
let binding = lowerer.islandModules.get(path);
if (binding === undefined) {
const global: IrGlobal = {
id: `%g.island.${lowerer.globalsList.length}`,
name: path.split("/").at(-1) ?? "island",
type: DYN,
mutable: true,
};
lowerer.globalsList.push(global);
binding = { global, path, esm: isEsmFile(path), snapshots: new Map() };
lowerer.islandModules.set(path, binding);
}
return binding;
}
/** The path literal of a load of the file at `path`. The build ships the
* file with the program (node-islands/modules.ts): the literal names it by
* its path in the shipped tree once lowering ends. A built-in module keeps
* its name. */
function islandLoadPath(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
const literal: IrExpr & { kind: "strLit" } = { kind: "strLit", value: path, type: STRING, loc };
if (!isAbsolute(path)) return literal;
let real = path;
try {
real = realpathSync(path);
} catch {
/* the resolved path */
}
const problem = lowerer.nodeIslandModules.addLoaded(real);
if (problem !== null) {
lowerer.pushDiag(nodeIslandModuleDiag(problem, loc));
throw new PoisonError();
}
lowerer.nodeIslandLoads.push({ literal, path: real });
return literal;
}
function loadIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
return {
kind: "libCall",
fn: "nodeIsland.require",
args: [islandLoadPath(lowerer, path, loc)],
type: DYN,
loc,
};
}
/** A static import (or re-export) of an island package: loads the module
* in the importer's %init prelude. Returns false when `spec` is not an
* island module. */
export function collectIslandImport(
lowerer: Lowerer,
sf: ts.SourceFile,
stmt: ts.Statement,
spec: string,
): boolean {
const islands = lowerer.nodeIslands;
if (islands === null) return false;
const path = islandModuleFile(islands, sf.fileName, spec, "import");
if (path === null) return false;
if (ts.isExportDeclaration(stmt)) {
lowerer.pushDiag(
nodeIslandBoundaryDiag(`re-exporting the Node island module '${spec}'`, locOf(stmt)),
);
return true;
}
lowerer.usesNodeIsland = true;
const loc = locOf(stmt);
const binding = islandModuleGlobal(lowerer, path);
const actions = lowerer.jsonInitActions.get(sf) ?? [];
lowerer.jsonInitActions.set(sf, actions);
actions.push({
kind: "assign",
localId: binding.global.id,
value: loadIslandModule(lowerer, path, loc),
loc,
});
// CommonJS named imports: Node's namespace snapshots them once the
// module evaluated.
const clause = ts.isImportDeclaration(stmt) ? stmt.importClause : undefined;
const named = clause?.namedBindings;
if (!binding.esm && named !== undefined && ts.isNamedImports(named)) {
for (const el of named.elements.nodes) {
if (el.isTypeOnly) continue;
const name = el.propertyName?.text ?? el.name.text;
if (name === "default") continue;
let g = binding.snapshots.get(name);
if (g === undefined) {
g = {
id: `%g.island.${lowerer.globalsList.length}`,
name,
type: DYN,
mutable: true,
};
lowerer.globalsList.push(g);
binding.snapshots.set(name, g);
}
actions.push({
kind: "assign",
localId: g.id,
value: islandGet(varRef(binding.global.id, DYN, loc), name, loc),
loc,
});
}
}
return true;
}
function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr {
return {
kind: "libCall",
fn: "nodeIsland.get",
args: [target, { kind: "strLit", value: key, type: STRING, loc }],
type: DYN,
loc,
};
}
type IslandBinding =
| { kind: "namespace"; module: IslandModuleBinding }
| { kind: "default"; module: IslandModuleBinding }
| { kind: "named"; module: IslandModuleBinding; name: string };
function moduleSpecifierOf(decl: ts.Node | undefined): string | null {
return ts.isImportDeclaration(decl) && ts.isStringLiteral(decl.moduleSpecifier)
? decl.moduleSpecifier.text
: null;
}
/** What an identifier binds from an island module, or null. */
/** Whether `ident` names an import of a Node island module: its value
* comes from the island, whatever the package's declarations say. */
export function isIslandImportBinding(lowerer: Lowerer, ident: ts.Identifier): boolean {
return islandBindingOf(lowerer, ident) !== null;
}
function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding | null {
const islands = lowerer.nodeIslands;
if (islands === null) return null;
const symbol = lowerer.checker.getSymbolAtLocation(ident);
const decl = symbol ? symbol.declarations[0] : undefined;
if (decl === undefined) return null;
let importDecl: ts.Node | undefined;
let binding: "namespace" | "default" | { name: string };
if (ts.isNamespaceImport(decl)) {
importDecl = decl.parent?.parent;
binding = "namespace";
} else if (ts.isImportClause(decl)) {
importDecl = decl.parent;
binding = "default";
} else if (ts.isImportSpecifier(decl)) {
importDecl = decl.parent?.parent?.parent;
const name = decl.propertyName?.text ?? decl.name.text;
binding = name === "default" ? "default" : { name };
} else {
return null;
}
const spec = moduleSpecifierOf(importDecl);
if (spec === null || importDecl === undefined) return null;
const path = islandModuleFile(
islands,
ts.getSourceFileOfNode(importDecl).fileName,
spec,
"import",
);
if (path === null) return null;
const module = lowerer.islandModules.get(path);
if (module === undefined) return null;
if (binding === "namespace") return { kind: "namespace", module };
if (binding === "default") return { kind: "default", module };
return { kind: "named", module, name: binding.name };
}
/** The raw (dyn) value an island import binding reads. */
function islandBindingValue(
lowerer: Lowerer,
b: IslandBinding,
node: ts.Node,
loc: SrcLoc,
): IrExpr {
const ns = varRef(b.module.global.id, DYN, loc);
switch (b.kind) {
case "namespace":
if (!b.module.esm)
boundaryFail(lowerer, "the namespace object of a CommonJS island module as a value", node);
return ns;
case "default":
return b.module.esm ? islandGet(ns, "default", loc) : ns;
case "named": {
if (b.module.esm) return islandGet(ns, b.name, loc);
const g = b.module.snapshots.get(b.name);
return g === undefined ? islandGet(ns, b.name, loc) : varRef(g.id, DYN, loc);
}
}
}
/** An island value (dyn) at the declared type of `node`: island handles
* stay dyn (FOREIGN), primitives and strings meet their declared type
* through the checked conversion. */
function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr {
const tsType = lowerer.typeOf(node);
const islands = lowerer.nodeIslands!;
if (isForeignType(islands, tsType, lowerer.checker)) return raw;
if ((tsType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return raw;
if ((tsType.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) !== 0 && isDiscarded(node))
return raw;
const mapped = lowerer.mapTypeOf(tsType);
if (mapped === null)
boundaryFail(
lowerer,
`an island value of type '${lowerer.checker.typeToString(tsType)}'`,
node,
);
if (mapped.kind === "dyn") return raw;
if (mapped.kind === "void") return raw;
// An island promise is the same promise on both sides (the runtime keeps
// one ScrPromise per V8 promise): PromiseResolve of the dyn value, whose
// settlement value meets the declared type when it is awaited.
if (
mapped.kind === "promise" &&
(mapped.inner.kind === "dyn" || copiedType(lowerer, mapped.inner))
) {
const promise: IrExpr = {
kind: "libCall",
fn: "dyn.promiseResolve",
args: [raw, thenReader(lowerer, raw.loc)],
type: { kind: "promise", inner: DYN },
loc: raw.loc,
};
return mapped.inner.kind === "dyn" ? promise : lowerer.coerceToExpected(promise, mapped);
}
if (!copiedType(lowerer, mapped))
boundaryFail(
lowerer,
`an island value of type '${lowerer.checker.typeToString(tsType)}' (only primitives and strings are copied; objects of types the island does not declare stay handles from NI-5 on)`,
node,
);
return lowerer.coerceToExpected(raw, mapped);
}
/** Whether a value of IR type `t` is copied at the boundary: primitives,
* strings, null/undefined, and unions of those. */
function copiedType(lowerer: Lowerer, t: IrType): boolean {
switch (t.kind) {
case "f64":
case "string":
case "bool":
case "bigint":
case "nullT":
case "undefinedT":
return true;
case "union": {
const def = lowerer.unions.get(t.unionId);
return def !== undefined && def.arms.every((a) => copiedType(lowerer, a));
}
default:
return false;
}
}
function isDiscarded(node: ts.Node): boolean {
let n: ts.Node = node;
while (n.parent && ts.isParenthesizedExpression(n.parent)) n = n.parent;
return (
n.parent !== undefined && (ts.isExpressionStatement(n.parent) || ts.isVoidExpression(n.parent))
);
}
/** Whether `node` evaluates to an island handle (by its declared type) or
* is an island namespace binding. */
function isForeignExpr(lowerer: Lowerer, node: ts.Expression): boolean {
const islands = lowerer.nodeIslands;
if (islands === null) return false;
const inner = stripTypeWrappers(node);
if (ts.isIdentifier(inner) && islandBindingOf(lowerer, inner) !== null) return true;
return isForeignType(islands, lowerer.typeOf(inner), lowerer.checker);
}
/** A value crossing INTO the island (an argument or an assigned value), as
* dyn. */
function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr {
const islands = lowerer.nodeIslands!;
const inner = stripTypeWrappers(node);
if (ts.isObjectLiteralExpression(inner) || ts.isArrayLiteralExpression(inner)) {
return freshLiteral(lowerer, inner);
}
const crossing = crossingOf(islands, lowerer.typeOf(node), lowerer.checker);
switch (crossing) {
case "copy":
case "handle":
return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN);
case "dyn":
case "refused":
return boundaryFail(
lowerer,
`a '${lowerer.checker.typeToString(lowerer.typeOf(node))}' value (a native object would cross as a copy and lose its identity)`,
node,
);
}
}
/** A fresh object or array literal crosses as a copy (a plain dyn object
* or array the runtime copies into V8): nothing native can alias it. Its
* members cross by the same rules (toIsland). */
function freshLiteral(
lowerer: Lowerer,
node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression,
): IrExpr {
const loc = locOf(node);
if (ts.isArrayLiteralExpression(node)) {
const elems = node.elements.nodes.map((el) => {
if (ts.isSpreadElement(el) || ts.isOmittedExpression(el))
boundaryFail(lowerer, "a spread or hole in an array literal", el);
return toIsland(lowerer, el);
});
return { kind: "dynArrLit", elems, type: DYN, loc };
}
const fields = node.properties.nodes.map((p) => {
let key: string;
let value: IrExpr;
if (ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) {
key = p.name.text;
value = toIsland(lowerer, p.initializer);
} else if (ts.isShorthandPropertyAssignment(p)) {
const name = p.name as ts.Identifier;
key = name.text;
value = toIsland(lowerer, name);
} else {
return boundaryFail(
lowerer,
"a computed key, method, accessor or spread in an object literal",
p,
);
}
return { key: { kind: "strLit", value: key, type: STRING, loc } as IrExpr, value };
});
return { kind: "dynObjLit", fields, type: DYN, loc };
}
/** Whether evaluating an argument runs no code that could change the
* receiver: literals, names, `this`, functions it creates, object and
* array literals of those, and operators over those. A unary operator
* (`+`, `-`, `~`) converts its operand with ToNumber, which calls a
* user-defined `valueOf`: only a numeric literal operand is inert. */
function inertArgument(node: ts.Expression): boolean {
const e = stripTypeWrappers(node);
if (
ts.isIdentifier(e) ||
ts.isLiteralExpression(e) ||
ts.isNoSubstitutionTemplateLiteral(e) ||
ts.isArrowFunction(e) ||
ts.isFunctionExpression(e) ||
e.kind === ts.SyntaxKind.ThisKeyword ||
e.kind === ts.SyntaxKind.TrueKeyword ||
e.kind === ts.SyntaxKind.FalseKeyword ||
e.kind === ts.SyntaxKind.NullKeyword
)
return true;
if (ts.isParenthesizedExpression(e)) return inertArgument(e.expression);
if (ts.isArrayLiteralExpression(e))
return e.elements.nodes.every((el) => !ts.isSpreadElement(el) && inertArgument(el));
if (ts.isObjectLiteralExpression(e))
return e.properties.nodes.every(
(p) =>
ts.isShorthandPropertyAssignment(p) ||
ts.isMethodDeclaration(p) ||
(ts.isPropertyAssignment(p) &&
!ts.isComputedPropertyName(p.name) &&
inertArgument(p.initializer)),
);
if (ts.isPrefixUnaryExpression(e)) {
// `!x` runs no conversion on its operand. `+`, `-` and `~` convert it
// with ToNumber, which calls a user-defined `valueOf` on an object:
// that can replace the method being called, so only a numeric literal
// is inert.
if (e.operator === ts.SyntaxKind.ExclamationToken) return inertArgument(e.operand);
return e.operand.kind === ts.SyntaxKind.NumericLiteral;
}
return false;
}
function argsOf(lowerer: Lowerer, args: readonly ts.Expression[], loc: SrcLoc): IrExpr {
for (const a of args) if (ts.isSpreadElement(a)) boundaryFail(lowerer, "spread arguments", a);
return { kind: "dynArrLit", elems: args.map((a) => toIsland(lowerer, a)), type: DYN, loc };
}
/** Expressions on island values (the lowerExpr hook; null: not an island
* expression, the ordinary lowering applies). */
export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | null {
if (lowerer.nodeIslands === null) return null;
const loc = locOf(expr);
if (ts.isIdentifier(expr)) {
const b = islandBindingOf(lowerer, expr);
if (b === null) return null;
lowerer.usesNodeIsland = true;
return fromIsland(lowerer, islandBindingValue(lowerer, b, expr, loc), expr);
}
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.name)) {
if (!isForeignExpr(lowerer, expr.expression)) return null;
if (expr.questionDotToken)
boundaryFail(lowerer, "an optional member read on an island value", expr);
lowerer.usesNodeIsland = true;
const target = islandReceiver(lowerer, expr.expression, expr.name.text, loc);
if (target.direct !== undefined) return fromIsland(lowerer, target.direct, expr);
return fromIsland(lowerer, islandGet(target.value, expr.name.text, loc), expr);
}
if (ts.isElementAccessExpression(expr)) {
if (!isForeignExpr(lowerer, expr.expression)) return null;
if (expr.questionDotToken)
boundaryFail(lowerer, "an optional member read on an island value", expr);
lowerer.usesNodeIsland = true;
const target = lowerer.lowerExpr(expr.expression);
const key = toIsland(lowerer, expr.argumentExpression);
return fromIsland(
lowerer,
{ kind: "libCall", fn: "nodeIsland.getKey", args: [target, key], type: DYN, loc },
expr,
);
}
if (ts.isCallExpression(expr)) {
const dynamicLoad = lowerIslandLoadCall(lowerer, expr, loc);
if (dynamicLoad !== undefined) return dynamicLoad;
const callee = stripTypeWrappers(expr.expression);
if (expr.questionDotToken) return null;
const args = expr.arguments.nodes;
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) {
if (!isForeignExpr(lowerer, callee.expression)) return null;
if (callee.questionDotToken)
boundaryFail(lowerer, "an optional call on an island value", expr);
lowerer.usesNodeIsland = true;
const target = islandReceiver(lowerer, callee.expression, callee.name.text, loc);
if (target.direct !== undefined) {
return fromIsland(
lowerer,
{
kind: "libCall",
fn: "nodeIsland.apply",
args: [target.direct, argsOf(lowerer, args, loc)],
type: DYN,
loc,
},
expr,
);
}
const name: IrExpr = { kind: "strLit", value: callee.name.text, type: STRING, loc };
if (args.every((a) => inertArgument(a))) {
return fromIsland(
lowerer,
{
kind: "libCall",
fn: "nodeIsland.invoke",
args: [target.value, name, argsOf(lowerer, args, loc)],
type: DYN,
loc,
},
expr,
);
}
// An argument that runs code could replace the method: JavaScript
// reads the method first, then evaluates the arguments.
const stmts: IrStmt[] = [];
const receiver = lowerer.declareHiddenLocal("%islandRecv", target.value.type);
stmts.push({ kind: "varDecl", localId: receiver.id, init: target.value, loc });
const recv = varRef(receiver.id, target.value.type, loc);
const method = lowerer.declareHiddenLocal("%islandMethod", DYN);
stmts.push({
kind: "varDecl",
localId: method.id,
init: { kind: "libCall", fn: "nodeIsland.get", args: [recv, name], type: DYN, loc },
loc,
});
const call: IrExpr = {
kind: "libCall",
fn: "nodeIsland.applyTo",
args: [varRef(method.id, DYN, loc), recv, argsOf(lowerer, args, loc)],
type: DYN,
loc,
};
const result = fromIsland(lowerer, call, expr);
return { kind: "seqExpr", stmts, result, type: result.type, loc };
}
if (!isForeignExpr(lowerer, callee)) return null;
lowerer.usesNodeIsland = true;
const fn = lowerer.lowerExpr(callee);
return fromIsland(
lowerer,
{
kind: "libCall",
fn: "nodeIsland.apply",
args: [fn, argsOf(lowerer, args, loc)],
type: DYN,
loc,
},
expr,
);
}
if (ts.isNewExpression(expr)) {
if (!isForeignExpr(lowerer, expr.expression)) return null;
lowerer.usesNodeIsland = true;
const ctor = lowerer.lowerExpr(expr.expression);
return fromIsland(
lowerer,
{
kind: "libCall",
fn: "nodeIsland.construct",
args: [ctor, argsOf(lowerer, expr.arguments?.nodes ?? [], loc)],
type: DYN,
loc,
},
expr,
);
}
if (ts.isBinaryExpression(expr)) {
const op = expr.operatorToken.kind;
if (op === ts.SyntaxKind.InstanceOfKeyword && isForeignExpr(lowerer, expr.right)) {
lowerer.usesNodeIsland = true;
const value = lowerer.coerceToExpected(lowerer.lowerExpr(expr.left), DYN);
const ctor = lowerer.lowerExpr(expr.right);
return { kind: "libCall", fn: "nodeIsland.instanceof", args: [value, ctor], type: BOOL, loc };
}
if (op === ts.SyntaxKind.EqualsToken) {
const left = stripTypeWrappers(expr.left);
if (
(ts.isPropertyAccessExpression(left) && ts.isIdentifier(left.name)) ||
ts.isElementAccessExpression(left)
) {
if (!isForeignExpr(lowerer, left.expression)) return null;
lowerer.usesNodeIsland = true;
// JavaScript's order: the receiver, then the key, then the value.
const stmts: IrStmt[] = [];
const hold = (name: string, init: IrExpr, type: IrType): IrExpr => {
const local = lowerer.declareHiddenLocal(name, type);
stmts.push({ kind: "varDecl", localId: local.id, init, loc });
return varRef(local.id, type, loc);
};
const target = lowerer.lowerExpr(left.expression);
const receiver = hold("%islandSetTarget", target, target.type);
const key = ts.isPropertyAccessExpression(left)
? null
: hold("%islandSetKey", toIsland(lowerer, left.argumentExpression), DYN);
const v = hold("%islandSet", toIsland(lowerer, expr.right), DYN);
stmts.push({
kind: "exprStmt",
expr:
key === null
? {
kind: "libCall",
fn: "nodeIsland.set",
args: [
receiver,
{
kind: "strLit",
value: (left as ts.PropertyAccessExpression).name.text,
type: STRING,
loc,
},
v,
],
type: { kind: "void" },
loc,
}
: {
kind: "libCall",
fn: "nodeIsland.setKey",
args: [receiver, key, v],
type: { kind: "void" },
loc,
},
loc,
});
const result = fromIsland(lowerer, v, expr);
return { kind: "seqExpr", stmts, result, type: result.type, loc };
}
return null;
}
const left = stripTypeWrappers(expr.left);
if (
op >= ts.SyntaxKind.FirstCompoundAssignment &&
op <= ts.SyntaxKind.LastCompoundAssignment &&
(ts.isPropertyAccessExpression(left) || ts.isElementAccessExpression(left)) &&
isForeignExpr(lowerer, left.expression)
) {
boundaryFail(lowerer, "a compound assignment to an island object's member", expr);
}
}
return null;
}
/** The receiver of a member operation on an island value. A CommonJS
* namespace binding's members are module.exports' (and `default` is
* module.exports itself), answered directly. */
function islandReceiver(
lowerer: Lowerer,
receiver: ts.Expression,
member: string,
loc: SrcLoc,
): { value: IrExpr; direct?: IrExpr } {
const inner = stripTypeWrappers(receiver);
if (ts.isIdentifier(inner)) {
const b = islandBindingOf(lowerer, inner);
if (b !== null && b.kind === "namespace" && !b.module.esm) {
const exports = varRef(b.module.global.id, DYN, loc);
if (member === "default") return { value: exports, direct: exports };
return { value: exports };
}
}
return { value: lowerer.coerceToExpected(lowerer.lowerExpr(receiver), FOREIGN) };
}
/** `require("pkg")` (the global require, anywhere) and `import("pkg")` of
* an island module: load it at the call. Undefined: not such a call. */
function lowerIslandLoadCall(
lowerer: Lowerer,
expr: ts.CallExpression,
loc: SrcLoc,
): IrExpr | undefined {
const islands = lowerer.nodeIslands!;
const arg = expr.arguments.nodes[0];
if (expr.arguments.nodes.length !== 1 || arg === undefined || !ts.isStringLiteralLike(arg))
return undefined;
const isImport = expr.expression.kind === ts.SyntaxKind.ImportKeyword;
const isRequire =
ts.isIdentifier(expr.expression) &&
expr.expression.text === "require" &&
!lowerer.checker
.getSymbolAtLocation(expr.expression)
?.declarations.some((d) => !ts.getSourceFileOfNode(d).isDeclarationFile);
if (!isImport && !isRequire) return undefined;
const file = ts.getSourceFileOfNode(expr).fileName;
const path = islandModuleFile(islands, file, arg.text, isImport ? "import" : "require");
if (path === null) return undefined;
lowerer.usesNodeIsland = true;
if (isImport) {
return {
kind: "libCall",
fn: "nodeIsland.import",
args: [islandLoadPath(lowerer, path, loc)],
type: { kind: "promise", inner: DYN },
loc,
};
}
return fromIsland(lowerer, loadIslandModule(lowerer, path, loc), expr);
}
@@ -2,6 +2,7 @@ import type { DiagnosticCode } from "../../diagnostics/codes.js";
import { keepsInitializerLayout, notePreservedLayout } from "./structural-instances.js";
import { jsGlobalValueByName } from "./global-values.js";
import { lowerTupleAliasDecl, stripTypeWrappers } from "./typed-arguments.js";
import { lowerIslandExpr } from "./lower-node-island.js";
import {
allBytesArms,
indexedUnionArms,
@@ -8032,6 +8033,11 @@ function lowerDereferencedElementWrite(lowerer: Lowerer, expr: ts.BinaryExpressi
export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStmt {
const stmtNode = expr.parent;
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
// Node islands (scope-B NI-4): writes and calls on island values.
if (lowerer.nodeIslands !== null) {
const island = lowerIslandExpr(lowerer, expr);
if (island !== null) return { kind: "exprStmt", expr: island, loc: locOf(expr) };
}
// A discarded conditional has no value to merge: represent its lazy
// branch selection directly as statement control flow. In particular,
// both arms may be void calls (`flag ? emitA() : emitB()`), for which a
@@ -44,6 +44,13 @@ import {
import { moduleNamespaceObject, moduleNamespaceValue } from "./module-namespace-elements.js";
import { keyedHelperName, moduleTagNumber, portableRelativePath } from "./stable-names.js";
import { positionTag, StructuralPositions } from "./structural-positions.js";
import {
activeNodeIslands,
isForeignType,
setActiveNodeIslands,
type NodeIslandSet,
} from "../../node-islands/boundary.js";
import type { IslandModuleBinding } from "./lower-node-island.js";
import { bindingSymbolOfRead, onlyForwarded, stripTypeWrappers } from "./typed-arguments.js";
import {
needsGeneralNodeStreams,
@@ -938,6 +945,9 @@ export interface LowerResult {
}
export interface LowerOptions {
/** Packages that run in Node islands (scope-B NI-4): their imports lower
* to island loads and their values to island handles. */
nodeIslands?: NodeIslandSet | null;
/** Parser/checker services owned by the load; required for npm graph analysis. */
frontendServices?: FrontendServices | undefined;
/** The project's tsconfig sets `checkJs` (see isCheckedJsSourceFile). */
@@ -1179,6 +1189,60 @@ export function lowerToIr(
entry: ts.SourceFile,
moduleOrder: ts.SourceFile[],
options: LowerOptions = {},
): LowerResult {
setActiveNodeIslands(options.nodeIslands ?? null);
try {
const result = lowerToIrWithIslands(program, entry, moduleOrder, options);
markModuleInitFences(program, result.runtimeFences);
return result;
} finally {
setActiveNodeIslands(null);
}
}
/** Marks the runtime fences in npm package code whose site is top-level
* module code (outside every function body): they throw when the module
* evaluates (ScrDiagnostic.moduleInit). */
function markModuleInitFences(program: ts.Program, fences: readonly ScrDiagnostic[]): void {
for (const d of fences) {
if (!d.loc.file.includes("/node_modules/")) continue;
// A fence without a source position belongs to synthesized code, such
// as the global object's lookup (global-values.ts), which runs when the
// program calls it, not when the module evaluates.
if (d.loc.start < 0) continue;
const sf = program.getSourceFile(d.loc.file);
if (sf === undefined) continue;
let inFunction = false;
let node: ts.Node | undefined = sf;
while (node !== undefined && !inFunction) {
let next: ts.Node | undefined;
ts.forEachChild(node, (child) => {
if (
next === undefined &&
ts.tokenStartOfNode(child) <= d.loc.start &&
d.loc.start < child.end
)
next = child;
return undefined;
});
if (next === undefined) break;
if (
ts.isFunctionLike(next) ||
(ts.isPropertyDeclaration(next) &&
!next.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.StaticKeyword))
)
inFunction = true;
node = next;
}
if (!inFunction) d.moduleInit = true;
}
}
function lowerToIrWithIslands(
program: ts.Program,
entry: ts.SourceFile,
moduleOrder: ts.SourceFile[],
options: LowerOptions,
): LowerResult {
const phaseTiming = process.env["SCRIPTC_TIMING"] === "1";
const phaseStarted = performance.now();
@@ -3550,6 +3614,11 @@ export class Lowerer {
/** The program calls into Node.js (@scriptc/node): the module carries
* the nodeIsland record, which the executable build fills in. */
usesNodeIsland = false;
/** The program's Node island packages (scope-B NI-4), or null. */
readonly nodeIslands: NodeIslandSet | null = activeNodeIslands();
/** Island module loads, by the importing file and specifier: the module
* global holding what the import form sees (lower-node-island.ts). */
readonly islandModules = new Map<string, IslandModuleBinding>();
/** The modules Node island calls load (lower-node-island.ts). */
readonly nodeIslandModules = new NodeIslandModuleCollector();
/** Each call's caller literal, set to its tree path when lowering ends. */
@@ -3560,6 +3629,8 @@ export class Lowerer {
caller: string;
target: string;
}[] = [];
/** Each island module load's path literal, set to its tree path when lowering ends. */
readonly nodeIslandLoads: { literal: IrExpr & { kind: "strLit" }; path: string }[] = [];
/** 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. */
@@ -4044,6 +4115,11 @@ export class Lowerer {
// one lowers again rather than replaying a record.
checkedTable("nodeIslandCallers", () => this.nodeIslandCallers.length),
checkedTable("nodeIslandSpecifiers", () => this.nodeIslandSpecifiers.length),
// Island module loads hold literals of the unit's IR too, and their
// module globals (with each CommonJS named import's snapshot global)
// are created by the first load: a unit that adds either lowers again.
checkedTable("islandModules", () => this.islandModules.size),
checkedTable("nodeIslandLoads", () => this.nodeIslandLoads.length),
checkedTable("fnSigsBySymbol", () => this.fnSigsBySymbol.size),
checkedTable("cjsExportsAssignments", () => this.cjsExportsAssignments.size),
checkedTable("inlineRequireCycleCalls", () => this.inlineRequireCycleCalls.size),
@@ -4267,6 +4343,7 @@ export class Lowerer {
static readonly unjournaled: readonly string[] = [
// settings
"frontendServices",
"nodeIslands",
"ambient",
"overridesAmbient",
"remainder",
@@ -4820,8 +4897,12 @@ export class Lowerer {
this.externalTypeSpecifiersByFile =
mode.externalTypeSpecifiersByFile ?? directExternalTypeSpecifiersByFile(this.externalTypes);
this.checker = program.getTypeChecker();
const islands = this.nodeIslands;
const shadow = canonShadowHook(this.checker);
this.typeCtx = {
...(islands !== null
? { foreignType: (t: ts.Type) => isForeignType(islands, t, this.checker) }
: {}),
checker: this.checker,
positionOf: (node) => this.positions.of(node),
shapes: this.shapes,
@@ -10568,14 +10649,16 @@ export class Lowerer {
}
/** The module's Node island record: the collected module tree, with each
* call's caller literal set to its path in that tree. The executable
* build fills in the libnode pack (index.ts). */
* call's caller literal and each load's path literal set to its path in
* that tree. The executable build fills in the libnode pack (index.ts). */
private nodeIslandRecord(): NonNullable<IrModule["nodeIsland"]> {
const modules = this.nodeIslandModules.tree();
for (const { literal, caller } of this.nodeIslandCallers)
literal.value = NodeIslandModuleCollector.callerPath(modules, caller);
for (const { literal, caller, target } of this.nodeIslandSpecifiers)
literal.value = NodeIslandModuleCollector.relativeSpecifier(caller, target);
for (const { literal, path } of this.nodeIslandLoads)
literal.value = NodeIslandModuleCollector.loadPath(modules, path);
return { libnode: "", nodeVersion: "", ...(modules === null ? {} : { modules }) };
}
@@ -6,7 +6,11 @@ import * as ts from "./ts7/adapter.js";
export const NODE_API_MODULE = "@scriptc/node";
export type NodeApiMember = "call" | "callSync";
export type NodeApiMember = "call" | "callSync" | "requireModule";
function isNodeApiMember(name: string): name is NodeApiMember {
return name === "call" || name === "callSync" || name === "requireModule";
}
function moduleSpecifierText(decl: ts.Node | undefined): string | null {
return ts.isImportDeclaration(decl) && ts.isStringLiteral(decl.moduleSpecifier)
@@ -28,7 +32,7 @@ export function nodeApiBindingOf(
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;
return isNodeApiMember(member) ? member : null;
}
/** The @scriptc/node export an identifier is a named-import binding of. */
@@ -55,6 +59,6 @@ export function nodeApiCalleeOf(program: ts.Program, callee: ts.Expression): Nod
)
return null;
const member = callee.name.text;
if (member !== "call" && member !== "callSync") return null;
if (!isNodeApiMember(member)) return null;
return isNodeApiNamespace(program, callee.expression) ? member : null;
}
+56 -40
View File
@@ -49,6 +49,7 @@ import { NODE_API_MODULE } from "./node-api-import.js";
import { erasedTypeOnlyImport } from "./type-only-imports.js";
import { EXPERIMENTAL_MODULE } from "./experimental-import.js";
import { readProjectConfig } from "../project-config.js";
import { setFrontendIslands } from "../node-islands/boundary.js";
import { typePatchDiagnostics } from "./type-patches/layer.js";
import { packageDirOf } from "../node-islands/origin.js";
import {
@@ -128,12 +129,18 @@ export interface Frontend {
}
/** Hosts own transport lifetimes; shared compiler stages own the frontend. */
/** What a frontend computes beyond the build's own needs. */
/** How a frontend loads the program, beyond its npm package selection. */
export interface FrontendOptions {
/** Packages that run in Node islands (scope-B NI-4): never compiled,
* typed by their declarations. */
islandPackages?: ReadonlySet<string>;
/** The Node island report (coverage): the project's scriptc settings and
* the detector's scan of the program and its npm packages. Builds do not
* read either. */
nodeIslands?: boolean;
/** The detector's scan alone, for a build that may run packages in Node
* islands (executable/prepare.ts decides which). */
islandScan?: boolean;
}
export type FrontendFactory = (
@@ -482,15 +489,21 @@ export function runFrontend(
transitive.add(base);
return base;
});
return loadFrontend(
entryPath,
trackedLoader,
"lib",
externalTypes,
[...new Set(names)],
transitive,
options,
);
const islands = options.islandPackages ?? new Set<string>();
setFrontendIslands(islands);
try {
return loadFrontend(
entryPath,
trackedLoader,
"lib",
externalTypes,
[...new Set(names)].filter((name) => !islands.has(name)),
transitive,
options,
);
} finally {
setFrontendIslands(new Set());
}
} catch (error) {
for (const load of active) {
// Preserve the original failure and attempt every remaining cleanup.
@@ -520,8 +533,10 @@ function loadFrontend(
clearResolveCaches();
const statuses: NpmStaticStatus[] = [];
const npmSites = new Map<string, SrcLoc>();
// Node island detection (report-only): how each package was refused, and
// the modules of packages removed from the program.
// Node island detection (the report, or a build that may run islands):
// how each package was refused, and the modules of packages removed from
// the program.
const scanIslands = options.nodeIslands === true || options.islandScan === true;
const refusals: RefusalRecord[] = [];
const droppedScan: { modules: NodeIslandScan["modules"]; edges: NodeIslandScan["edges"] } = {
modules: [],
@@ -532,7 +547,11 @@ function loadFrontend(
};
// Explicit library attempts bypass only the auto-selection heuristics.
// The shared preflight/lowering refusal and dependency-closure checks remain.
// Node island packages are judged already: they stay out of the
// program, typed by their declarations (scope-B NI-4).
const judged = new Set<string>(libraryNpmStatic);
if (options.islandPackages !== undefined)
for (const name of options.islandPackages) judged.add(name);
let requested: string[] = [];
let reusableScout: ReturnType<typeof loadProgram> | null = null;
let reusablePreflight: ScrDiagnostic[] | null = null;
@@ -658,7 +677,7 @@ function loadFrontend(
}
const dropping = [...reasons.keys()].filter((p) => effective.has(p));
if (dropping.length === 0) break;
if (options.nodeIslands === true) scanDroppedPackages(load, dropping, droppedScan);
if (scanIslands) scanDroppedPackages(load, dropping, droppedScan);
for (const p of dropping) {
effective.delete(p);
statuses.push({ package: p, status: "refused", detail: reasons.get(p)! });
@@ -710,15 +729,13 @@ function loadFrontend(
probe.dispose();
if (probeDiags.some((d) => d.code === "SC0001")) dropWithNote(p);
}
if (options.nodeIslands === true)
scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan);
if (scanIslands) scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan);
load.dispose();
load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes });
preflight = checkPreflight(load);
if (preflight.some((d) => d.code === "SC0001") && effective.size > 0) {
for (const p of [...effective]) dropWithNote(p);
if (options.nodeIslands === true)
scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan);
if (scanIslands) scanDroppedPackages(load, lastResortDropped.splice(0), droppedScan);
load.dispose();
load = loadProgram(entryPath, { npmStatic: [...effective], externalTypes });
preflight = checkPreflight(load);
@@ -820,29 +837,28 @@ function loadFrontend(
},
npmStatic: statuses,
npmImportSites: npmSites,
nodeIslandScan:
options.nodeIslands !== true
? undefined
: ({ userJs }) => {
const files = new Set<SourceFile>([finalLoad.entry, ...finalLoad.moduleOrder]);
addDynamicImportGraph(finalLoad, files);
// Package edges matter only when the program has npm packages: one
// that loads, one that was refused, or one judged and dropped.
const npm =
statuses.length > 0 ||
droppedScan.modules.length > 0 ||
[...files].some((sf) => sf.fileName.includes("/node_modules/"));
const scanned = scanFiles(finalLoad.program, [...files], {
userJs,
dropped: false,
edges: npm,
});
return {
modules: [...droppedScan.modules, ...scanned.modules],
edges: [...droppedScan.edges, ...scanned.edges],
refusals,
};
},
nodeIslandScan: !scanIslands
? undefined
: ({ userJs }) => {
const files = new Set<SourceFile>([finalLoad.entry, ...finalLoad.moduleOrder]);
addDynamicImportGraph(finalLoad, files);
// Package edges matter only when the program has npm packages: one
// that loads, one that was refused, or one judged and dropped.
const npm =
statuses.length > 0 ||
droppedScan.modules.length > 0 ||
[...files].some((sf) => sf.fileName.includes("/node_modules/"));
const scanned = scanFiles(finalLoad.program, [...files], {
userJs,
dropped: false,
edges: npm,
});
return {
modules: [...droppedScan.modules, ...scanned.modules],
edges: [...droppedScan.edges, ...scanned.edges],
refusals,
};
},
dispose: finalLoad.dispose,
};
}
+28 -1
View File
@@ -39,6 +39,7 @@
* JSON.parse's wording rather than tsc's, the one message-text delta on
* that path (no snapshot pins it). */
import { isFrontendIsland } from "../node-islands/boundary.js";
import { builtinModules } from "node:module";
import { dirname, isAbsolute, resolve } from "node:path";
import * as ts from "./ts7/adapter.js";
@@ -124,6 +125,7 @@ import {
isRelativeSpecifier,
isWorkspacePackageName,
npmPackageNameOf,
packageNameOfSpecifier,
registerWorkspacePackage,
workspacePackageOfPath,
} from "./workspace-registry.js";
@@ -5860,6 +5862,16 @@ function preflight7(load: LoadResult): {
);
continue;
}
// A re-export from a Node island package that tsconfig `paths` maps
// to its installed directory: its declarations are no program
// module (the lowering reports the island boundary).
if (
projectReexport !== null &&
projectReexport.isDeclarationFile &&
!isRelativeSpecifier(fromSpec) &&
isFrontendIsland(packageNameOfSpecifier(fromSpec))
)
continue;
const reDep = projectReexport;
// `export * as ns from "./m"` re-exports the module NAMESPACE
// object under a name: importers' `x.ns.member` reads resolve
@@ -5984,6 +5996,16 @@ function preflight7(load: LoadResult): {
// Node's startup crash with Node's exact message; what keeps a compile
// fence is unsupported builtin or types-only surface.
if (isBare && npmStaticDep === null) {
// A Node island package that tsconfig `paths` maps to its
// installed directory resolves to its declarations here: it loads
// in Node, typed by them, like an island package resolved through
// node_modules (scope-B NI-4).
if (
projDep !== null &&
projDep.isDeclarationFile &&
isFrontendIsland(packageNameOfSpecifier(spec))
)
continue;
if (projDep !== null && projDep.isDeclarationFile) {
diags.push(
unsupportedDiag(
@@ -6145,7 +6167,12 @@ function preflight7(load: LoadResult): {
),
);
}
} else if (dep === null && !(!isRelative && ambientModules.has(spec))) {
} else if (
dep === null &&
!(!isRelative && ambientModules.has(spec)) &&
// A Node island module's namespace (scope-B NI-4).
!(!isRelative && isFrontendIsland(packageNameOfSpecifier(spec)))
) {
diags.push(unsupportedDiag("SC1013", locOf7(clause.namedBindings)));
}
}
@@ -19,6 +19,7 @@ import {
CRYPTOHMAC_T,
DATE_T,
DYN,
FOREIGN,
F64,
funcOf,
isSupportedArrayElem,
@@ -1366,6 +1367,10 @@ export type TypeParamTsResolver = (t: ts.Type) => ts.Type | null;
* once per Lowerer and threaded through the recursion — adding a hook means
* one new field here, not a new parameter at every call site. */
export interface TypeMapperCtx {
/** Node islands (scope-B NI-4): whether a type's values are island
* handles, which map to the `foreign` type (a dyn value). Absent in
* programs without islands. */
foreignType?: (type: ts.Type) => boolean;
/** Set only while a union maps an arm that is the type inferred from an
* empty object literal, beside another non-nullish arm: that arm holds
* only the literal's objects, so it maps to the empty record (see the
@@ -2003,6 +2008,7 @@ function mapTypeEntry(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
return STRING;
if (flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) return BOOL;
}
if (ctx.foreignType !== undefined && ctx.foreignType(type)) return FOREIGN;
const localValue = ctx.localClassValue?.(type);
if (localValue) {
contextResolutions++;
+10
View File
@@ -1,3 +1,4 @@
import { createRequire } from "node:module";
import { shapeOverlayEnabled } from "./frontend/shape-inference/index.js";
import { nodeFrontendServices } from "./frontend/services-node.js";
import {
@@ -9,6 +10,8 @@ import { optimizationField, type NativeOptimization } from "./backend/optimizati
import { targetCpuField, targetCpuTargetRefusal } from "./backend/target-cpu.js";
import { compilationTiming, type CompilationTiming, type TimingClock } from "./timing.js";
import { prepareExecutableModule } from "./executable/prepare.js";
import { nodeIslandOptionsKey } from "./node-islands/policy.js";
import { setIslandResolver } from "./node-islands/resolve.js";
import {
prepareLibrary,
libraryLocalizeSymbols,
@@ -458,6 +461,10 @@ export async function analyzeCached(
return result;
}
// Island code resolves through Node (node-islands/resolve.ts; the
// self-hosted compiler builds no islands).
setIslandResolver((fromFile, spec) => createRequire(fromFile).resolve(spec));
const nodeFrontend: FrontendFactory = (
entry,
npmStatic,
@@ -1770,6 +1777,9 @@ async function compileTracked(
...(functionLayoutDigest === undefined ? {} : { functionLayout: functionLayoutDigest }),
...checkerField(),
...(youngNurseryRequested() ? { youngNursery: true as const } : {}),
...(nodeIslandOptionsKey(opts.nodeIslands) !== ""
? { nodeIslands: nodeIslandOptionsKey(opts.nodeIslands) }
: {}),
...(heapCensusRequested() ? { heapCensus: true as const } : {}),
npmStatic: opts.npmStatic ?? null,
...(shapeOverlayEnabled() ? { shapeOverlay: true as const } : {}),
@@ -10,6 +10,17 @@ import type { IrLibFn } from "./builtin-signatures.js";
export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
// The copied error of a synchronous call into Node (@scriptc/node).
"nodeIsland.callSync",
// Island module evaluation and member operations run JavaScript.
"nodeIsland.require",
"nodeIsland.get",
"nodeIsland.getKey",
"nodeIsland.set",
"nodeIsland.setKey",
"nodeIsland.invoke",
"nodeIsland.apply",
"nodeIsland.applyTo",
"nodeIsland.construct",
"nodeIsland.instanceof",
// Node's argument, cipher-name, IV, tag-length and key-length errors.
"crypto.cipherNew",
// Node's curve, key, point, encoding and format errors.
@@ -185,6 +185,23 @@ function buildLibFnSignatures() {
argTypes: [STRING, STRING, STRING, DYN],
result: { kind: "promise", inner: DYN },
},
/** Node islands (scope-B NI-4, scr_node_island_handle.c): island values
* are dyn values (`foreign` lowers as dyn), objects are handle boxes
* whose identity is the V8 object's. require/import load an island
* module by its absolute (or `$ORIGIN/`) path (also @scriptc/node
* requireModule); the rest are member reads,
* writes and calls on island values, argument lists as one dyn array. */
"nodeIsland.require": { argTypes: [STRING], result: DYN },
"nodeIsland.import": { argTypes: [STRING], result: { kind: "promise", inner: DYN } },
"nodeIsland.get": { argTypes: [DYN, STRING], result: DYN },
"nodeIsland.getKey": { argTypes: [DYN, DYN], result: DYN },
"nodeIsland.set": { argTypes: [DYN, STRING, DYN], result: VOID },
"nodeIsland.setKey": { argTypes: [DYN, DYN, DYN], result: VOID },
"nodeIsland.invoke": { argTypes: [DYN, STRING, DYN], result: DYN },
"nodeIsland.apply": { argTypes: [DYN, DYN], result: DYN },
"nodeIsland.applyTo": { argTypes: [DYN, DYN, DYN], result: DYN },
"nodeIsland.construct": { argTypes: [DYN, DYN], result: DYN },
"nodeIsland.instanceof": { argTypes: [DYN, DYN], result: BOOL },
/** Keyed WRITE on a dyn value — `h.onDone = cb` / `h["k"] = v` on a
* checked-dynamic object (args: receiver, key string, value — all
* borrowed; the runtime copies the key and retains the value in). An
+8
View File
@@ -618,6 +618,14 @@ export const CHILDWRITER_T: IrType = { kind: "childWriter" };
export const PROCSTREAM_T: IrType = { kind: "procStream" };
export const VOID: IrType = { kind: "void" };
export const DYN: IrType = { kind: "dyn" };
/** The `foreign` type (scope-B NI-4): a value owned by a Node island,
* typed by the island package's declarations. Objects stay in V8 and cross
* as handles (identity is the V8 object's); member operations compile from
* the declarations into `nodeIsland.*` bridge calls. NI-4 represents it
* exactly as `dyn` (a handle box with the island tag, primitives as plain
* dyn values), so foreign -> any is the identity and the generic dyn paths
* (equality, inspection, any slots) apply unchanged. */
export const FOREIGN: IrType = DYN;
export const JSVAL: IrType = { kind: "jsval" };
export const CAUGHT: IrType = { kind: "caught" };
export const UNDEFINED_T: IrType = { kind: "undefinedT" };
@@ -0,0 +1,157 @@
/* The Node island boundary (DESIGN.md §2.4–§2.5, scope-B NI-4): which
* values of a compiled program are island values, and what crosses by
* copy. Rule (decision 2): a value crosses by reference unless copying it
* is unobservable. Primitives and strings are copied; every object stays a
* handle whose identity is the V8 object's.
*
* Island values are typed by the island package's own declarations. A
* checker type is FOREIGN when it is an object type declared in an island
* package (its .d.ts, or its @types twin): a class, interface, type alias
* or type literal there, the type of an exported function or class value,
* or an island module's namespace. Values of foreign types are held as
* island handles (the `foreign` IR type, represented as a dyn value), and
* member operations on them compile from the declarations into bridge
* calls. A union is foreign when every arm that is not null or undefined
* is foreign (the dyn representation holds null and undefined). */
import * as ts from "../frontend/ts7/adapter.js";
import { packageDirOf } from "./origin.js";
/** One package the build runs in a Node island. */
export interface IslandPackage {
name: string;
version: string | null;
/** The package directory (…/node_modules/<name>). */
dir: string;
/** The island class (runtime, object-model, gap, packaging) and the first
* reason, for the SC8001 note. */
class: string;
reason: string;
}
/** The program's island packages, by package name. */
export interface NodeIslandSet {
packages: ReadonlyMap<string, IslandPackage>;
}
/** The island package that owns a declaration or JavaScript file, or null.
* `@types/<name>` declarations type the package `<name>`. */
export function islandPackageOfFile(islands: NodeIslandSet, file: string): IslandPackage | null {
const at = packageDirOf(file);
if (at === null) return null;
let name = at.name;
if (name.startsWith("@types/")) {
const bare = name.slice("@types/".length);
name = bare.includes("__") ? `@${bare.replace("__", "/")}` : bare;
}
return islands.packages.get(name) ?? null;
}
function declaredInIsland(islands: NodeIslandSet, symbol: ts.Symbol | null | undefined): boolean {
if (!symbol) return false;
const decls = symbol.declarations;
if (decls.length === 0) return false;
return decls.every(
(d) => islandPackageOfFile(islands, ts.getSourceFileOfNode(d).fileName) !== null,
);
}
const NULLISH = ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void;
/** Whether values of `type` are island handles (see the header). An
* anonymous object type every property of which the island declares (the
* namespace TypeScript synthesizes for `import()` of a CommonJS island
* module) is foreign too. */
export function isForeignType(
islands: NodeIslandSet,
type: ts.Type,
checker?: ts.TypeChecker,
): boolean {
if (type.isUnionType()) {
const arms = ts.constituentTypes(type).filter((t) => (t.flags & NULLISH) === 0);
return arms.length > 0 && arms.every((t) => isForeignType(islands, t, checker));
}
// An intersection the island declares (execa's `ChildProcess &
// ExecaChildPromise & Promise<…>`), or one with an island-declared arm,
// is made by island code: its values are island objects.
if ((type.flags & ts.TypeFlags.Intersection) !== 0) {
if (declaredInIsland(islands, type.getAliasSymbol())) return true;
return ts.constituentTypes(type).some((t) => isForeignType(islands, t, checker));
}
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
if (
declaredInIsland(islands, type.getAliasSymbol()) ||
declaredInIsland(islands, type.getSymbol())
)
return true;
if (checker === undefined || type.getAliasSymbol()) return false;
const symbol = type.getSymbol();
if (symbol && symbol.declarations.length > 0) return false;
// Synthetic members (the interop `default`) have no declarations.
let declared = 0;
for (const p of checker.getPropertiesOfType(type)) {
if (p.declarations.length === 0) continue;
if (!declaredInIsland(islands, p)) return false;
declared++;
}
return declared > 0;
}
/** How a value of a checker type crosses INTO an island as an argument or
* an assigned value: copied (primitives, strings, null/undefined), passed
* as its handle (foreign), passed as a dyn value the runtime converts
* (any/unknown: a handle crosses as its object, a primitive is copied, a
* fresh literal is copied), or not representable yet. */
export type BoundaryCrossing = "copy" | "handle" | "dyn" | "refused";
const COPIED =
ts.TypeFlags.NumberLike |
ts.TypeFlags.StringLike |
ts.TypeFlags.BooleanLike |
ts.TypeFlags.BigIntLike |
ts.TypeFlags.Null |
ts.TypeFlags.Undefined |
ts.TypeFlags.Void;
export function crossingOf(
islands: NodeIslandSet,
type: ts.Type,
checker?: ts.TypeChecker,
): BoundaryCrossing {
if (type.isUnionType()) {
const arms = ts.constituentTypes(type);
if (arms.every((t) => (t.flags & COPIED) !== 0)) return "copy";
if (isForeignType(islands, type, checker)) return "handle";
return "refused";
}
if ((type.flags & COPIED) !== 0) return "copy";
if ((type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return "dyn";
if (isForeignType(islands, type, checker)) return "handle";
return "refused";
}
/* The island set of the lowering in progress. lowerToIr sets it from its
* options for the duration of one synchronous lowering; every Lowerer it
* creates reads it at construction. Null in programs without islands, so
* their lowering is unchanged. */
let active: NodeIslandSet | null = null;
export function setActiveNodeIslands(islands: NodeIslandSet | null): void {
active = islands !== null && islands.packages.size > 0 ? islands : null;
}
export function activeNodeIslands(): NodeIslandSet | null {
return active;
}
/* The island package names of the frontend load in progress (runFrontend
* sets them for one synchronous load): preflight lets their imports
* through, typed by their declarations. */
let frontendIslands: ReadonlySet<string> = new Set();
export function setFrontendIslands(names: ReadonlySet<string>): void {
frontendIslands = names;
}
export function isFrontendIsland(pkg: string): boolean {
return frontendIslands.size > 0 && frontendIslands.has(pkg);
}
@@ -25,7 +25,7 @@ const MESSAGE_REASONS: { code: string; pattern: RegExp; reason: string }[] = [
pattern: /createRequire's require with this argument shape/,
reason: "dynamic-require",
},
{ code: "SC2020", pattern: /native addon/, reason: "native-addon" },
{ code: "SC2020", pattern: /^native addon /, reason: "native-addon" },
{
code: "SC2020",
pattern: /through require\.cache|require\.extensions|Module\._|module\.register/,
@@ -41,7 +41,7 @@ import {
export const NODE_ISLAND_MIN_NODE = "24.13.1";
/** The bridge ABI this compiler's runtime speaks (scr_node_bridge.h). */
export const NODE_BRIDGE_ABI = 4;
export const NODE_BRIDGE_ABI = 5;
/** The prefix of a recorded path relative to the executable's directory. */
export const ORIGIN = "$ORIGIN/";
@@ -30,6 +30,10 @@ describe("module specifiers in JavaScript", () => {
export * from "./f";
export { g } from "./g";
const h = await import("./h.js");
const require2 = createRequire(import.meta.url);
const j = require2("./j.cjs");
const k = require2.resolve("k");
const i = import.meta.resolve("i");
import.meta.url;
obj.require("x");
const t = \`\${require("./t")}\`;
@@ -44,6 +48,9 @@ describe("module specifiers in JavaScript", () => {
"./f",
"./g",
"./h.js",
"./j.cjs",
"k",
"i",
"./t",
]);
expect(scan.computed).toEqual([]);
@@ -67,10 +74,19 @@ describe("module specifiers in JavaScript", () => {
expect(forms("require(name)")).toEqual(["require() with a computed specifier"]);
expect(forms("require('./a' + b)")).toEqual(["require() with a computed specifier"]);
expect(forms("import(`./x/${y}`)")).toEqual(["import() with a computed specifier"]);
expect(forms("import.meta.resolve(name)")).toEqual([
"import.meta.resolve() with a computed specifier",
]);
expect(forms("const r = require; r('./a')")).toEqual(["require as a value"]);
expect(forms("createRequire(import.meta.url)('./a')")).toEqual([
"a require() from createRequire()",
]);
expect(forms("const req = createRequire(import.meta.url); req(name)")).toEqual([
"require() with a computed specifier",
]);
expect(forms("const req = createRequire(import.meta.url); use(req)")).toEqual([
"require as a value",
]);
expect(forms("function require(x) {} typeof require")).toEqual([]);
});
});
@@ -138,6 +154,36 @@ describe("the collected module tree", () => {
]);
});
test("packages a package loads without declaring them ship too", () => {
const root = project({
"main.ts": "",
"node_modules/bridge/package.json": JSON.stringify({
type: "module",
imports: { "#mapped": { node: "mapped", default: "./local.js" } },
}),
"node_modules/bridge/index.js":
'import { a } from "zoo";\nexport const b = () => import("zoo/extra.js");',
"node_modules/bridge/index.d.ts": 'import type { T } from "typesonly";',
"node_modules/zoo/package.json": "{}",
"node_modules/zoo/index.js": "exports.a = 1;",
"node_modules/zoo/extra.js": "",
"node_modules/typesonly/index.d.ts": "",
"node_modules/mapped/index.js": "",
});
const collector = new NodeIslandModuleCollector();
expect(collector.addLoaded(join(root, "node_modules/bridge/index.js"))).toBeNull();
// The root stays above node_modules, where Node looks for packages.
expect(collector.tree()!.files.map((f) => f.path)).toEqual([
"node_modules/bridge/index.d.ts",
"node_modules/bridge/index.js",
"node_modules/bridge/package.json",
"node_modules/mapped/index.js",
"node_modules/zoo/extra.js",
"node_modules/zoo/index.js",
"node_modules/zoo/package.json",
]);
});
test("pnpm's links between package directories stay links inside the tree", () => {
const root = project({
"app/main.ts": "",
+111 -17
View File
@@ -5,18 +5,25 @@
* Each `call`/`callSync` names its module with a string literal. The build
* resolves it from the calling module as Node's require() does:
* - a relative or absolute specifier names a module of the program: the
* module and every module its string-literal `require()`, `import` and
* `export ... from` reach are collected; a computed specifier in such a
* module is refused, since the build cannot know what it loads;
* module and every module its string-literal `require()` (also through a
* `createRequire()` function), `import` and `export ... from` reach are
* collected; a computed specifier in such a module is refused, since the
* build cannot know what it loads;
* - a package specifier names a package: its whole directory is collected
* (computed requires inside packages keep working), and so are its
* `dependencies`, `optionalDependencies` and `peerDependencies`, found as
* Node finds them;
* `dependencies`, `optionalDependencies` and `peerDependencies`, the
* packages its "imports" map to and the packages its JavaScript names
* with string literals, all found as Node finds them;
* - a Node built-in module needs nothing, and neither does a module the
* build cannot find: loading it throws MODULE_NOT_FOUND when the call
* runs, as under Node.
* A load of a resolved file (`requireModule` and the compiler's own Node
* islands, lower-node-island.ts) collects the package that holds the file
* the same way, or, for a module of the program, the module and what it
* loads; the load then names the file by its path in the tree.
* Paths keep their layout relative to one root directory (the deepest
* directory that holds every collected path), with symbolic links between
* directory that holds every collected path, above any node_modules
* directory, where Node looks for packages), with symbolic links between
* package directories (pnpm's layout) kept as links. Only these relative
* paths enter the executable. Every read goes through the frontend input
* tracker, so an edit to an island module invalidates cached builds. */
@@ -33,6 +40,7 @@ import {
trackedRealpath,
} from "../frontend/input-tracker.js";
import type { IrNodeIslandModules } from "../ir/ir.js";
import { packageDirOf } from "./origin.js";
export type NodeIslandModuleTree = IrNodeIslandModules;
type NodeIslandModuleFile = IrNodeIslandModules["files"][number];
@@ -46,6 +54,9 @@ const FILE_EXTENSIONS = [".js", ".json", ".node"];
/** Module files whose imports are traced. */
const TRACED = /\.(?:[cm]?js|[cm]?ts)$/;
/** A package's JavaScript files, whose imports of other packages are followed. */
const SCRIPT = /\.[cm]?js$/;
function isBuiltin(specifier: string): boolean {
return specifier.startsWith("node:") || BUILTINS.has(specifier);
}
@@ -138,6 +149,16 @@ function commonDirectory(paths: readonly string[]): string {
return joined === "" ? sep : joined;
}
/** The string targets of a package.json "imports" map, through its
* conditions and fallback arrays. */
function importTargets(value: unknown): string[] {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.flatMap(importTargets);
if (value !== null && typeof value === "object")
return Object.values(value).flatMap(importTargets);
return [];
}
function portable(path: string): string {
return sep === "/" ? path : path.split(sep).join("/");
}
@@ -151,6 +172,8 @@ export class NodeIslandModuleCollector {
private readonly traced = new Set<string>();
/** Real paths of the calling modules. */
private readonly callers = new Set<string>();
/** Real paths of the files loads name (addLoaded). */
private readonly loaded = new Set<string>();
/** Collects the module `specifier` names from `caller` (a real path).
* Returns null, or the reason the build cannot ship it. */
@@ -159,6 +182,19 @@ export class NodeIslandModuleCollector {
return this.resolveFrom(caller, specifier);
}
/** Collects the module a load names by its resolved path (a real path):
* the package that holds it, or a module of the program with what it
* loads. Returns null, or the reason the build cannot ship it. */
addLoaded(file: string): string | null {
this.loaded.add(file);
const at = packageDirOf(file);
if (at !== null) {
this.addPackage(at.dir);
return null;
}
return this.addModule(this.placed(file));
}
/** The collected tree, or null when no call loads a file. */
tree(): NodeIslandModuleTree | null {
if (this.files.size === 0 && this.links.size === 0) return null;
@@ -167,8 +203,14 @@ export class NodeIslandModuleCollector {
...this.links.keys(),
...this.links.values(),
...[...this.callers].map((caller) => dirname(caller)),
...[...this.loaded].map((file) => dirname(file)),
];
const root = commonDirectory(anchors);
// Node finds a package through the node_modules directory that holds
// it, so a root inside one moves above it.
let root = commonDirectory(anchors);
const parts = root.split(sep);
const outer = parts.indexOf("node_modules");
if (outer >= 0) root = parts.slice(0, outer).join(sep) || sep;
const files: NodeIslandModuleFile[] = [];
for (const file of [...this.files].sort()) {
const bytes = trackedReadBytes(file);
@@ -193,6 +235,12 @@ export class NodeIslandModuleCollector {
return tree === null ? basename(caller) : portable(relative(tree.root, caller));
}
/** The name a load gives a collected file: `./` and its path relative to
* the tree root (the runtime resolves it under the root). */
static loadPath(tree: NodeIslandModuleTree | null, file: string): string {
return tree === null ? file : `./${portable(relative(tree.root, file))}`;
}
private resolveFrom(fromFile: string, specifier: string): string | null {
if (isBuiltin(specifier)) return null;
if (specifier.startsWith("#"))
@@ -270,7 +318,21 @@ export class NodeIslandModuleCollector {
const dir = this.placed(found);
if (this.packages.has(dir)) return;
this.packages.add(dir);
this.addDirectory(dir, new Set());
const scripts: string[] = [];
this.addDirectory(dir, new Set(), scripts);
// A package may load another it does not declare (one installed beside
// it): Node finds that one by walking up node_modules, and so does the
// build, from each string-literal specifier of the package's JavaScript.
for (const file of scripts) {
const text = trackedReadFile(file);
if (text === null) continue;
for (const specifier of scanModuleSpecifiers(text).specifiers) {
if (isBuiltin(specifier) || isPathSpecifier(specifier)) continue;
if (specifier.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(specifier)) continue;
const other = packageDirectory(dirname(file), packageName(specifier));
if (other !== null) this.addPackage(other);
}
}
const manifest = trackedReadFile(join(dir, "package.json"));
if (manifest === null) return;
let parsed: Record<string, unknown>;
@@ -285,6 +347,9 @@ export class NodeIslandModuleCollector {
if (deps !== null && typeof deps === "object")
for (const name of Object.keys(deps)) names.add(name);
}
// A package import ("imports") may map to another package.
for (const target of importTargets(parsed["imports"]))
if (!isPathSpecifier(target) && !isBuiltin(target)) names.add(packageName(target));
for (const name of [...names].sort()) {
// A dependency that is not installed (optional, or a peer the program
// does not use) is not loaded either.
@@ -294,17 +359,21 @@ export class NodeIslandModuleCollector {
}
/** Every file below `dir`, except nested node_modules (dependencies come
* through package.json) and .git. */
private addDirectory(dir: string, visited: Set<string>): void {
* through package.json and the package's imports) and .git; the
* JavaScript files among them also go to `scripts`. */
private addDirectory(dir: string, visited: Set<string>, scripts: string[]): void {
const real = trackedRealpath(dir) ?? dir;
if (visited.has(real)) return;
visited.add(real);
const entries = trackedAccessibleEntries(dir);
if (entries === null) return;
for (const file of entries.files) this.files.add(join(dir, file));
for (const file of entries.files) {
this.files.add(join(dir, file));
if (SCRIPT.test(file)) scripts.push(join(dir, file));
}
for (const sub of entries.directories) {
if (sub === "node_modules" || sub === ".git") continue;
this.addDirectory(join(dir, sub), visited);
this.addDirectory(join(dir, sub), visited, scripts);
}
}
}
@@ -490,8 +559,8 @@ export function moduleTokens(text: string): Token[] {
}
export interface ModuleSpecifierScan {
/** String-literal specifiers of require(), require.resolve(), import()
* and import/export declarations, in source order. */
/** String-literal specifiers of require(), require.resolve(), import(),
* import.meta.resolve() and import/export declarations, in source order. */
specifiers: string[];
/** Loads whose module the source does not name with a string literal. */
computed: { at: number; form: string }[];
@@ -513,11 +582,27 @@ export function scanModuleSpecifiers(text: string): ModuleSpecifierScan {
(is(k + 2, "punct", ")") || is(k + 2, "punct", ","))
? at(k + 1)!.value
: null;
// `const r = createRequire(...)` declares a require function of the
// module: calls of `r` load modules as require() calls do.
const aliases = new Set<string>();
const declarations = new Set<number>();
for (let k = 0; k + 4 < tokens.length; k++) {
if (
(is(k, "name", "const") || is(k, "name", "let") || is(k, "name", "var")) &&
is(k + 1, "name") &&
is(k + 2, "punct", "=") &&
is(k + 3, "name", "createRequire") &&
is(k + 4, "punct", "(")
) {
aliases.add(at(k + 1)!.value);
declarations.add(k + 1).add(k + 3);
}
}
for (let k = 0; k < tokens.length; k++) {
const t = tokens[k]!;
if (t.kind !== "name") continue;
if (t.kind !== "name" || declarations.has(k)) continue;
const member = is(k - 1, "punct", ".") || is(k - 1, "punct", "?.");
if (t.value === "require" && !member && !is(k + 1, "punct", ":")) {
if ((t.value === "require" || aliases.has(t.value)) && !member && !is(k + 1, "punct", ":")) {
if (is(k - 1, "name", "function") || is(k - 1, "name", "typeof")) continue;
let call = k + 1;
let form = "require()";
@@ -541,7 +626,16 @@ export function scanModuleSpecifiers(text: string): ModuleSpecifierScan {
continue;
}
if (t.value === "import" && !member) {
if (is(k + 1, "punct", ".")) continue; // import.meta
if (is(k + 1, "punct", ".")) {
// import.meta.resolve() names a module, as require.resolve() does.
if (is(k + 2, "name", "meta") && is(k + 3, "punct", ".") && is(k + 4, "name", "resolve")) {
const literal = literalCall(k + 5);
if (literal !== null) specifiers.push(literal);
else if (is(k + 5, "punct", "("))
computed.push({ at: t.at, form: "import.meta.resolve() with a computed specifier" });
}
continue;
}
if (is(k + 1, "punct", "(")) {
const literal = literalCall(k + 1);
if (literal === null)
@@ -0,0 +1,16 @@
/** `require.resolve(spec)` from `fromFile` for island code (scope-B NI-4).
* The Node-hosted compiler installs the resolver (index.ts, Node's own
* createRequire); the self-hosted compiler builds no islands and never
* installs one. Throws when the specifier does not resolve. */
type IslandResolver = (fromFile: string, spec: string) => string;
let islandResolver: IslandResolver | null = null;
export function setIslandResolver(resolver: IslandResolver): void {
islandResolver = resolver;
}
export function resolveFrom(fromFile: string, spec: string): string {
if (islandResolver === null) throw new Error(`no module resolver for '${spec}'`);
return islandResolver(fromFile, spec);
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6812-node-island-handles/main.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6813-node-island-handle-errors/main.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6814-node-island-handle-bytes/main.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6815-node-island-handle-promises/main.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6816-node-island-objects/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6817-node-island-esm/main.ts"
],
"diags": []
}
@@ -0,0 +1,18 @@
{
"order": [
"<repo>/tests/corpus/6839-node-island-path-mapped/main.ts"
],
"diags": [
{
"code": "SC1010",
"message": "the 'pathkit' import (it resolves only to type declarations — there is no runtime implementation to compile) is not supported yet",
"loc": {
"file": "<repo>/tests/corpus/6839-node-island-path-mapped/main.ts",
"start": 234,
"end": 294
},
"milestone": "M4",
"hint": "relative imports (./file, ../dir/file), package.json-mediated project imports (#alias via the imports field, self-name references via exports), statically compiled npm packages, and the built-in fs, fs/promises, path, os, url, crypto, zlib, child_process, net, http, tls, https, http2, dgram, dns, dns/promises, util, util/types, string_decoder, querystring, readline, events, stream, stream/promises, stream/consumers, stream/web, buffer, assert, assert/strict, worker_threads, cluster, tty, async_hooks, timers, timers/promises, diagnostics_channel, perf_hooks, module, v8, and console modules (bare or node:-prefixed) and node:test (node:-prefixed only, like in Node) are supported"
}
]
}
@@ -24,6 +24,7 @@
#include <dlfcn.h>
#include <node.h>
#include <node_buffer.h>
#include <node_api.h>
#include <node_version.h>
#include <uv.h>
@@ -32,6 +33,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <memory>
#include <string>
#include <unordered_map>
@@ -55,6 +57,7 @@ using v8::TryCatch;
using v8::Value;
const ScrNodeBridgeHost *g_host = nullptr;
void DropAllHandles();
std::unique_ptr<node::MultiIsolatePlatform> g_platform;
node::EmbedderSnapshotData::Pointer g_snapshot;
std::unique_ptr<node::CommonEnvironmentSetup> g_setup;
@@ -64,6 +67,7 @@ Global<Function> g_call;
Global<Function> g_set_timer, g_clear_timer, g_ref_timer, g_refresh_timer;
Global<Function> g_set_immediate, g_clear_immediate, g_ref_immediate;
Global<Function> g_next_tick, g_emit;
Global<Function> g_load; // the bootstrap's module loader (NI-4)
char *Copy(const std::string &text, size_t *len) {
char *out = static_cast<char *>(std::malloc(text.size() + 1));
@@ -282,7 +286,8 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar
!TakeFunction(isolate, context, d, "clearImmediate", &g_clear_immediate) ||
!TakeFunction(isolate, context, d, "refImmediate", &g_ref_immediate) ||
!TakeFunction(isolate, context, d, "nextTick", &g_next_tick) ||
!TakeFunction(isolate, context, d, "emit", &g_emit))
!TakeFunction(isolate, context, d, "emit", &g_emit) ||
!TakeFunction(isolate, context, d, "load", &g_load))
return Copy("the Node island bootstrap left an incomplete dispatcher", nullptr);
global->Delete(context, Utf8(isolate, "__scriptcNodeIsland")).FromMaybe(false);
global->Delete(context, Utf8(isolate, "__scriptcNodeIslandHost")).FromMaybe(false);
@@ -614,6 +619,8 @@ void Stop() {
Isolate *isolate = g_setup->isolate();
node::Stop(g_setup->env());
g_call.Reset();
g_load.Reset();
DropAllHandles();
for (Global<Function> *fn :
{&g_set_timer, &g_clear_timer, &g_ref_timer, &g_refresh_timer, &g_set_immediate,
&g_clear_immediate, &g_ref_immediate, &g_next_tick, &g_emit})
@@ -627,6 +634,594 @@ void Stop() {
node::TearDownOncePerProcess();
}
// ── object handles (scope-B NI-4) ───────────────────────────────────────
// The import table: V8 values the runtime holds, one v8::Global per value
// and one id per value while held (found again through the value's
// identity hash), so a V8 object crossing twice is the same runtime
// handle. A typed array's slot also keeps its backing store, so memory the
// runtime aliases stays valid even if JavaScript detaches the buffer.
struct Slot {
Global<Value> value;
std::shared_ptr<v8::BackingStore> store;
int hash = 0;
bool live = false;
};
std::vector<Slot> g_slots(1); // id 0 is never used
std::vector<uint32_t> g_free_ids;
std::unordered_multimap<int, uint32_t> g_ids;
uint32_t g_live_handles = 0;
// Strings of out-values: valid until the next outermost entry.
std::deque<std::string> g_scratch;
int g_entry_depth = 0;
int IdentityHash(Local<Value> v) {
if (v->IsObject()) return v.As<Object>()->GetIdentityHash();
if (v->IsSymbol()) return v.As<v8::Name>()->GetIdentityHash();
return 0;
}
uint32_t Import(Isolate *isolate, Local<Value> v, std::shared_ptr<v8::BackingStore> store = nullptr) {
int hash = IdentityHash(v);
auto range = g_ids.equal_range(hash);
for (auto it = range.first; it != range.second; ++it) {
Slot &slot = g_slots[it->second];
if (slot.value.Get(isolate)->StrictEquals(v)) return it->second;
}
uint32_t id;
if (!g_free_ids.empty()) {
id = g_free_ids.back();
g_free_ids.pop_back();
} else {
id = static_cast<uint32_t>(g_slots.size());
g_slots.emplace_back();
}
Slot &slot = g_slots[id];
slot.value.Reset(isolate, v);
slot.store = std::move(store);
slot.hash = hash;
slot.live = true;
g_ids.emplace(hash, id);
g_live_handles++;
return id;
}
Local<Value> HandleValue(Isolate *isolate, uint32_t id) {
if (id == 0 || id >= g_slots.size() || !g_slots[id].live) return v8::Undefined(isolate);
return g_slots[id].value.Get(isolate);
}
void Drop(uint32_t id) {
if (id == 0 || id >= g_slots.size() || !g_slots[id].live) return;
Slot &slot = g_slots[id];
auto range = g_ids.equal_range(slot.hash);
for (auto it = range.first; it != range.second; ++it) {
if (it->second == id) {
g_ids.erase(it);
break;
}
}
slot.value.Reset();
slot.store.reset();
slot.live = false;
g_free_ids.push_back(id);
g_live_handles--;
}
void DropAllHandles() {
for (uint32_t id = 1; id < g_slots.size(); id++) Drop(id);
}
uint32_t LiveHandles() { return g_live_handles; }
const char *Scratch(std::string text, size_t *len) {
g_scratch.push_back(std::move(text));
*len = g_scratch.back().size();
return g_scratch.back().data();
}
// WTF-8: V8's UTF-8 writer encodes a lone surrogate as its 3-byte form.
std::string Wtf8(Isolate *isolate, Local<String> s) {
size_t n = s->Utf8LengthV2(isolate);
std::string out(n, '\0');
if (n > 0) s->WriteUtf8V2(isolate, out.data(), n);
return out;
}
bool HasLoneSurrogate(const char *p, size_t n) {
for (size_t i = 0; i + 1 < n; i++)
if (static_cast<unsigned char>(p[i]) == 0xED && static_cast<unsigned char>(p[i + 1]) >= 0xA0)
return true;
return false;
}
// A runtime string in V8: NewFromUtf8 would replace lone surrogates, so a
// string that holds one decodes WTF-8 to UTF-16 here.
Local<String> FromWtf8(Isolate *isolate, const char *p, size_t n) {
if (!HasLoneSurrogate(p, n)) return Utf8(isolate, p, n);
std::vector<uint16_t> units;
units.reserve(n);
for (size_t i = 0; i < n;) {
unsigned char c = static_cast<unsigned char>(p[i]);
uint32_t cp;
size_t k;
if (c < 0x80) { cp = c; k = 1; }
else if ((c & 0xE0) == 0xC0 && i + 1 < n) { cp = ((c & 0x1F) << 6) | (p[i + 1] & 0x3F); k = 2; }
else if ((c & 0xF0) == 0xE0 && i + 2 < n) {
cp = ((c & 0x0F) << 12) | ((p[i + 1] & 0x3F) << 6) | (p[i + 2] & 0x3F); k = 3;
} else if ((c & 0xF8) == 0xF0 && i + 3 < n) {
cp = ((c & 0x07) << 18) | ((p[i + 1] & 0x3F) << 12) | ((p[i + 2] & 0x3F) << 6) | (p[i + 3] & 0x3F);
k = 4;
} else { cp = 0xFFFD; k = 1; }
if (cp >= 0x10000) {
cp -= 0x10000;
units.push_back(static_cast<uint16_t>(0xD800 + (cp >> 10)));
units.push_back(static_cast<uint16_t>(0xDC00 + (cp & 0x3FF)));
} else {
units.push_back(static_cast<uint16_t>(cp));
}
i += k;
}
return String::NewFromTwoByte(isolate, units.data(), NewStringType::kNormal,
static_cast<int>(units.size()))
.ToLocalChecked();
}
// A decimal BigInt text in V8, through 64-bit words.
Local<Value> BigIntFromText(Local<Context> context, const char *p, size_t n) {
int sign = 0;
size_t i = 0;
if (i < n && p[i] == '-') { sign = 1; i++; }
std::vector<uint64_t> words(1, 0);
for (; i < n; i++) {
unsigned __int128 carry = static_cast<unsigned>(p[i] - '0');
for (uint64_t &w : words) {
unsigned __int128 v = static_cast<unsigned __int128>(w) * 10 + carry;
w = static_cast<uint64_t>(v);
carry = v >> 64;
}
if (carry != 0) words.push_back(static_cast<uint64_t>(carry));
}
return v8::BigInt::NewFromWords(context, sign, static_cast<int>(words.size()), words.data())
.ToLocalChecked();
}
uint32_t ElemOf(Local<v8::ArrayBufferView> view) {
// ScrBytesElem numbering (scr_bytes.h).
if (view->IsUint8Array()) return 0;
if (view->IsUint32Array()) return 1;
if (view->IsFloat32Array()) return 2;
if (view->IsInt32Array()) return 3;
if (view->IsFloat64Array()) return 4;
if (view->IsInt8Array()) return 5;
if (view->IsUint16Array()) return 6;
if (view->IsInt16Array()) return 7;
if (view->IsUint8ClampedArray()) return 8;
if (view->IsFloat16Array()) return 9;
return 0; // DataView: bytes
}
size_t ElemSize(uint32_t elem) {
static const size_t sizes[] = {1, 4, 4, 4, 8, 1, 2, 2, 1, 2};
return elem < 10 ? sizes[elem] : 1;
}
void FromV8(Isolate *isolate, Local<Context> context, Local<Value> v, ScrNodeValue *out) {
std::memset(out, 0, sizeof *out);
if (v.IsEmpty() || v->IsUndefined()) { out->tag = SCR_NV_UNDEFINED; return; }
if (v->IsNull()) { out->tag = SCR_NV_NULL; return; }
if (v->IsBoolean()) { out->tag = SCR_NV_BOOL; out->u.boolean = v->IsTrue() ? 1 : 0; return; }
if (v->IsNumber()) { out->tag = SCR_NV_NUMBER; out->u.num = v.As<v8::Number>()->Value(); return; }
if (v->IsString()) {
out->tag = SCR_NV_STRING;
out->u.str.data = Scratch(Wtf8(isolate, v.As<String>()), &out->u.str.len);
return;
}
if (v->IsBigInt()) {
out->tag = SCR_NV_BIGINT;
Local<String> text = v->ToString(context).ToLocalChecked();
out->u.str.data = Scratch(Wtf8(isolate, text), &out->u.str.len);
return;
}
if (v->IsArrayBufferView()) {
Local<v8::ArrayBufferView> view = v.As<v8::ArrayBufferView>();
bool dataview = view->IsDataView();
// Buffer() moves on-heap elements off the heap: the data stays put.
Local<v8::ArrayBuffer> buffer = view->Buffer();
std::shared_ptr<v8::BackingStore> store = buffer->GetBackingStore();
if (!store->IsShared() && !store->IsResizableByUserJavaScript()) {
uint32_t elem = dataview ? 0 : ElemOf(view);
out->tag = SCR_NV_BYTES;
out->u.bytes.elem = elem;
out->u.bytes.data = static_cast<uint8_t *>(store->Data()) + view->ByteOffset();
out->u.bytes.len = view->ByteLength() / ElemSize(elem);
if (node::Buffer::HasInstance(v)) out->flags |= SCR_NVF_BUFFER;
if (dataview) out->flags |= SCR_NVF_DATAVIEW;
out->u.bytes.handle = Import(isolate, v, store);
return;
}
}
out->tag = SCR_NV_HANDLE;
uint32_t flags = 0;
if (v->IsSymbol()) flags |= SCR_NVF_SYMBOL;
if (v->IsFunction()) {
flags |= SCR_NVF_FUNCTION;
if (v.As<Function>()->IsConstructor()) flags |= SCR_NVF_CONSTRUCTOR;
}
if (v->IsNativeError()) flags |= SCR_NVF_ERROR;
if (v->IsArray()) flags |= SCR_NVF_ARRAY;
if (v->IsPromise()) flags |= SCR_NVF_PROMISE;
out->flags = flags;
out->u.handle = Import(isolate, v);
}
void FreeRuntimeBytes(void *, size_t, void *owner) {
if (owner != nullptr) g_host->bytes_free(owner);
}
Local<Value> ErrorConstructor(Isolate *isolate, Local<Context> context, uint32_t kind) {
static const char *const names[] = {"Error", "TypeError", "RangeError", "SyntaxError",
"Error", "ReferenceError", "EvalError",
"URIError"};
const char *name = kind < 8 ? names[kind] : "Error";
return context->Global()->Get(context, Utf8(isolate, name)).ToLocalChecked();
}
Local<Value> ToV8(Isolate *isolate, Local<Context> context, const ScrNodeValue *in) {
if (in == nullptr) return v8::Undefined(isolate);
switch (in->tag) {
case SCR_NV_NULL: return v8::Null(isolate);
case SCR_NV_BOOL: return v8::Boolean::New(isolate, in->u.boolean != 0);
case SCR_NV_NUMBER: return v8::Number::New(isolate, in->u.num);
case SCR_NV_STRING: return FromWtf8(isolate, in->u.str.data, in->u.str.len);
case SCR_NV_BIGINT: return BigIntFromText(context, in->u.str.data, in->u.str.len);
case SCR_NV_HANDLE: return HandleValue(isolate, in->u.handle);
case SCR_NV_BYTES: {
if (in->u.bytes.handle != 0) return HandleValue(isolate, in->u.bytes.handle);
size_t elem_size = ElemSize(in->u.bytes.elem);
size_t byte_len = in->u.bytes.len * elem_size;
Local<v8::ArrayBuffer> buffer;
if (byte_len == 0 || in->u.bytes.data == nullptr) {
buffer = v8::ArrayBuffer::New(isolate, 0);
if (in->u.bytes.owner != nullptr) g_host->bytes_free(in->u.bytes.owner);
} else {
std::unique_ptr<v8::BackingStore> store = v8::ArrayBuffer::NewBackingStore(
in->u.bytes.data, byte_len, FreeRuntimeBytes, in->u.bytes.owner);
buffer = v8::ArrayBuffer::New(isolate, std::move(store));
}
size_t n = in->u.bytes.len;
if (in->flags & SCR_NVF_DATAVIEW) return v8::DataView::New(buffer, 0, byte_len);
if (in->flags & SCR_NVF_BUFFER)
return node::Buffer::New(isolate, buffer, 0, byte_len).ToLocalChecked();
switch (in->u.bytes.elem) {
case 1: return v8::Uint32Array::New(buffer, 0, n);
case 2: return v8::Float32Array::New(buffer, 0, n);
case 3: return v8::Int32Array::New(buffer, 0, n);
case 4: return v8::Float64Array::New(buffer, 0, n);
case 5: return v8::Int8Array::New(buffer, 0, n);
case 6: return v8::Uint16Array::New(buffer, 0, n);
case 7: return v8::Int16Array::New(buffer, 0, n);
case 8: return v8::Uint8ClampedArray::New(buffer, 0, n);
case 9: return v8::Float16Array::New(buffer, 0, n);
default: return v8::Uint8Array::New(buffer, 0, n);
}
}
case SCR_NV_ERROR: {
Local<Value> ctor = ErrorConstructor(isolate, context, in->u.error.kind);
Local<Value> argv[] = {FromWtf8(isolate, in->u.error.message, in->u.error.message_len)};
Local<Object> error = ctor.As<Function>()->NewInstance(context, 1, argv).ToLocalChecked();
std::string name(in->u.error.name, in->u.error.name_len);
Local<Value> default_name;
if (!error->Get(context, Utf8(isolate, "name")).ToLocal(&default_name) ||
ToStdString(isolate, default_name) != name)
error->Set(context, Utf8(isolate, "name"), FromWtf8(isolate, name.data(), name.size())).Check();
if (in->u.error.stack != nullptr)
error->Set(context, Utf8(isolate, "stack"),
FromWtf8(isolate, in->u.error.stack, in->u.error.stack_len)).Check();
if (in->u.error.code != nullptr)
error->Set(context, Utf8(isolate, "code"),
FromWtf8(isolate, in->u.error.code, in->u.error.code_len)).Check();
return error;
}
case SCR_NV_JSON: {
Local<Value> parsed;
if (v8::JSON::Parse(context, FromWtf8(isolate, in->u.str.data, in->u.str.len)).ToLocal(&parsed))
return parsed;
return v8::Undefined(isolate);
}
default: return v8::Undefined(isolate);
}
}
// One entry from the runtime: the isolate scope, V8's stack limit for the
// caller's stack, and the out-value scratch (reset by outermost entries).
struct Op {
Enter e;
TryCatch try_catch;
Op() : try_catch(e.isolate) {
if (g_entry_depth++ == 0) g_scratch.clear();
}
~Op() { g_entry_depth--; }
// The operation's outcome: 0 and the result, or 1 and the thrown value.
int Finish(Local<Value> result, bool ok, ScrNodeValue *out) {
if (ok) {
FromV8(e.isolate, e.context, result, out);
return 0;
}
if (try_catch.HasCaught() && !try_catch.HasTerminated()) {
Local<Value> exception = try_catch.Exception();
try_catch.Reset();
FromV8(e.isolate, e.context, exception, out);
} else {
std::memset(out, 0, sizeof *out);
out->tag = SCR_NV_UNDEFINED;
}
return 1;
}
Local<Value> V(const ScrNodeValue *in) { return ToV8(e.isolate, e.context, in); }
};
int Load(const char *path, int esm, ScrNodeValue *out) {
Op op;
Local<Value> argv[] = {Utf8(op.e.isolate, path), v8::Boolean::New(op.e.isolate, esm != 0)};
Local<Value> result;
bool ok = g_load.Get(op.e.isolate)
->Call(op.e.context, v8::Undefined(op.e.isolate), 2, argv)
.ToLocal(&result);
return op.Finish(result, ok, out);
}
// The target of a property operation: an object, or a primitive whose
// wrapper answers (ToObject throws for undefined and null).
bool Target(Op &op, uint32_t h, Local<Object> *target) {
return HandleValue(op.e.isolate, h)->ToObject(op.e.context).ToLocal(target);
}
bool Key(Op &op, const ScrNodeValue *key, Local<Value> *out) {
Local<Value> k = op.V(key);
if (k->IsString() || k->IsSymbol() || k->IsNumber()) {
*out = k;
return true;
}
Local<String> name;
if (!k->ToString(op.e.context).ToLocal(&name)) return false;
*out = name;
return true;
}
int Get(uint32_t h, const ScrNodeValue *key, ScrNodeValue *out) {
Op op;
Local<Object> target;
Local<Value> k, result;
bool ok = Target(op, h, &target) && Key(op, key, &k) && target->Get(op.e.context, k).ToLocal(&result);
return op.Finish(result, ok, out);
}
int SetProp(uint32_t h, const ScrNodeValue *key, const ScrNodeValue *value, ScrNodeValue *out) {
Op op;
Local<Object> target;
Local<Value> k;
bool ok = Target(op, h, &target) && Key(op, key, &k);
if (ok) {
v8::Maybe<bool> done = target->Set(op.e.context, k, op.V(value));
ok = done.IsJust();
// Strict-mode semantics: a refused assignment throws, as in a module.
if (ok && !done.FromJust()) {
op.e.isolate->ThrowException(v8::Exception::TypeError(
Utf8(op.e.isolate, "Cannot assign to read only property")));
ok = false;
}
}
return op.Finish(v8::Undefined(op.e.isolate), ok, out);
}
int Has(uint32_t h, const ScrNodeValue *key, ScrNodeValue *out) {
Op op;
Local<Value> target = HandleValue(op.e.isolate, h);
if (!target->IsObject()) {
op.e.isolate->ThrowException(v8::Exception::TypeError(
Utf8(op.e.isolate, "Cannot use 'in' operator to search for a key in a primitive")));
return op.Finish(Local<Value>(), false, out);
}
Local<Value> k;
bool ok = Key(op, key, &k);
v8::Maybe<bool> found = ok ? target.As<Object>()->Has(op.e.context, k) : v8::Nothing<bool>();
ok = found.IsJust();
return op.Finish(v8::Boolean::New(op.e.isolate, ok && found.FromJust()), ok, out);
}
int Del(uint32_t h, const ScrNodeValue *key, ScrNodeValue *out) {
Op op;
Local<Object> target;
Local<Value> k;
bool ok = Target(op, h, &target) && Key(op, key, &k);
v8::Maybe<bool> done = ok ? target->Delete(op.e.context, k) : v8::Nothing<bool>();
ok = done.IsJust();
if (ok && !done.FromJust()) {
op.e.isolate->ThrowException(v8::Exception::TypeError(
Utf8(op.e.isolate, "Cannot delete property")));
ok = false;
}
return op.Finish(v8::Boolean::New(op.e.isolate, ok), ok, out);
}
std::vector<Local<Value>> Args(Op &op, const ScrNodeValue *args, size_t argc) {
std::vector<Local<Value>> argv;
argv.reserve(argc);
for (size_t i = 0; i < argc; i++) argv.push_back(op.V(&args[i]));
return argv;
}
void NotAFunction(Op &op, const char *what) {
op.e.isolate->ThrowException(v8::Exception::TypeError(
Utf8(op.e.isolate, (std::string(what) + " is not a function").c_str())));
}
int Invoke(uint32_t fn, const ScrNodeValue *this_, const ScrNodeValue *args, size_t argc,
ScrNodeValue *out) {
Op op;
Local<Value> callee = HandleValue(op.e.isolate, fn);
if (!callee->IsFunction()) {
NotAFunction(op, "value");
return op.Finish(Local<Value>(), false, out);
}
std::vector<Local<Value>> argv = Args(op, args, argc);
Local<Value> result;
bool ok = callee.As<Function>()
->Call(op.e.context, op.V(this_), static_cast<int>(argv.size()), argv.data())
.ToLocal(&result);
return op.Finish(result, ok, out);
}
int InvokeMethod(uint32_t h, const ScrNodeValue *key, const ScrNodeValue *args, size_t argc,
ScrNodeValue *out) {
Op op;
Local<Value> receiver = HandleValue(op.e.isolate, h);
Local<Object> target;
Local<Value> k, callee;
if (!receiver->ToObject(op.e.context).ToLocal(&target) || !Key(op, key, &k) ||
!target->Get(op.e.context, k).ToLocal(&callee))
return op.Finish(Local<Value>(), false, out);
if (!callee->IsFunction()) {
std::string what = "property";
if (k->IsString()) what = ToStdString(op.e.isolate, k);
NotAFunction(op, what.c_str());
return op.Finish(Local<Value>(), false, out);
}
std::vector<Local<Value>> argv = Args(op, args, argc);
Local<Value> result;
bool ok = callee.As<Function>()
->Call(op.e.context, receiver, static_cast<int>(argv.size()), argv.data())
.ToLocal(&result);
return op.Finish(result, ok, out);
}
int Construct(uint32_t h, const ScrNodeValue *args, size_t argc, ScrNodeValue *out) {
Op op;
Local<Value> callee = HandleValue(op.e.isolate, h);
if (!callee->IsFunction() || !callee.As<Function>()->IsConstructor()) {
op.e.isolate->ThrowException(v8::Exception::TypeError(
Utf8(op.e.isolate, "value is not a constructor")));
return op.Finish(Local<Value>(), false, out);
}
std::vector<Local<Value>> argv = Args(op, args, argc);
Local<Object> result;
bool ok = callee.As<Function>()
->NewInstance(op.e.context, static_cast<int>(argv.size()), argv.data())
.ToLocal(&result);
return op.Finish(result, ok, out);
}
int InstanceOf(const ScrNodeValue *value, uint32_t ctor, ScrNodeValue *out) {
Op op;
Local<Value> v = op.V(value);
Local<Value> c = HandleValue(op.e.isolate, ctor);
if (!c->IsObject()) {
op.e.isolate->ThrowException(v8::Exception::TypeError(
Utf8(op.e.isolate, "Right-hand side of 'instanceof' is not callable")));
return op.Finish(Local<Value>(), false, out);
}
v8::Maybe<bool> is = v->InstanceOf(op.e.context, c.As<Object>());
bool ok = is.IsJust();
return op.Finish(v8::Boolean::New(op.e.isolate, ok && is.FromJust()), ok, out);
}
int OwnKeys(uint32_t h, int mode, ScrNodeValue *out) {
Op op;
Local<Object> target;
Local<v8::Array> keys;
bool ok = Target(op, h, &target) &&
target
->GetOwnPropertyNames(
op.e.context,
mode == 0 ? static_cast<v8::PropertyFilter>(v8::ONLY_ENUMERABLE | v8::SKIP_SYMBOLS)
: v8::ALL_PROPERTIES,
v8::KeyConversionMode::kConvertToString)
.ToLocal(&keys);
return op.Finish(keys, ok, out);
}
void OnSettled(const FunctionCallbackInfo<Value> &info, int rejected) {
Isolate *isolate = info.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
uint32_t token = info.Data()->Uint32Value(context).FromMaybe(0);
ScrNodeValue value;
FromV8(isolate, context, info[0], &value);
g_host->settle_value(token, rejected, &value);
}
void OnFulfilled(const FunctionCallbackInfo<Value> &info) { OnSettled(info, 0); }
void OnRejected(const FunctionCallbackInfo<Value> &info) { OnSettled(info, 1); }
// await of a value: PromiseResolve (a native promise is itself) and one
// reaction, as `await` does in JavaScript.
int Then(const ScrNodeValue *value, uint32_t token, ScrNodeValue *out) {
Op op;
Local<Value> v = op.V(value);
Local<v8::Promise> promise;
if (v->IsPromise()) {
promise = v.As<v8::Promise>();
} else {
Local<v8::Promise::Resolver> resolver;
if (!v8::Promise::Resolver::New(op.e.context).ToLocal(&resolver) ||
resolver->Resolve(op.e.context, v).IsNothing())
return op.Finish(Local<Value>(), false, out);
promise = resolver->GetPromise();
}
Local<Value> data = Integer::NewFromUnsigned(op.e.isolate, token);
Local<Function> fulfilled, rejected;
Local<v8::Promise> chained;
bool ok = Function::New(op.e.context, OnFulfilled, data).ToLocal(&fulfilled) &&
Function::New(op.e.context, OnRejected, data).ToLocal(&rejected) &&
promise->Then(op.e.context, fulfilled, rejected).ToLocal(&chained);
return op.Finish(v8::Undefined(op.e.isolate), ok, out);
}
void ErrorInfo(uint32_t h, ScrNodeErrorInfo *out) {
Op op;
std::memset(out, 0, sizeof *out);
Local<Value> v = HandleValue(op.e.isolate, h);
if (!v->IsObject()) return;
Local<Object> error = v.As<Object>();
static const struct { const char *name; uint32_t kind; } kinds[] = {
{"TypeError", 1}, {"RangeError", 2}, {"SyntaxError", 3},
{"ReferenceError", 5}, {"EvalError", 6}, {"URIError", 7}};
for (const auto &k : kinds) {
Local<Value> ctor = op.e.context->Global()->Get(op.e.context, Utf8(op.e.isolate, k.name)).ToLocalChecked();
if (error->InstanceOf(op.e.context, ctor.As<Object>()).FromMaybe(false)) {
out->kind = k.kind;
break;
}
}
auto read = [&](const char *key, const char **data, size_t *len, bool optional) {
Local<Value> value;
if (!error->Get(op.e.context, Utf8(op.e.isolate, key)).ToLocal(&value)) {
op.try_catch.Reset();
return;
}
if (optional && !value->IsString()) return;
Local<String> text;
if (!value->ToString(op.e.context).ToLocal(&text)) {
op.try_catch.Reset();
return;
}
*data = Scratch(Wtf8(op.e.isolate, text), len);
};
read("name", &out->name, &out->name_len, false);
read("message", &out->message, &out->message_len, false);
read("stack", &out->stack, &out->stack_len, true);
read("code", &out->code, &out->code_len, true);
}
void DropHandle(uint32_t h) {
if (!g_setup) return;
Isolate *isolate = g_setup->isolate();
HandleScope handles(isolate);
Drop(h);
}
const ScrNodeBridge kBridge = {
SCR_NODE_BRIDGE_ABI,
NODE_VERSION_STRING,
@@ -648,6 +1243,20 @@ const ScrNodeBridge kBridge = {
RunLoop,
Emit,
Stop,
Load,
Get,
SetProp,
Has,
Del,
Invoke,
InvokeMethod,
Construct,
InstanceOf,
OwnKeys,
Then,
ErrorInfo,
DropHandle,
LiveHandles,
};
} // namespace
+1 -1
View File
@@ -46,7 +46,7 @@ static const char kBootstrap[] =
" },\n"
" setTimer() { return 0; }, clearTimer() {}, refTimer() {}, refreshTimer() {},\n"
" setImmediate() { return 0; }, clearImmediate() {}, refImmediate() {},\n"
" nextTick() {}, emit() {},\n"
" nextTick() {}, emit() {}, load() {},\n"
"};\n";
static double now_ms(void) {
+13
View File
@@ -36,3 +36,16 @@ export declare function call<T = unknown>(
name: string,
...args: unknown[]
): Promise<T>;
/** Loads the module `specifier` with require() from the calling module and
* returns its exports **by reference** (scope-B Node islands, NI-4).
*
* In scriptc builds the module runs in Node.js loaded into the program on
* first use; the specifier must be a string literal, resolved at build time
* as `require.resolve` resolves it from the caller. The exports and every
* object reached through them stay Node objects: the program holds handles
* to them, so identity, aliasing and mutation are shared with Node.
* Primitives, strings and BigInt are copied; promises become promises;
* Buffers and typed arrays share their memory. Under Node.js this is
* `createRequire(callerFile)(specifier)`. */
export declare function requireModule<T = any>(specifier: string): T;
+5
View File
@@ -135,3 +135,8 @@ export async function call(specifier, name, ...args) {
throw copyThrown(error);
}
}
/** requireModule(specifier): the module's exports, by reference. */
export function requireModule(specifier) {
return createRequire(callerFile(requireModule))(specifier);
}
@@ -81,7 +81,10 @@ export const OPTIONAL_RUNTIME_SOURCES = [
["scr_js_value.c", "dynTier"],
["scr_js_object.c", "dynTier"],
["scr_event_target.c", any("eventTarget", "nativeFetch", "abortSignal")],
["scr_async_dyn.c", any("dynAsync", "dynInvoke", "dc", "fileHandle", "nativeFetch")],
[
"scr_async_dyn.c",
any("dynAsync", "dynInvoke", "dc", "fileHandle", "nativeFetch", "nodeIsland"),
],
// The crypto global's object extends scr_async_dyn.c's crypto object.
["scr_webcrypto_object.c", all("webcrypto", "dynAsync")],
// The performance timeline emits its buffer warning through scr_async_dyn.c.
@@ -191,6 +194,7 @@ export const OPTIONAL_RUNTIME_SOURCES = [
// Node islands: libnode loaded on first use (explicit @scriptc/node calls),
// over the SCR_NODE_ISLANDS build of scr_async.c.
["scr_node_island.c", "nodeIsland"],
["scr_node_island_handle.c", "nodeIsland"],
["scr_stream_from_web.c", all("stream", "nativeFetch")],
];
@@ -201,6 +205,18 @@ export function variantsFor(source) {
// other program links the ordinary object, byte for byte.
if (source === "scr_json.c") {
variants.push({ id: "dyn-tier", when: { dynTier: true }, defines: ["SCR_JS_TIER"] });
// Dynamic operations on Node island handles (typeof, calls, `in`,
// delete, Object.keys) route to scr_node_island_handle.c.
variants.push({
id: "node-island",
when: { nodeIsland: true },
defines: ["SCR_NODE_ISLANDS"],
});
variants.push({
id: "dyn-tier-node-island",
when: { dynTier: true, nodeIsland: true },
defines: ["SCR_JS_TIER", "SCR_NODE_ISLANDS"],
});
// Programs with web streams: instanceof over their classes' handles.
variants.push({
id: "handle-classes",
@@ -212,6 +228,26 @@ export function variantsFor(source) {
when: { dynTier: true, webStreams: true },
defines: ["SCR_JS_TIER", "SCR_HANDLE_CLASSES"],
});
// Both: the variant with the most conditions is chosen, so programs
// with web streams and Node islands need the builds that define both.
variants.push({
id: "handle-classes-node-island",
when: { webStreams: true, nodeIsland: true },
defines: ["SCR_HANDLE_CLASSES", "SCR_NODE_ISLANDS"],
});
variants.push({
id: "dyn-tier-handle-classes-node-island",
when: { dynTier: true, webStreams: true, nodeIsland: true },
defines: ["SCR_JS_TIER", "SCR_HANDLE_CLASSES", "SCR_NODE_ISLANDS"],
});
}
// util.inspect and instanceof of Node island handles.
if (source === "scr_inspect.c" || source === "scr_dyn_builtin_class.c") {
variants.push({
id: "node-island",
when: { nodeIsland: true },
defines: ["SCR_NODE_ISLANDS"],
});
}
if (source === "scr_bytes.c") {
variants.push({
@@ -239,6 +275,14 @@ export function variantsFor(source) {
when: { webStreams: true },
defines: ["SCR_NATIVE_REACTIONS"],
});
// A program with both web streams and Node islands needs both builds:
// the island slot carries the hosted loop, and the variant with the
// most conditions is the one selected.
variants.push({
id: "native-reactions-node-island",
when: { webStreams: true, nodeIsland: true },
defines: ["SCR_NATIVE_REACTIONS", "SCR_NODE_ISLANDS"],
});
}
if (source === "scr_dyn_builtin_class.c" || source === "scr_fetch.c") {
variants.push({
@@ -247,6 +291,14 @@ export function variantsFor(source) {
defines: ["SCR_HANDLE_CLASSES"],
});
}
// instanceof over both web stream and Node island handles.
if (source === "scr_dyn_builtin_class.c") {
variants.push({
id: "handle-classes-node-island",
when: { webStreams: true, nodeIsland: true },
defines: ["SCR_HANDLE_CLASSES", "SCR_NODE_ISLANDS"],
});
}
// stdin setEncoding, string data listeners and stdio 'resize' events.
if (source === "scr_events.c") {
variants.push({
@@ -0,0 +1,42 @@
import { expect, test } from "vitest";
import { createRuntimePackMatrix } from "../runtime-pack-matrix.mjs";
const matrix = createRuntimePackMatrix({
target: { object_format: "elf" },
compileFlags: [],
systemLibraries: [],
});
/** The runtime pack's variant selection: the matching variant with the
* most conditions, ties by id. */
function selected(
source: string,
features: Record<string, boolean>,
): { id: string; defines: string[] } {
const unit = matrix.runtime_units.find((u: { source: string }) => u.source === source);
const matches = unit.variants.filter((variant: { when: Record<string, boolean> }) =>
Object.entries(variant.when).every(([name, expected]) => features[name] === expected),
);
matches.sort(
(a: { when: object; id: string }, b: { when: object; id: string }) =>
Object.keys(b.when).length - Object.keys(a.when).length || a.id.localeCompare(b.id),
);
return matches[0];
}
test("web streams and Node islands select an async build that defines both", () => {
const variant = selected("scr_async.c", { webStreams: true, nodeIsland: true, workers: false });
expect(variant.defines).toEqual(
expect.arrayContaining(["SCR_NATIVE_REACTIONS", "SCR_NODE_ISLANDS"]),
);
// Each feature alone still selects its own build, byte for byte.
expect(
selected("scr_async.c", { webStreams: true, nodeIsland: false, workers: false }).defines,
).toEqual(["SCR_NATIVE_REACTIONS"]);
expect(
selected("scr_async.c", { webStreams: false, nodeIsland: true, workers: false }).defines,
).toEqual(["SCR_NODE_ISLANDS"]);
expect(
selected("scr_async.c", { webStreams: false, nodeIsland: false, workers: false }).defines,
).toEqual([]);
});
+1
View File
@@ -240,6 +240,7 @@ typedef enum {
/* Tags added since the last pin, by name only (see above). */
#define SCR_DYNH_NEW_TAGS(Y) \
Y(NODE_ISLAND) /* a Node island value (scr_node_island_handle.c) */ \
Y(WS_BYTE_STRATEGY) /* ByteLengthQueuingStrategy (scr_webstreams.c) */ \
Y(WS_COUNT_STRATEGY) /* CountQueuingStrategy (scr_webstreams.c) */ \
Y(WS_RCONTROLLER) /* ReadableStreamDefaultController (scr_webstreams.c) */ \
@@ -1587,6 +1587,9 @@ bool scr_dyn_builtin_instanceof(ScrDyn *value, double kind) {
/* `value instanceof callee` for an untyped constructor function. */
bool scr_dyn_function_instanceof(ScrDyn *value, ScrDyn *callee) {
#ifdef SCR_NODE_ISLANDS
if (scr_ni_is_handle(callee)) return scr_ni_instanceof(value, callee) == 1;
#endif
if (!scr_dyn_is_callable(callee)) {
static const char message[] = "Right-hand side of 'instanceof' is not callable";
scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1);
+4
View File
@@ -2653,6 +2653,10 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) {
ScrStr *iterator = scr_insp_native_iterator(d, recurse, depth);
if (iterator) return iterator;
}
#ifdef SCR_NODE_ISLANDS
/* An island object prints as Node's util.inspect prints it. */
if (d->v.handle.tag == SCR_DYNH_NODE_ISLAND) return scr_ni_inspect(d, recurse, depth);
#endif
const ScrDynHandleOps *ops = scr_dyn_handle_ops_of(d);
if (ops->inspect_view) {
/* Node's custom inspectors answer the bare class name beyond the
+66
View File
@@ -5604,6 +5604,9 @@ static bool scr_dyn_instanceof_function(const ScrDyn *value) {
}
bool scr_bytes_instanceof_at(const ScrDyn *value, const ScrDyn *callee, const ScrStr *refusal) {
#ifdef SCR_NODE_ISLANDS
if (scr_ni_is_handle(callee)) return scr_ni_instanceof((ScrDyn *)value, (ScrDyn *)callee) == 1;
#endif
if (!callee || callee->kind != SCR_DYN_FUNC) {
static const char message[] = "Right-hand side of 'instanceof' is not callable";
scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1);
@@ -6459,6 +6462,15 @@ ScrDyn *scr_dyn_class_prototype(ScrDyn *value, ScrDyn *base) {
* compiled per signature). Args borrowed; result owned (+1) or NULL with
* the exception pending. */
ScrDyn *scr_dyn_call(const ScrDyn *d, ScrDyn *const *args, size_t argc, const char *what) {
#ifdef SCR_NODE_ISLANDS
/* An island function: called in Node with the ambient receiver. */
if (scr_ni_is_handle(d) && scr_ni_is_function(d)) {
ScrDyn *receiver = scr_dyn_this_get();
ScrDyn *result = scr_ni_call((ScrDyn *)d, receiver, args, argc);
scr_dyn_release(receiver);
return result;
}
#endif
if (d->kind == SCR_DYN_PROXY && scr_dyn_is_callable(d->v.proxy.target)) {
ScrDyn *trap = scr_dyn_proxy_trap(d, "apply");
if (scr_exc_pending()) return NULL;
@@ -7590,6 +7602,10 @@ void scr_dyn_name_class_prototype(ScrDyn *prototype, const ScrStr *name) {
/* Ordinary native function constructors retain their live prototype table.
* A constructor may replace the fresh receiver with another object. */
ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr *what) {
#ifdef SCR_NODE_ISLANDS
if (scr_ni_is_handle(callee))
return scr_ni_construct((ScrDyn *)callee, args->v.arr.items, args->v.arr.len);
#endif
if (callee->kind != SCR_DYN_FUNC || callee->v.fn.class_obj ||
(scr_closure_identity(callee->v.fn.clo)->function_kind & SCR_CLOSURE_KIND_MASK) != SCR_CLOSURE_KIND_OWN_PROTOTYPE)
return scr_bytes_construct(callee, args, what);
@@ -9685,6 +9701,9 @@ bool scr_dyn_truthy(const ScrDyn *d) {
* null answers "object" — JS's oldest wart, preserved. */
ScrStr *scr_dyn_typeof(const ScrDyn *d) {
const char *s;
#ifdef SCR_NODE_ISLANDS
if (scr_ni_is_handle(d)) return scr_ni_typeof((ScrDyn *)d);
#endif
if (d->kind == SCR_DYN_PROXY) return scr_dyn_typeof(d->v.proxy.target);
if (d->kind == SCR_DYN_TYPED_REF) {
if ((d->v.typed_ref.type_key_len >= 7 &&
@@ -9721,6 +9740,9 @@ ScrStr *scr_dyn_typeof(const ScrDyn *d) {
bool scr_dyn_is_callable(const ScrDyn *value) {
while (value->kind == SCR_DYN_PROXY) value = value->v.proxy.target;
#ifdef SCR_NODE_ISLANDS
if (scr_ni_is_handle(value)) return scr_ni_is_function(value);
#endif
return value->kind == SCR_DYN_FUNC || scr_dyn_ref_typeof_is(value, "function");
}
@@ -11828,6 +11850,14 @@ bool scr_dyn_has_key(const ScrDyn *v, const ScrStr *key) {
* Array.prototype before it for arrays) and, for a class instance seen as
* `unknown`, its class's compiled methods and accessors. */
bool scr_dyn_in(const ScrDyn *v, const ScrStr *key) {
#ifdef SCR_NODE_ISLANDS
if (scr_ni_is_handle(v)) {
ScrDyn *k = scr_dyn_new_str((ScrStr *)key);
int found = scr_ni_has((ScrDyn *)v, k);
scr_dyn_release(k);
return found == 1;
}
#endif
/* Generator objects have no checked view; scr_dyn_has_key answers their
* prototype members. */
if (v->kind == SCR_DYN_TYPED_REF && !scr_dyn_generator(v)) {
@@ -14205,6 +14235,14 @@ void scr_dyn_obj_delete_field(ScrDyn *obj, const ScrStr *key) {
* arrays, typed references and handles keep their explicit boundary;
* deleting from a materialized snapshot would lose the mutation. */
void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict) {
#ifdef SCR_NODE_ISLANDS
if (scr_ni_is_handle(recv)) {
ScrDyn *k = scr_dyn_new_str((ScrStr *)key);
(void)scr_ni_delete(recv, k);
scr_dyn_release(k);
return;
}
#endif
if (scr_dyn_class_reflection_fence(recv) || scr_dyn_error_prototype_fence(recv)) return;
if (recv->kind == SCR_DYN_PROXY) { scr_dyn_proxy_delete(recv, key); return; }
if (recv->kind == SCR_DYN_TYPED_REF && recv->v.typed_ref.commit &&
@@ -16246,6 +16284,34 @@ static ScrDyn *scr_dyn_obj_data_walk(const ScrDyn *v, ScrObjWalk mode) {
static ScrDyn *scr_dyn_objwalk(const ScrDyn *v, ScrObjWalk mode) {
if (scr_dyn_class_reflection_fence(v)) return NULL;
#ifdef SCR_NODE_ISLANDS
/* Object.keys/values/entries of an island object: its own enumerable
* string keys, read in Node. */
if (scr_ni_is_handle(v)) {
ScrDyn *keys = scr_ni_own_keys((ScrDyn *)v, 0);
if (keys == NULL || mode == SCR_OBJWALK_KEYS) return keys;
ScrDyn *out = scr_dyn_new_arr();
for (size_t i = 0; i < keys->v.arr.len; i++) {
ScrDyn *key = keys->v.arr.items[i];
ScrDyn *value = scr_ni_get((ScrDyn *)v, key);
if (value == NULL) {
scr_dyn_release(out);
scr_dyn_release(keys);
return NULL;
}
if (mode == SCR_OBJWALK_VALUES) {
scr_dyn_arr_push(out, value);
} else {
ScrDyn *pair = scr_dyn_new_arr();
scr_dyn_arr_push(pair, scr_dyn_retain(key));
scr_dyn_arr_push(pair, value);
scr_dyn_arr_push(out, pair);
}
}
scr_dyn_release(keys);
return out;
}
#endif
if (v->kind == SCR_DYN_PROXY) {
ScrDyn *trap = scr_dyn_proxy_trap(v, "ownKeys");
if (scr_exc_pending()) return NULL;
+110 -1
View File
@@ -17,7 +17,7 @@
#include <stddef.h>
#include <stdint.h>
#define SCR_NODE_BRIDGE_ABI 4u
#define SCR_NODE_BRIDGE_ABI 5u
/* The symbol a bridge library exports: `const ScrNodeBridge *(void)`. */
#define SCR_NODE_BRIDGE_ENTRY "scr_node_bridge_v1"
@@ -59,6 +59,78 @@ typedef struct ScrNodeIoState {
int cap_ms;
} ScrNodeIoState;
/* ── object handles (scope-B NI-4) ────────────────────────────────────────
* A value crossing between the runtime and V8. Primitives, strings and
* BigInt are copies; objects are handles: ids into the bridge's import
* table of v8::Global references, one id per V8 object while the runtime
* holds it (the bridge answers the same id for the same object), released
* with `drop`. Typed arrays and Buffers coming out of V8 also carry their
* backing store's memory (BYTES), which the runtime aliases.
*
* Out-values the bridge writes hold bridge-owned string data valid until
* the next bridge entry; the runtime copies it at once. In-values are
* borrowed for the call. */
enum {
SCR_NV_UNDEFINED = 0,
SCR_NV_NULL = 1,
SCR_NV_BOOL = 2,
SCR_NV_NUMBER = 3,
SCR_NV_STRING = 4, /* WTF-8: lone surrogates as their 3-byte forms */
SCR_NV_BIGINT = 5, /* str: decimal text with an optional leading '-' */
SCR_NV_HANDLE = 6, /* handle: an import-table id; flags: SCR_NVF_* */
SCR_NV_BYTES = 7, /* bytes: a typed array / Buffer / DataView over shared memory */
SCR_NV_ERROR = 8, /* into V8 only: a new Error of an intrinsic class */
SCR_NV_JSON = 9, /* into V8 only: str is JSON text, parsed there (a copy) */
SCR_NV_EXPORT = 10, /* reserved for NI-5: a runtime object exported by reference */
};
/* What a handle is, reported with every handle the bridge answers. */
enum {
SCR_NVF_FUNCTION = 1,
SCR_NVF_SYMBOL = 2,
SCR_NVF_ERROR = 4,
SCR_NVF_ARRAY = 8,
SCR_NVF_PROMISE = 16,
SCR_NVF_CONSTRUCTOR = 32,
SCR_NVF_BUFFER = 64, /* BYTES: a Buffer; DATAVIEW: a DataView */
SCR_NVF_DATAVIEW = 128,
};
/* BYTES element kinds: ScrBytesElem's numbering (scr_bytes.h). */
typedef struct ScrNodeValue {
uint32_t tag;
uint32_t flags;
union {
int boolean;
double num;
struct {
const char *data;
size_t len;
} str;
uint32_t handle;
struct {
uint8_t *data; /* first element */
size_t len; /* elements */
uint32_t elem; /* ScrBytesElem */
uint32_t handle; /* out of V8: the view's handle (keeps the store alive) */
void *owner; /* into V8: the runtime ScrBytes root that owns data */
} bytes;
struct {
uint32_t kind; /* scr_error.h SCR_ERR_* (intrinsic class) */
const char *name, *message, *stack, *code; /* code NULL: none */
size_t name_len, message_len, stack_len, code_len;
} error;
} u;
} ScrNodeValue;
/* An island error's own properties, read by `error_info` (bridge-owned
* strings, valid until the next entry; code NULL when it has no string
* code). kind is the nearest intrinsic class (SCR_ERR_*). */
typedef struct ScrNodeErrorInfo {
uint32_t kind;
const char *name, *message, *stack, *code;
size_t name_len, message_len, stack_len, code_len;
} ScrNodeErrorInfo;
/* What the runtime provides to the bridge. */
typedef struct ScrNodeBridgeHost {
/* An asynchronous call settled with this outcome. */
@@ -93,6 +165,13 @@ typedef struct ScrNodeBridgeHost {
* start it records for the thread, so it must not record the thread's
* when JavaScript runs on the runtime's stack. */
uintptr_t (*stack_start)(void);
/* NI-4: a thenable the runtime awaits (`then`) settled; `value` is the
* fulfillment or (rejected=1) the reason. Runs as a V8 reaction job. */
void (*settle_value)(uint32_t token, int rejected, const ScrNodeValue *value);
/* NI-4: V8 freed a backing store over runtime bytes (`owner` of a BYTES
* value that went into V8). May run on any thread: the runtime queues
* the release for its own thread. */
void (*bytes_free)(void *owner);
} ScrNodeBridgeHost;
typedef struct ScrNodeBridge {
@@ -151,6 +230,36 @@ typedef struct ScrNodeBridge {
void (*emit)(uint32_t event, uint32_t listener, const char *name, int arg);
/* Tears Node down at process exit. */
void (*stop)(void);
/* ── object handles (NI-4). Each operation answers 0 (returned: *out is
* the result) or 1 (threw: *out is the thrown value). ── */
/* Loads a module by absolute path: require(path) (esm=0, the exports),
* or import(path) (esm=1, a promise of the namespace). */
int (*load)(const char *path, int esm, ScrNodeValue *out);
/* Property access on handle h; key goes through ToPropertyKey. */
int (*get)(uint32_t h, const ScrNodeValue *key, ScrNodeValue *out);
int (*set)(uint32_t h, const ScrNodeValue *key, const ScrNodeValue *value, ScrNodeValue *out);
int (*has)(uint32_t h, const ScrNodeValue *key, ScrNodeValue *out); /* out: BOOL */
int (*del)(uint32_t h, const ScrNodeValue *key, ScrNodeValue *out); /* out: BOOL */
/* fn(...args) with receiver this_ (NULL: undefined). */
int (*invoke)(uint32_t fn, const ScrNodeValue *this_, const ScrNodeValue *args, size_t argc,
ScrNodeValue *out);
/* h[key](...args) with h as the receiver. */
int (*invoke_method)(uint32_t h, const ScrNodeValue *key, const ScrNodeValue *args, size_t argc,
ScrNodeValue *out);
int (*construct)(uint32_t h, const ScrNodeValue *args, size_t argc, ScrNodeValue *out);
int (*instance_of)(const ScrNodeValue *value, uint32_t ctor, ScrNodeValue *out); /* BOOL */
/* Own keys of h: mode 0 Object.keys, mode 1 Reflect.ownKeys. out: a
* handle to a fresh array, which the runtime reads and drops. */
int (*own_keys)(uint32_t h, int mode, ScrNodeValue *out);
/* Promise.resolve(value).then(...): settles through host->settle_value
* with `token`. Answers 1 with *out thrown if `then` could not attach. */
int (*then)(const ScrNodeValue *value, uint32_t token, ScrNodeValue *out);
/* The own name, message, stack and string code of an error handle. */
void (*error_info)(uint32_t h, ScrNodeErrorInfo *out);
/* Releases handle h's Global. */
void (*drop)(uint32_t h);
/* The number of live import-table entries (leak tests). */
uint32_t (*live_handles)(void);
} ScrNodeBridge;
#endif /* SCR_NODE_BRIDGE_H */
+36 -5
View File
@@ -689,19 +689,24 @@ static void ni_open_modules(void) {
ni_fatal("scriptc: cannot write this program's Node island modules to a private directory\n");
}
/* The calling module's path inside the tree: `<root>/<relative path>`. */
static char *ni_caller_path(const ScrStr *relative) {
/* A path inside the tree: `<root>/<relative path>`. */
static char *ni_tree_path(const char *relative, size_t len) {
size_t root = strlen(ni_root);
bool slash = root > 0 && ni_root[root - 1] == '/';
char *path = malloc(root + 1 + relative->len + 1);
char *path = malloc(root + 1 + len + 1);
if (path == NULL) ni_oom();
memcpy(path, ni_root, root);
if (!slash) path[root++] = '/';
memcpy(path + root, relative->data, relative->len);
path[root + relative->len] = '\0';
memcpy(path + root, relative, len);
path[root + len] = '\0';
return path;
}
/* The calling module's path inside the tree. */
static char *ni_caller_path(const ScrStr *relative) {
return ni_tree_path(relative->data, relative->len);
}
/* The bootstrap: the tree root for the host's resolution guard, then the
* host script. +1 malloc'd. */
static char *ni_bootstrap(void) {
@@ -1158,6 +1163,8 @@ static const ScrNodeBridgeHost ni_host = {
&ni_host_listen,
&ni_stack_limit,
&ni_stack_start,
&scr_ni_settle_value,
&scr_ni_bytes_free,
};
typedef struct {
@@ -1186,6 +1193,7 @@ void scr_node_island_emit(uint32_t event, uint32_t listener, ScrStr *name, int a
static void ni_stop_job(void *p) {
(void)p;
scr_ni_stop();
ni_bridge->stop();
}
@@ -1194,6 +1202,13 @@ static void ni_stop_job(void *p) {
* the events unit's own, which then finds them done), then Node stops. */
static void ni_stop_atexit(void) {
scr_run_exit_listeners((double)scr_exit_code_hint_get());
/* SCRIPTC_NODE_ISLANDS_TRACE=handles: the import table at exit (leak
* tests: acyclic island objects the program dropped are released). */
const char *trace = getenv("SCRIPTC_NODE_ISLANDS_TRACE");
if (trace != NULL && strcmp(trace, "handles") == 0) {
fflush(stdout);
fprintf(stderr, "[node-islands] live handles %u\n", scr_ni_live_handles());
}
ni_on_node_stack(ni_stop_job, NULL);
}
@@ -1349,6 +1364,7 @@ static void ni_start(void) {
* delivery; installed before the stop handler, whose 'exit' listeners
* run first. */
scr_events_install();
scr_ni_handles_install();
scr_atexit(ni_stop_atexit);
ni_hosted = *bridge;
ni_hosted.run_loop = &ni_run_loop;
@@ -1365,6 +1381,21 @@ static void ni_start(void) {
scr_loop_host(&ni_hosted, since);
}
const ScrNodeBridge *scr_ni_bridge(void) {
ni_start();
return ni_bridge;
}
const ScrNodeBridge *scr_ni_bridge_loaded(void) { return ni_bridge; }
char *scr_ni_module_path(const ScrStr *path) {
if (path->len >= 2 && path->data[0] == '.' && path->data[1] == '/')
return ni_tree_path(path->data + 2, path->len - 2);
return ni_cstr_of(path->data, path->len);
}
void scr_ni_on_node_stack(void (*job)(void *), void *arg) { ni_on_node_stack(job, arg); }
/* ── calls ────────────────────────────────────────────────────────────── */
/* The JSON text of the argument array (+1 dyn string), or NULL with the
+56
View File
@@ -66,4 +66,60 @@ void scr_signal_off_island(uint32_t listener);
void scr_process_on_exit_island(uint32_t listener);
void scr_process_off_exit_island(uint32_t listener);
/* ── object handles (scope-B NI-4, scr_node_island_handle.c) ─────────────
* Island values the program holds are SCR_DYN_HANDLE boxes with tag
* SCR_DYNH_NODE_ISLAND: one handle per V8 value, so `===` is identity.
* Primitives, strings and BigInt are copies; island promises are scriptc
* promises; typed arrays and Buffers are ScrBytes over the same memory.
* All arguments are borrowed; ScrDyn results are +1, NULL (or false, or
* -1 for the int forms) with the exception pending. The first call loads
* Node. See scriptc-perf/results/ni-4/ABI.md. */
ScrDyn *scr_ni_require(ScrStr *path); /* require(absolute path): the exports */
ScrPromise *scr_ni_import(ScrStr *path); /* import(absolute path): the namespace */
ScrDyn *scr_ni_get(ScrDyn *obj, ScrDyn *key);
ScrDyn *scr_ni_get_str(ScrDyn *obj, const char *key, size_t len);
bool scr_ni_set(ScrDyn *obj, ScrDyn *key, ScrDyn *value);
bool scr_ni_set_str(ScrDyn *obj, const char *key, size_t len, ScrDyn *value);
int scr_ni_has(ScrDyn *obj, ScrDyn *key);
int scr_ni_delete(ScrDyn *obj, ScrDyn *key);
ScrDyn *scr_ni_call(ScrDyn *fn, ScrDyn *this_, ScrDyn *const *args, size_t argc);
ScrDyn *scr_ni_call_method(ScrDyn *obj, const char *key, size_t len, ScrDyn *const *args,
size_t argc);
ScrDyn *scr_ni_construct(ScrDyn *ctor, ScrDyn *const *args, size_t argc);
ScrStr *scr_ni_typeof(ScrDyn *value);
int scr_ni_instanceof(ScrDyn *value, ScrDyn *ctor);
bool scr_ni_is_handle(const ScrDyn *value);
bool scr_ni_is_function(const ScrDyn *value); /* an island handle that is callable */
/* util.inspect of an island value at nesting `recurse` with limit `depth`,
* by Node's own util.inspect (+1; "" with the exception pending). */
ScrStr *scr_ni_inspect(const ScrDyn *value, double recurse, double depth);
ScrDyn *scr_ni_own_keys(ScrDyn *obj, int mode); /* 0 Object.keys, 1 Reflect.ownKeys */
ScrPromise *scr_ni_await(ScrDyn *value);
uint32_t scr_ni_live_handles(void); /* live import-table entries (leak tests) */
/* Between the two island units. */
struct ScrNodeValue;
const struct ScrNodeBridge *scr_ni_bridge(void); /* loads Node */
const struct ScrNodeBridge *scr_ni_bridge_loaded(void); /* NULL until loaded */
/* The file a module load names, +1 malloc'd: `./<path>` is a path in the
* program's module tree, under its root once Node is loaded; anything else
* (a built-in module) as it is. */
char *scr_ni_module_path(const ScrStr *path);
void scr_ni_on_node_stack(void (*job)(void *), void *arg);
void scr_ni_settle_value(uint32_t token, int rejected, const struct ScrNodeValue *value);
void scr_ni_bytes_free(void *owner);
void scr_ni_handles_install(void);
void scr_ni_stop(void);
/* Island operations the compiler emits (lib-shared.ts adapters). */
ScrDyn *scr_node_island_apply(ScrDyn *fn, ScrDyn *args);
/* fn called with `this` (a method read before its arguments ran). */
ScrDyn *scr_node_island_apply_to(ScrDyn *fn, ScrDyn *this_, ScrDyn *args);
ScrDyn *scr_node_island_construct(ScrDyn *ctor, ScrDyn *args);
ScrDyn *scr_node_island_get(ScrDyn *obj, ScrStr *key);
bool scr_node_island_instanceof(ScrDyn *value, ScrDyn *ctor);
ScrDyn *scr_node_island_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *args);
void scr_node_island_set(ScrDyn *obj, ScrStr *key, ScrDyn *value);
void scr_node_island_set_key(ScrDyn *obj, ScrDyn *key, ScrDyn *value);
#endif /* SCR_NODE_ISLAND_H */
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -14,7 +14,7 @@
* them in-process under Node: both must produce the same results, errors
* and messages. Outcomes are "R<json>" (returned; "R" alone is undefined)
* or "T<json>" (threw), the encoding of scr_node_bridge.h. */
const { createRequire, registerHooks } = require("node:module");
const { createRequire, isBuiltin, registerHooks } = require("node:module");
const { constants: osConstants } = require("node:os");
const timers = require("node:timers");
@@ -157,6 +157,19 @@ function call(id, sync, file, specifier, name, argsJson) {
return undefined;
}
// ── island modules (scope-B NI-4) ──────────────────────────────────────
// Island code loaded by absolute path: require (CommonJS, and require(esm)
// for ES modules without top-level await) answers the exports; import
// answers a promise of the namespace.
const { pathToFileURL } = require("node:url");
function load(path, esm) {
// A built-in module's name, with or without the node: prefix.
if (isBuiltin(path)) return require(path);
if (esm) return import(pathToFileURL(path).href);
return createRequire(path)(path);
}
// ── the hosted loop (scope-B NI-3) ─────────────────────────────────────
// The runtime's ticks, timers and immediates are Node's, created here with
// Node's own timers (captured now, so island code replacing the globals
@@ -388,4 +401,5 @@ globalThis.__scriptcNodeIsland = {
refImmediate,
nextTick,
emit,
load,
};
+15 -1
View File
@@ -20,7 +20,7 @@ static const char scr_node_island_host_js[] =
" * them in-process under Node: both must produce the same results, errors\012"
" * and messages. Outcomes are \"R<json>\" (returned; \"R\" alone is undefined)\012"
" * or \"T<json>\" (threw), the encoding of scr_node_bridge.h. */\012"
"const { createRequire, registerHooks } = require(\"node:module\");\012"
"const { createRequire, isBuiltin, registerHooks } = require(\"node:module\");\012"
"const { constants: osConstants } = require(\"node:os\");\012"
"const timers = require(\"node:timers\");\012"
"\012"
@@ -163,6 +163,19 @@ static const char scr_node_island_host_js[] =
" return undefined;\012"
"}\012"
"\012"
"// \342\224\200\342\224\200 island modules (scope-B NI-4) \342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\012"
"// Island code loaded by absolute path: require (CommonJS, and require(esm)\012"
"// for ES modules without top-level await) answers the exports; import\012"
"// answers a promise of the namespace.\012"
"const { pathToFileURL } = require(\"node:url\");\012"
"\012"
"function load(path, esm) {\012"
" // A built-in module's name, with or without the node: prefix.\012"
" if (isBuiltin(path)) return require(path);\012"
" if (esm) return import(pathToFileURL(path).href);\012"
" return createRequire(path)(path);\012"
"}\012"
"\012"
"// \342\224\200\342\224\200 the hosted loop (scope-B NI-3) \342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\342\224\200\012"
"// The runtime's ticks, timers and immediates are Node's, created here with\012"
"// Node's own timers (captured now, so island code replacing the globals\012"
@@ -394,6 +407,7 @@ static const char scr_node_island_host_js[] =
" refImmediate,\012"
" nextTick,\012"
" emit,\012"
" load,\012"
"};\012";
#endif /* SCR_NODE_ISLAND_HOST_H */
@@ -5,6 +5,10 @@
// fs.realpath 1.0.0 (ISC) and value-forms read builtin functions as values:
// fs.realpathSync stored and compared, Buffer.alloc aliased, and a String
// method read for a feature probe.
// package.json keeps bound-builtins native: a fence in its module code
// would run it in a Node island, and this program iterates the array its
// report() returns, which native code cannot iterate across the island
// boundary yet.
import truncate from 'truncate-utf8-bytes';
import { report, badLength } from 'bound-builtins';
import realpath from 'fs.realpath';
@@ -0,0 +1 @@
{ "type": "module", "scriptc": { "packages": { "bound-builtins": "native" } } }
@@ -0,0 +1,6 @@
export const kind = "esm";
export let counter = 0;
export function bump() {
return ++counter;
}
export default { name: "default export" };
@@ -0,0 +1,30 @@
"use strict";
const shared = { count: 0, items: [] };
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
norm() {
return Math.hypot(this.x, this.y);
}
}
exports.shared = shared;
exports.Point = Point;
exports.getShared = () => shared;
exports.bump = () => ++shared.count;
exports.read = (o, k) => o[k];
exports.same = (a, b) => a === b;
exports.makeCounter = () => {
let n = 0;
return { inc: () => ++n, get value() { return n; } };
};
exports.add = (a, b) => a + b;
exports.numbers = () => [-0, NaN, Infinity, 2 ** 53 + 2, 10n ** 30n, -7n];
exports.isNegZero = (x) => Object.is(x, -0);
exports.strings = () => ["plain", "wörld ☃ 🌍", "lone \ud800 high", "\udc00 lone low"];
exports.codeUnits = (s) => Array.from(s, (c) => c.charCodeAt(0).toString(16)).join(" ");
exports.describe = (v) => `${typeof v} ${Object.prototype.toString.call(v)}`;
exports.keysOf = (o) => Object.keys(o).join(",");
@@ -0,0 +1,62 @@
// @requires-libnode
// Island objects held by reference (scope-B NI-4): one handle per Node
// object, so identity, aliasing and mutation are shared with Node, while
// primitives, strings and BigInt cross as exact copies.
import { requireModule } from "@scriptc/node";
const lib = requireModule("./lib.cjs");
// Identity: the same Node object is the same value on both sides.
const a = lib.getShared();
const b = lib.shared;
console.log("same handle", a === b, lib.same(a, b), lib.same(a, lib.getShared()));
console.log("distinct", lib.makeCounter() === lib.makeCounter());
// Mutation is visible on both sides.
a.count = 41;
console.log("island sees", lib.read(b, "count"), "bump", lib.bump(), "native sees", a.count);
a.items.push("x");
lib.shared.items.push("y");
console.log("items", a.items.length, b.items.join("+"), lib.read(lib.shared.items, 1));
a.extra = { nested: true };
console.log("keys", lib.keysOf(a), Object.keys(b).join(","));
console.log("in", "extra" in a, "missing" in a);
delete a.extra;
console.log("deleted", "extra" in b, lib.keysOf(a));
// Closures and getters live in Node.
const counter = lib.makeCounter();
counter.inc();
counter.inc();
console.log("counter", counter.inc(), counter.value);
// Functions as values, typeof, classes, instanceof.
const add = lib.add;
console.log("call", add(2, 3), typeof add, typeof a, typeof lib.Point);
const p = new lib.Point(3, 4);
console.log("point", p.x, p.y, p.norm(), p instanceof lib.Point, a instanceof lib.Point);
console.log("describe", lib.describe(p), lib.describe(add));
// Exact numbers and BigInt.
const nums = lib.numbers();
const negZero = nums[0];
console.log("numbers", Object.is(negZero, -0), lib.isNegZero(negZero), Number.isNaN(nums[1]), nums[2], nums[3]);
console.log("bigint", nums[4], typeof nums[4], nums[4] + 1n, lib.add(nums[5], 2n));
console.log("negzero in", lib.isNegZero(-0), lib.isNegZero(0));
// Strings, lone surrogates included.
const strs = lib.strings();
for (let i = 0; i < 4; i++) {
const s = strs[i];
console.log("string", s.length, lib.codeUnits(s) === lib.codeUnits(strs[i]), lib.same(s, strs[i]));
}
console.log("native string", lib.codeUnits("a\ud800b"), lib.add("x", "é"));
// util.inspect of island objects is Node's own.
console.log(p);
console.log(counter, [1, 2]);
console.log("inspect", lib.makeCounter());
// ES modules load through require(esm): the namespace is a live handle.
const esm = requireModule("./esm.mjs");
console.log("esm", esm.kind, esm.default.name, esm.bump(), esm.bump(), esm.counter);
@@ -0,0 +1,30 @@
"use strict";
class NotFound extends Error {
constructor(what) {
super(`${what} not found`);
this.name = "NotFound";
this.code = "E_NOT_FOUND";
}
}
let last;
exports.NotFound = NotFound;
exports.fail = (kind) => {
if (kind === "type") {
const e = new TypeError("bad type");
e.code = "ERR_BAD";
last = e;
throw e;
}
if (kind === "range") throw (last = new RangeError("out of range"));
if (kind === "custom") throw (last = new NotFound("thing"));
if (kind === "string") throw "just a string";
if (kind === "object") throw (last = { reason: "plain object" });
return "no throw";
};
exports.isLast = (e) => e === last;
exports.inspectError = (e) =>
`${e instanceof Error} ${e.constructor.name} ${e.name} ${JSON.stringify(e.message)} ${e.code} ${typeof e.stack}`;
exports.rethrow = (e) => {
throw e;
};
exports.call = (o, m) => o[m]();
@@ -0,0 +1,61 @@
// @requires-libnode
// Errors across the island boundary (scope-B NI-4): an island Error caught
// in the program keeps its class, name, message, code and stack, and is the
// same Node object when it crosses back; a program error passed into Node
// becomes a Node error with the same name, message and code.
import { requireModule } from "@scriptc/node";
const lib = requireModule("./lib.cjs");
for (const kind of ["type", "range", "custom"]) {
try {
lib.fail(kind);
} catch (e) {
console.log(kind, e instanceof Error, e instanceof TypeError, e instanceof RangeError);
console.log(" ", e.name, e.message, e.code);
console.log(" stack head", String(e.stack).split("\n")[0]);
console.log(" stack has island frame", String(e.stack).includes("lib.cjs"));
console.log(" same object in Node", lib.isLast(e), lib.inspectError(e));
try {
lib.rethrow(e);
} catch (again) {
console.log(" rethrown", again === e, again.message);
}
}
}
try {
lib.fail("string");
} catch (e) {
console.log("string", typeof e, e);
}
try {
lib.fail("object");
} catch (e) {
console.log("object", typeof e, e.reason, lib.isLast(e));
}
console.log(lib.fail("none"));
// Program errors crossing into Node.
const mine = new RangeError("from the program");
console.log("native", lib.inspectError(mine));
const coded = new Error("with code");
coded.code = "E_MINE";
console.log("native coded", lib.inspectError(coded));
try {
lib.rethrow(new TypeError("round trip"));
} catch (e) {
console.log("round trip", e instanceof TypeError, e.message);
}
// Errors from calling missing members.
try {
lib.call({}, "nope");
} catch (e) {
console.log("missing method", e instanceof TypeError, e.message);
}
try {
lib.nope();
} catch (e) {
console.log("missing export", e instanceof TypeError, e.message);
}
@@ -0,0 +1,12 @@
"use strict";
let kept;
exports.makeBuffer = () => (kept = Buffer.from("hello"));
exports.kept = () => kept;
exports.read = (b) => `${b.constructor.name} ${Buffer.isBuffer(b)} [${Array.from(b).join(",")}]`;
exports.fill = (b, v) => {
b.fill(v);
return b.length;
};
exports.same = (a, b) => a === b;
exports.floats = () => new Float64Array([1.5, -0, NaN]);
exports.sum = (arr) => arr.reduce((a, b) => a + b, 0);
@@ -0,0 +1,31 @@
// @requires-libnode
// Byte stores across the island boundary (scope-B NI-4): a Buffer or typed
// array crossing either way shares its memory, so writes on one side are
// visible on the other without copies.
import { requireModule } from "@scriptc/node";
const lib = requireModule("./lib.cjs");
const buf = lib.makeBuffer();
console.log("from island", buf.length, buf.toString(), Buffer.isBuffer(buf));
buf[0] = 72 - 32 + 32; // 'H'
buf[1] = 69;
console.log("island sees native write", lib.read(lib.kept()));
lib.fill(lib.kept(), 120);
console.log("native sees island write", buf.toString(), buf[4]);
console.log("same buffer back", lib.same(buf, lib.kept()), buf === lib.kept());
const mine = new Uint8Array([1, 2, 3]);
console.log("into island", lib.read(mine));
lib.fill(mine, 9);
console.log("native sees fill", mine.join(","));
const nb = Buffer.from("abc");
lib.fill(nb, 122);
console.log("native buffer", nb.toString(), lib.read(nb));
const floats = lib.floats();
console.log("floats", floats.length, floats[0], Object.is(floats[1], -0), Number.isNaN(floats[2]));
floats[0] = 2.5;
floats[2] = 4;
console.log("float sum in island", lib.sum(floats));
@@ -0,0 +1,10 @@
"use strict";
let pending;
exports.later = (v, ms) => new Promise((resolve) => setTimeout(() => resolve(v), ms));
exports.fails = async (m) => {
await null;
throw new RangeError(m);
};
exports.object = async () => ({ answer: 42 });
exports.keep = () => (pending = Promise.resolve("kept"));
exports.isKept = (p) => p === pending;
@@ -0,0 +1,24 @@
// @requires-libnode
// Island promises as program promises (scope-B NI-4): awaiting one settles
// through a V8 reaction on the one hosted loop; rejections keep their
// error; the same Node promise is the same program promise.
import { requireModule } from "@scriptc/node";
const lib = requireModule("./lib.cjs");
async function main() {
console.log("later", await lib.later("value", 5));
try {
await lib.fails("nope");
} catch (e) {
console.log("rejected", e instanceof RangeError, e.message);
}
const o = await lib.object();
console.log("object", o.answer, typeof o);
const p = lib.keep();
console.log("promise identity", lib.isKept(p), await p);
const all = await Promise.all([lib.later(1, 3), lib.later(2, 1)]);
console.log("all", all.join(","));
}
main().then(() => console.log("done"));
@@ -0,0 +1,84 @@
// @requires-libnode
// An npm package scriptc refuses natively (its loader uses a computed
// require() specifier) runs in a Node island: its objects cross as
// handles, so the same object returned twice is ===, and writes through
// one reference are visible through every other, on both sides.
import { Store, createStore, describe, VERSION, makePoint, addPoints, ping, makeTricky, type Tricky } from "islandkit";
import * as kit from "islandkit";
console.log(VERSION);
const s = createStore("fruits");
const apple = s.add("apple", 3);
const banana = s.add("banana", 5);
console.log(s.name, s.size, s.total());
const again = s.get("apple");
console.log("identity", apple === again, apple !== banana, again === s.first());
console.log("owner", apple.owner === s, banana.owner === s);
apple.value = 10;
console.log("alias", s.get("apple")!.value, s.total());
s.rename(apple, "apricot");
console.log(apple.name, describe(apple), s.get("apricot") === apple);
const missing = s.get("cherry");
console.log("missing", missing === undefined);
const veg = new Store("veg");
veg.add("kale", 1);
console.log(veg.name, veg.size, veg instanceof Store, s instanceof kit.Store);
console.log(veg.first() !== null, new Store("empty").first() === null);
const p = addPoints(makePoint(1, 2), { x: 3, y: 4 });
console.log(p.x, p.y);
p.x = 100;
console.log(p.x + p.y);
// JavaScript reads `tricky.method` before it evaluates `+swapper`, whose
// valueOf replaces the method: the call must reach the original.
class Swapper {
target: Tricky;
constructor(target: Tricky) {
this.target = target;
}
valueOf(): number {
this.target.method = this.target.other;
return 1;
}
}
const tricky = makeTricky();
const swapper = new Swapper(tricky);
console.log("unary", tricky.method(+swapper), tricky.method(1));
console.log(ping(), ping(), kit.ping());
try {
s.add("", 1);
} catch (e) {
console.log("caught", (e as Error).message);
}
function heaviest(store: Store, names: string[]): string {
let best = "";
let max = -Infinity;
for (const n of names) {
const e = store.get(n);
if (e !== undefined && e.value > max) {
max = e.value;
best = e.name;
}
}
return best;
}
console.log("heaviest", heaviest(s, ["apricot", "banana", "cherry"]));
// Native web streams and Node islands in one program select the async
// runtime build that defines both (SCR_NATIVE_REACTIONS and
// SCR_NODE_ISLANDS); without it the hosted-loop symbols are undefined.
const stream = new ReadableStream({
start(controller) {
controller.close();
},
});
console.log("stream", stream instanceof ReadableStream, stream.locked);
@@ -0,0 +1,31 @@
export interface Entry {
name: string;
value: number;
readonly owner: Store;
}
export interface Point {
x: number;
y: number;
}
export declare class Store {
constructor(name: string);
readonly name: string;
readonly size: number;
add(name: string, value: number): Entry;
get(name: string): Entry | undefined;
total(): number;
rename(entry: Entry, name: string): void;
first(): Entry | null;
}
export declare const VERSION: string;
export declare function createStore(name: string): Store;
export declare function describe(entry: Entry): string;
export declare function makePoint(x: number, y: number): Point;
export declare function addPoints(a: Point, b: Point): Point;
export declare let loads: number;
export declare function ping(): number;
export interface Tricky {
method(value: number): string;
other(): string;
}
export declare function makeTricky(): Tricky;
+26
View File
@@ -0,0 +1,26 @@
"use strict";
// A small object API whose loader picks its parts with a computed
// require() specifier: scriptc cannot compile that natively (R1.2), so the
// package runs in a Node island. Its objects keep their identity across
// the boundary.
const { createRequire } = require("node:module");
const load = createRequire(__filename);
const parts = ["./lib/format", "./lib/store"].map((p) => load(p));
const [format, store] = parts;
exports.VERSION = "islandkit " + require("./package.json").version;
exports.Store = store.Store;
exports.createStore = (name) => new store.Store(name);
exports.describe = format.describe;
exports.makePoint = (x, y) => ({ x, y });
exports.addPoints = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
exports.loads = 0;
exports.ping = () => ++exports.loads;
exports.makeTricky = () => ({
method() {
return "original";
},
other() {
return "replaced";
},
});
@@ -0,0 +1,2 @@
"use strict";
exports.describe = (entry) => `${entry.name}=${entry.value} in ${entry.owner.name}`;
@@ -0,0 +1,33 @@
"use strict";
class Store {
constructor(name) {
this.name = name;
this.entries = new Map();
}
get size() {
return this.entries.size;
}
add(name, value) {
if (name === "") throw new TypeError("entry names must not be empty");
const entry = { name, value, owner: this };
this.entries.set(name, entry);
return entry;
}
get(name) {
return this.entries.get(name);
}
total() {
let sum = 0;
for (const e of this.entries.values()) sum += e.value;
return sum;
}
rename(entry, name) {
this.entries.delete(entry.name);
entry.name = name;
this.entries.set(name, entry);
}
first() {
return this.entries.values().next().value ?? null;
}
}
exports.Store = Store;
@@ -0,0 +1,6 @@
{
"name": "islandkit",
"version": "1.2.0",
"main": "index.js",
"types": "index.d.ts"
}
+27
View File
@@ -0,0 +1,27 @@
// @requires-libnode
// An ES module package scriptc refuses natively (it imports its parts
// with a computed specifier) runs in a Node island: named imports are live
// bindings, the default export and the namespace are the module's own, and
// a later import() answers the same module instance.
import greet, { count, bump, Counter, loadPart, shared, hit } from "esmisland";
import * as ns from "esmisland";
console.log(greet("island"), count);
bump();
bump();
console.log("live", count, ns.count);
const c = new Counter(5);
c.inc();
console.log(c.value, c instanceof Counter, c instanceof ns.Counter);
console.log(hit() === shared, shared.hits, ns.shared === shared);
async function main(): Promise<void> {
const label = await loadPart("a");
console.log(label);
const again = await import("esmisland");
console.log(again.count, again.shared === shared, again.Counter === Counter);
again.bump();
console.log("after", count);
}
void main();
+14
View File
@@ -0,0 +1,14 @@
export declare let count: number;
export declare function bump(): number;
export declare class Counter {
constructor(start: number);
value: number;
inc(): number;
}
export default function greet(name: string): string;
export declare function loadPart(name: string): Promise<string>;
export interface Shared {
hits: number;
}
export declare const shared: Shared;
export declare function hit(): Shared;
+29
View File
@@ -0,0 +1,29 @@
// An ES module whose parts load through import() with a computed
// specifier: scriptc cannot compile that natively (R1.2), so it runs in a
// Node island. Its live bindings, classes and shared objects keep their
// meaning across the boundary.
export let count = 0;
export function bump() {
count++;
return count;
}
export class Counter {
constructor(start) {
this.value = start;
}
inc() {
return ++this.value;
}
}
export default function greet(name) {
return `hello ${name}`;
}
export async function loadPart(name) {
const part = await import(`./parts/${name}.js`);
return part.label;
}
export const shared = { hits: 0 };
export function hit() {
shared.hits++;
return shared;
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "esmisland",
"version": "0.3.0",
"type": "module",
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
}
}
}
+1
View File
@@ -0,0 +1 @@
export const label = "part a";
@@ -0,0 +1,21 @@
// @requires-libnode
// tsconfig `paths` maps an island package to its installed directory (a
// monorepo pinning one copy): its import resolves to the package's
// declarations, which type the program, and the package loads in Node.
import { Paths, heavier, route, type Route } from "pathkit";
interface Table<T extends Route> {
title: string;
rows: T[];
}
function render<T extends Route>(table: Table<T>): string {
return `${table.title}: ${table.rows.map((r) => r.label()).join(", ")}`;
}
const routes: Route[] = [route("/a", 1), route("/b", 3), route("/c", 2)];
const table: Table<Route> = { title: "routes", rows: routes };
console.log(render(table));
const top = heavier(routes[0]!, routes[1]!);
console.log(top.rank, top.label(), top === routes[1], routes.length);
console.log(Paths.join("api", "v1"));
@@ -0,0 +1,10 @@
export interface Route {
readonly path: string;
readonly weight: number;
label(): string;
}
export type Weighted<T extends Route> = T & { readonly rank: number };
export declare function route(path: string, weight: number): Route;
export declare function heavier<T extends Route>(a: T, b: T): Weighted<T>;
import * as Paths from "./paths.mjs";
export { Paths };
@@ -0,0 +1,13 @@
// The loader requires its part with spread arguments, which scriptc does
// not compile natively: the package runs in a Node island.
import { createRequire } from "node:module";
const load = createRequire(import.meta.url);
const { Route } = load(...["./route.cjs"]);
export function route(path, weight) {
return new Route(path, weight);
}
export function heavier(a, b) {
return Object.assign(b.weight > a.weight ? b : a, { rank: 1 });
}
import * as Paths from "./paths.mjs";
export { Paths };
@@ -0,0 +1 @@
export declare function join(...parts: string[]): string;
@@ -0,0 +1,3 @@
export function join(...parts) {
return "/" + parts.join("/");
}
@@ -0,0 +1,11 @@
"use strict";
class Route {
constructor(path, weight) {
this.path = path;
this.weight = weight;
}
label() {
return this.path + "@" + this.weight;
}
}
exports.Route = Route;
@@ -0,0 +1,7 @@
{
"name": "pathkit",
"version": "2.0.0",
"type": "module",
"types": "./build/index.d.mts",
"exports": { ".": { "import": "./build/index.mjs", "default": "./build/index.mjs" } }
}
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"strict": true,
"paths": {
"pathkit": ["./node_modules/pathkit"]
}
}
}
+7
View File
@@ -0,0 +1,7 @@
// requireModule() of Node's built-in modules, named with and without the
// node: prefix.
import { requireModule } from "@scriptc/node";
const path = requireModule("path");
const os = requireModule("node:os");
console.log(path.join("a", "b"), typeof os.platform());
+7
View File
@@ -0,0 +1,7 @@
"use strict";
exports.make = (i) => ({ i, payload: new Array(16).fill(i), tag: `object ${i}` });
exports.buffer = (i) => Buffer.alloc(64, i & 0xff);
exports.fail = (i) => {
throw new Error(`failure ${i}`);
};
exports.later = async (i) => i;
+25
View File
@@ -0,0 +1,25 @@
// Island objects the program drops are released (scope-B NI-4, acyclic
// garbage): after many iterations that each take handles to fresh Node
// objects, buffers, errors and promises, the import table holds only what
// the program still references. node-island-handles.test.ts reads the
// count SCRIPTC_NODE_ISLANDS_TRACE=handles prints at exit.
import { requireModule } from "@scriptc/node";
const lib = requireModule("./handles-leak.cjs");
const n = Number(process.argv[2] ?? "20000");
let sum = 0;
for (let i = 0; i < n; i++) {
const o = lib.make(i);
sum += o.i + o.payload.length;
const b = lib.buffer(i);
sum += b[0];
try {
lib.fail(i);
} catch (e) {
sum += e.message.length;
}
}
let awaited = 0;
for (let i = 0; i < 1000; i++) awaited += await lib.later(i);
const kept = lib.make(-1);
console.log("sum", sum, awaited, kept.tag);
+26
View File
@@ -0,0 +1,26 @@
// JavaScript's evaluation order on island objects: a method call reads the
// method before its arguments run, and an assignment evaluates the receiver
// and the key before the value.
import { make, type Thing } from "orderkit";
const o = make();
// The argument replaces the method; the call still runs the one it read.
console.log((o.m as (...args: unknown[]) => string)((o.m = 0), 1));
const trace: string[] = [];
const target = make();
function receiver(): Thing {
trace.push("receiver");
return target;
}
function key(): string {
trace.push("key");
return "k";
}
function value(): number {
trace.push("value");
return 1;
}
receiver()[key()] = value();
receiver().p = value();
console.log(trace.join(","), target["k"], target["p"]);
+5
View File
@@ -0,0 +1,5 @@
export declare class Thing {
m: ((...args: unknown[]) => string) | number;
[key: string]: unknown;
}
export declare function make(): Thing;
+6
View File
@@ -0,0 +1,6 @@
"use strict";
// Loads its part through a computed specifier when it loads, so it runs in
// a Node island.
const { createRequire } = require("node:module");
const parts = [createRequire(__filename)(["./thing"][0])];
exports.make = () => new parts[0].Thing();
+1
View File
@@ -0,0 +1 @@
{ "name": "orderkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
+7
View File
@@ -0,0 +1,7 @@
"use strict";
class Thing {
m() {
return "original";
}
}
exports.Thing = Thing;
+7
View File
@@ -0,0 +1,7 @@
// Re-exports from an island package that tsconfig `paths` maps to its
// installed directory: the type-only one is erased, the value one reports
// the island boundary, and the package's declarations never compile as a
// program module. The test builds it beside corpus 6839's pathkit package.
export type { Route } from "pathkit";
export { route } from "pathkit";
console.log("loaded");
@@ -0,0 +1 @@
{ "plain": 2000, "san": 30000 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 30000 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 30000 }
@@ -0,0 +1 @@
{ "plain": 2000, "san": 30000 }
@@ -0,0 +1 @@
{ "plain": 3300, "san": 30000 }
+217
View File
@@ -0,0 +1,217 @@
/* Island object handles (scope-B NI-4) where a differential corpus program
* cannot show it: handles to Node objects the program dropped are released,
* so the import table does not grow with the work a program does (acyclic
* garbage; cycles across both heaps wait for NI-8). The corpus (6812-6815)
* pins handle semantics against Node. An island package ships with the
* program, so the executable runs without the tree it was built from.
* Needs a libnode pack in SCRIPTC_LIBNODE (libnode.ts). */
import { execFile } from "node:child_process";
import { cpSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
import { libnodePack } from "./libnode.js";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island-handles");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const skip = libnodePack() === null;
describe.skipIf(skip)("Node island handles", () => {
test("handles the program dropped are released", async () => {
mkdirSync(outDir, { recursive: true });
const result = await compile(join(repoRoot, "tests/fixtures/node-island/handles-leak.mjs"), {
outPath: join(outDir, "handles-leak"),
outDir,
sanitize,
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const env = { ...process.env, SCRIPTC_NODE_ISLANDS_TRACE: "handles" };
const counts: number[] = [];
for (const n of ["2000", "20000"]) {
const { stdout, stderr } = await execFileAsync(result.binaryPath, [n], {
env,
encoding: "utf8",
});
expect(stdout).toMatch(/^sum \d+ 499500 object -1\n$/);
const live = /\[node-islands\] live handles (\d+)/.exec(stderr);
expect(live).not.toBeNull();
counts.push(Number(live![1]));
}
// The module and the kept object (with util.inspect, at most a few
// more), independent of the number of iterations.
expect(counts[0]).toBe(counts[1]);
expect(counts[1]).toBeLessThan(8);
}, 300_000);
async function runFixture(name: string, entry: string): Promise<string> {
const dir = join(outDir, name);
mkdirSync(dir, { recursive: true });
const result = await compile(join(repoRoot, "tests/fixtures/node-island", entry), {
outPath: join(dir, name),
outDir: dir,
sanitize,
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const env = { ...process.env, XDG_CACHE_HOME: join(dir, "cache") };
return (await execFileAsync(result.binaryPath, [], { env, encoding: "utf8" })).stdout;
}
test("island member calls and assignments evaluate in JavaScript's order", async () => {
expect(await runFixture("order", "order/main.ts")).toBe(
"original\nreceiver,key,value,receiver,value 1 1\n",
);
}, 300_000);
test("requireModule() loads Node's built-in modules", async () => {
expect(await runFixture("builtins", "builtins.ts")).toBe("a/b string\n");
}, 300_000);
/** Runs a binary that refuses at run time: its stderr. */
async function refusal(binary: string): Promise<string> {
const run = await execFileAsync(binary, [], { encoding: "utf8" }).catch(
(e: { stderr?: string }) => e,
);
return String((run as { stderr?: string }).stderr ?? "");
}
test("a package marked native never runs in a Node island", async () => {
// esmbridge's native build has deferred refusals (its CommonJS exports
// object read as a value, an Object.keys form), so by default it runs in
// a Node island; marked native, it compiles natively and refuses at run
// time.
const dir = join(outDir, "native-policy");
mkdirSync(dir, { recursive: true });
const entry = join(repoRoot, "tests/fixtures/npm/cases/cjs-esm-named/main.ts");
const islanded = await compile(entry, { outPath: join(dir, "main"), outDir: dir, sanitize });
// The SC8001 notes that name esmbridge.
const islandNotes = (r: typeof islanded): string[] =>
r.ok
? (r.warnings ?? [])
.filter((d) => d.code === "SC8001" && d.message.includes("'esmbridge@"))
.map((d) => d.message)
: [];
expect(islanded.ok).toBe(true);
expect(islandNotes(islanded)).toHaveLength(1);
const marked = await compile(entry, {
outPath: join(dir, "marked"),
outDir: dir,
sanitize,
nodeIslands: { native: ["esmbridge"] },
});
expect(marked.ok).toBe(true);
expect(islandNotes(marked)).toEqual([]);
if (marked.ok)
expect(await refusal(marked.binaryPath)).toMatch(/\[SC\d{4} at [^\]]*\/esmbridge\//);
}, 300_000);
/** The SC8001 notes of a build that name `pkg`. */
async function islandNotesOf(entry: string, name: string, pkg: string): Promise<string[]> {
const dir = join(outDir, name);
mkdirSync(dir, { recursive: true });
const result = await compile(join(repoRoot, entry), {
outPath: join(dir, name),
outDir: dir,
sanitize,
});
expect(result.ok, name).toBe(true);
if (!result.ok) return [];
return (result.warnings ?? [])
.filter((d) => d.code === "SC8001" && d.message.includes(`'${pkg}@`))
.map((d) => d.message);
}
test("a package whose fences may never run stays native", async () => {
// jose's run-time fences are inside function bodies, on paths a program
// may never call: only fences that surely fire make a Node island.
expect(
await islandNotesOf("tests/corpus/6331-jose-root-entry/main.ts", "jose-native", "jose"),
).toEqual([]);
}, 300_000);
test("a fence in module code makes a Node island, with the ES modules importing it", async () => {
// cjszoo's module code sets its exports object's prototype (R2.2), so
// its fence fires when it loads; esmbridge's ES module imports names
// from it, which only Node's CommonJS facade gives as Node does.
const entry = "tests/fixtures/npm/cases/cjs-esm-named/main.ts";
const zoo = await islandNotesOf(entry, "fence-sure", "cjszoo");
expect(zoo).toHaveLength(1);
expect(zoo[0]).toContain("R2.2");
const bridge = await islandNotesOf(entry, "fence-sure", "esmbridge");
expect(bridge).toEqual([
"package 'esmbridge@1.0.0' runs in a Node island: packaging: an ES module of the package imports the CommonJS island package 'cjszoo'",
]);
}, 300_000);
test("without islands, a package's deferred refusals refuse at run time", async () => {
// With --node-islands=off, packages whose native builds have deferred
// refusals compile natively and report them when they run: orderkit's
// createRequire call with a computed specifier (SC2020), and the first
// one cjs-esm-named reaches in its packages (esmbridge, cjszoo).
const cases = [
["order-off", "tests/fixtures/node-island/order/main.ts", /\[SC2020 at [^\]]*\/orderkit\//],
[
"esm-named-off",
"tests/fixtures/npm/cases/cjs-esm-named/main.ts",
/\[SC\d{4} at [^\]]*\/node_modules\/(?:esmbridge|cjszoo)\//,
],
] as const;
for (const [name, entry, code] of cases) {
const dir = join(outDir, name);
mkdirSync(dir, { recursive: true });
const result = await compile(join(repoRoot, entry), {
outPath: join(dir, name),
outDir: dir,
sanitize,
nodeIslands: { mode: "off" },
});
expect(result.ok, name).toBe(true);
if (!result.ok) continue;
expect((result.warnings ?? []).map((d) => d.code)).not.toContain("SC8001");
expect(await refusal(result.binaryPath), name).toMatch(code);
}
}, 300_000);
test("an island package ships with the program", async () => {
const root = join(outDir, "ships");
const src = join(root, "src");
rmSync(root, { recursive: true, force: true });
cpSync(join(repoRoot, "tests/corpus/6816-node-island-objects"), src, { recursive: true });
const env = { ...process.env, XDG_CACHE_HOME: join(root, "cache") };
const built: Record<string, string> = {};
for (const libnode of ["inside", "beside"]) {
const dir = join(root, libnode);
mkdirSync(dir, { recursive: true });
const result = await compile(join(src, "main.ts"), {
outPath: join(dir, "main"),
outDir: dir,
sanitize,
...(libnode === "beside" ? { libnode } : {}),
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
built[libnode] = result.binaryPath;
}
const before = await execFileAsync(built["inside"]!, [], { env, encoding: "utf8" });
expect(before.stdout).toMatch(/^islandkit \d/);
// The executable names its island package by its path in the shipped
// tree, never by the directory it was built from.
expect(readFileSync(built["inside"]!).toString("latin1").includes(src)).toBe(false);
// Beside the executable, the tree holds the package's files.
const index = readFileSync(join(src, "node_modules/islandkit/index.js"), "utf8");
const modules = join(root, "beside/main.libnode/modules");
const files = readdirSync(modules, { recursive: true, encoding: "utf8" });
expect(
files.some((f) => f.endsWith("index.js") && readFileSync(join(modules, f), "utf8") === index),
).toBe(true);
renameSync(src, join(root, "moved"));
for (const binary of Object.values(built)) {
const after = await execFileAsync(binary, [], { env, encoding: "utf8" });
expect(after.stdout).toBe(before.stdout);
}
}, 300_000);
});
+20 -1
View File
@@ -308,7 +308,7 @@ describe.skipIf(skip)("Node islands in-process", () => {
});
expect(result.code).toBe(1);
expect(result.stderr).toBe(
`scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 4)\n`,
`scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 5)\n`,
);
});
@@ -354,3 +354,22 @@ test("recursive JSON types cross into Node", async () => {
const ir = JSON.stringify(deserializeModule(readFileSync(outPath, "utf8")));
expect(ir).toContain("nodeIsland.callSync");
}, 300_000);
// Needs no libnode pack: the build fails before linking.
test("re-exporting a path-mapped island package reports the island boundary", async () => {
const dir = join(outDir, "path-mapped-reexport");
rmSync(dir, { recursive: true, force: true });
const corpus = join(repoRoot, "tests/corpus/6839-node-island-path-mapped");
cpSync(join(corpus, "node_modules"), join(dir, "node_modules"), { recursive: true });
copyFileSync(join(corpus, "tsconfig.json"), join(dir, "tsconfig.json"));
copyFileSync(
join(repoRoot, "tests/fixtures/node-island/path-mapped-reexport.ts"),
join(dir, "main.ts"),
);
const result = await compile(join(dir, "main.ts"), { outPath: join(dir, "main"), outDir: dir });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.diagnostics.map((d) => `${d.code} ${d.loc.file.slice(dir.length + 1)}`)).toEqual([
"SC8010 main.ts",
]);
}, 300_000);
+11 -4
View File
@@ -15,6 +15,7 @@ import { promisify } from "node:util";
import { beforeEach, describe, expect, test } from "vitest";
import { analyze, compile } from "@scriptc/compiler";
import { skipUnownedTest } from "./shard.js";
import { libnodePack } from "./libnode.js";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
@@ -632,7 +633,10 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => {
}
});
test("a word segmenter in a package constructs, and its segment() throws SC2020", async () => {
// The package builds its segmenter at module load, a run-time fence that
// surely fires, so the package runs in its Node island and segments as
// Node does.
test("a word segmenter a package builds at load runs in its Node island", async () => {
const entry = join(pilotRoot, "word-segments-cli.ts");
const { coverage } = analyze(entry, { npmStatic: "auto" });
expect(coverage.npmStatic).toEqual([{ package: "word-segments", status: "static" }]);
@@ -645,12 +649,15 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => {
outDir,
outPath: join(outDir, "program"),
});
if (libnodePack() === null) {
expect(result.ok, "no libnode pack: the compile stops").toBe(false);
if (!result.ok) expect(result.diagnostics.map((d) => d.code)).toContain("SC8005");
return;
}
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const nativeRes = await runBinary(result.binaryPath, []);
expect(nativeRes.stdout.toString()).toMatch(
/^SC2020: Intl\.Segmenter segmentation with granularity 'word' is not supported yet \[SC2020 at .*index\.js:1\]\n$/,
);
expect(nativeRes.stdout.toString()).toBe("two| |words\n");
expect(nativeRes.exitCode).toBe(0);
});
+43 -16
View File
@@ -9,7 +9,10 @@
* (a refusal deferred to the operation that reached it), for every argv
* list or for the listed `argvs`, while the other lists match Node;
* - divergence: it runs but does not match Node yet. These run as expected
* failures, so a fix that makes one match fails here until KNOWN drops it.
* failures, so a fix that makes one match fails here until KNOWN drops it;
* - island: a package it imports runs in a Node island (scope-B NI-4): with
* a libnode pack (SCRIPTC_LIBNODE) it matches Node; without one the build
* stops with SC8005 (no pack to load Node from).
*
* The fixture node_modules are committed test data. Binaries compile the
* package sources at build time and read nothing from node_modules at run
@@ -24,6 +27,7 @@ import { describe, expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
import { npmCases, type NpmCase } from "./npm-cases.js";
import { shardSelect, shardSuffix } from "./shard.js";
import { libnodePack } from "./libnode.js";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
@@ -31,22 +35,26 @@ const fixturesRoot = join(repoRoot, "tests/fixtures");
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
type Known = { build: string } | { run: string; argvs?: string[][] } | { divergence: string };
type Known =
| { build: string }
| { run: string; argvs?: string[][] }
| { divergence: string }
| { island: true };
const KNOWN: Record<string, Known> = {
"assert-shims": { run: "SC1043" },
"buffer-shims": { run: "SC2020" },
"bytes-exit": { build: "SC2004" },
"cjs-esm-named": { run: "SC1090" },
"cjs-lexer-aligned": { run: "SC1090" },
"cjs-esm-named": { island: true },
"cjs-lexer-aligned": { island: true },
"commander-calc": { run: "SC1090", argvs: [["--help"]] },
"commander-calc-async": { run: "SC1090", argvs: [["--help"]] },
"create-require": { run: "SC1090" },
"dotfolder": { run: "SC1090" },
"dual-package-kinds": { build: "SC2013" },
"dynamic-import": { build: "SC2012" },
"dotfolder": { island: true },
"dual-package-kinds": { island: true },
"dynamic-import": { island: true },
"dynamic-import-local": { build: "SC2012" },
"esbuild-require": { build: "SC2013" },
"esbuild-require": { island: true },
"esm-missing-default": { build: "SC1090" },
"esm-missing-export": { build: "SC1090" },
"fs-shims": { run: "SC1101" },
@@ -58,19 +66,25 @@ const KNOWN: Record<string, Known> = {
},
// require() inside a function.
"island-web-plumbing": { run: "SC1090" },
"lazy-traps": { build: "SC2013" },
"lazy-traps": {
divergence:
"an island module loaded by path lists itself twice in MODULE_NOT_FOUND require stacks",
},
"misc-shims": { run: "SC1090" },
"namespace": { build: "SC1013" },
namespace: { island: true },
"node24-async-events": { run: "SC2020" },
"node24-commonjs-metadata": { run: "SC2020" },
"node24-commonjs-metadata": {
divergence:
"a Node island (its require.cache use needs the runtime) loads its entry with itself as the CJS parent, so require stacks list it twice",
},
"node24-crypto-kdf": { run: "SC2020" },
"node24-deep-equality": { run: "SC2020" },
"node24-diagnostics-async": { run: "SC1090" },
"node24-diagnostics-tracing": { run: "SC1090" },
"node24-env": { run: "SC1090" },
"node24-esm-metadata": { run: "SC1090" },
"node24-esm-metadata": { island: true },
"node24-event-emitter": { run: "SC2020" },
"node24-file-url-conversion": { build: "SC1090" },
"node24-file-url-conversion": { island: true },
"node24-fs-descriptors": { run: "SC2020" },
"node24-mime": { run: "SC2020" },
"node24-mime-errors": { run: "SC2020" },
@@ -87,16 +101,16 @@ const KNOWN: Record<string, Known> = {
"node24-url-mutation": { run: "SC1090" },
"node24-util-adapters": { run: "Object.create with this value is not supported yet" },
"node24-util-types": { run: "SC1090" },
"package-imports": { build: "SC2013" },
"package-imports": { island: true },
"promise-bridge": { build: "SC1090" },
"self-name": { build: "SC2013" },
"stream-shims": { run: "SC2020" },
"util-shims": { run: "SC2020" },
// The package redeclares the var it exported: Node copies the first value.
"var-export-redeclared": { run: "SC1090" },
"var-export-redeclared": { island: true },
// `class Upper extends TransformStream`: extending a builtin class from JavaScript.
"web-streams": { run: "SC1090" },
"workspace-copied": { build: "SC2013" },
"workspace-copied": { island: true },
"workspace-unclean": { build: "SC2013" },
};
@@ -222,6 +236,19 @@ describe(`npm native (${cases.length} programs${sanitize ? ", sanitized" : ""}${
}, 120_000);
continue;
}
if (known !== undefined && "island" in known) {
test(`${c.name} (Node island)`, async () => {
const built = await build(c.entry);
if (libnodePack() === null) {
expect(built.ok, "no libnode pack: the compile stops").toBe(false);
if (!built.ok) expect(built.codes, built.text).toContain("SC8005");
return;
}
if (!built.ok) throw new Error(`npm fixture failed to compile:\n${built.text}`);
await expectMatchesNode(c, built.binary);
}, 120_000);
continue;
}
test(
c.name,
async () => {

Some files were not shown because too many files have changed in this diff Show More