diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02479a922..f2646e945 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1056,7 +1056,7 @@ export const LIB_FN_SYMS: Record = { "nodeIsland.defer": "scr_node_island_defer", "nodeIsland.import": "scr_ni_import", "nodeIsland.get": "scr_node_island_get", - "nodeIsland.getKey": "scr_ni_get", + "nodeIsland.getKey": "scr_node_island_get_key", "nodeIsland.set": "scr_node_island_set", "nodeIsland.setKey": "scr_node_island_set_key", "nodeIsland.invoke": "scr_node_island_invoke", @@ -1064,6 +1064,7 @@ export const LIB_FN_SYMS: Record = { "nodeIsland.applyTo": "scr_node_island_apply_to", "nodeIsland.construct": "scr_node_island_construct", "nodeIsland.instanceof": "scr_node_island_instanceof", + "nodeIsland.isArray": "scr_ni_is_array", "nodeIsland.classMethods": "scr_node_island_class_methods", "nodeIsland.subclass": "scr_node_island_subclass", "nodeIsland.superConstruct": "scr_node_island_super_construct", diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 3515bc387..01efc7e34 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -186,7 +186,10 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ ), unit((u) => u.opts.assert || u.regex || u.opts.symbol, "scr_assert.c"), unit((u) => u.opts.inspect, "scr_inspect.c", "scr_console_native.c"), - unit((u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch, "scr_dyn_invoke.c"), + unit( + (u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch || u.opts.nodeIsland === true, + "scr_dyn_invoke.c", + ), // Buffer.prototype methods called on checked-dynamic values. unit((u) => u.opts.dynBuffer === true && u.opts.dynInvoke, "scr_bytes_dyn.c"), // Builtin functions read as values in JavaScript dependencies. diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index 1e35903cd..f3d7681d5 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -117,7 +117,9 @@ export const DIAGNOSTIC_CODES = { SC8010: "value crossing a Node island boundary in a form without an exact copy", SC8011: "native class extending a Node island class outside the supported forms", SC8020: "stateful module duplicated across the Node island boundary", + SC8021: "Node island evaluation notes (deferred or at startup)", SC8022: "island code extends a class of a compiled module", + SC8023: "Node island build failed; built natively with runtime fences", SC8030: "type patch: no range covers the installed copy", SC8031: "type patch: selector matches nothing", SC8032: "type patch: declaration already has the type", @@ -156,8 +158,6 @@ export const RETIRED_DIAGNOSTIC_CODES = { * abandoned, delete them here. */ export const RESERVED_DIAGNOSTIC_CODES = { SC8012: "Node islands (ni/* branches)", - SC8021: "Node islands (ni/* branches)", - SC8023: "Node islands (ni/* branches)", } as const satisfies Record<`SC${number}`, string>; export type DiagnosticCode = keyof typeof DIAGNOSTIC_CODES; diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 0cabb6ddb..a9ae4c627 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -442,6 +442,23 @@ export function nodeIslandBoundaryDiag(what: string, loc: SrcLoc): ScrDiagnostic }; } +/** SC8023 — the build that ran fenced packages as Node islands failed, so + * the program was built natively with those runtime fences (each throws + * when reached). A warning on the native build, with the island build's + * first error. */ +export function nodeIslandRetryFailedDiag( + packages: readonly string[], + first: ScrDiagnostic, + loc: SrcLoc, +): ScrDiagnostic { + return { + code: "SC8023", + message: `running ${packages.map((p) => `'${p}'`).join(", ")} in Node islands failed to compile (${first.code} at ${first.loc.file}:${first.loc.start}: ${first.message}); built natively instead, where their runtime fences throw when reached`, + loc, + hint: "fix the island build's error, or mark the packages with --node-island= to see every error", + }; +} + /** 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 diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index 144e3f860..fdc09bae9 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -234,6 +234,19 @@ export interface NodeIslandsEnvelope { unknownPackages: string[]; } +/** Node island notices of a build that succeeds (scope-B): SC8001 and + * SC8021 notes (a package runs in a Node island, an island evaluates at + * boot) and the SC8020 warning (a stateful module duplicated across the + * boundary). The envelope reports all three as warnings. */ +export function isNodeIslandNoticeCode(code: string): boolean { + return code === "SC8001" || code === "SC8020" || code === "SC8021" || code === "SC8023"; +} + +/** SC8001 and SC8021 are notes, not warnings. */ +export function isNodeIslandNoteCode(code: string): boolean { + return code === "SC8001" || code === "SC8021"; +} + /** The divergence band: compiles, but the program can behave differently * from Node. Warnings, never build failures. */ export function isDivergenceCode(code: string): boolean { @@ -370,6 +383,7 @@ export function locateDiagnostic( severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) || + isNodeIslandNoticeCode(diag.code) || diag.code === "SC8004" || isTypePatchCode(diag.code) ? "warning" diff --git a/packages/compiler/src/diagnostics/render.ts b/packages/compiler/src/diagnostics/render.ts index a370eb3b1..d73a9ce9d 100644 --- a/packages/compiler/src/diagnostics/render.ts +++ b/packages/compiler/src/diagnostics/render.ts @@ -4,6 +4,7 @@ * longer than FRAME_MAX_WIDTH (minified sources) shows a window of that * width around the diagnostic's column, with "…" where text is cut. */ +import { isNodeIslandNoteCode, isNodeIslandNoticeCode } from "./envelope.js"; import { isTypePatchCode, type ScrDiagnostic } from "./diagnostic.js"; export interface RenderOptions { @@ -102,14 +103,21 @@ export function renderDiagnostic( frame.push( c(DIM, ` ${" ".repeat(gutterWidth)} | `) + " ".repeat(caretCol) + - c(isWarningCode(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)), + c( + isWarningCode(diag.code) || isNodeIslandNoticeCode(diag.code) ? YELLOW : RED, + "^" + "~".repeat(spanOnLine - 1), + ), ); emitLine(lineNum + 1); } - const warning = isWarningCode(diag.code); + // Node island notices of a successful build: SC8020 warns, SC8001 and + // SC8021 are notes. + const note = isNodeIslandNoteCode(diag.code); + const warning = isWarningCode(diag.code) || diag.code === "SC8020" || diag.code === "SC8023"; + const severity = note ? c(CYAN, "note") : warning ? c(YELLOW, "warning") : c(RED, "error"); out.push( - `${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${warning ? c(YELLOW, "warning") : c(RED, "error")} ${c(BOLD, diag.code)}: ${diag.message}`, + `${c(BOLD, `${diag.loc.file}:${lineNum}:${colNum}`)} - ${severity} ${c(BOLD, diag.code)}: ${diag.message}`, ); if (frame.length) { out.push("", ...frame); @@ -191,9 +199,11 @@ export function renderWarnings( sourceTextByFile: Map, opts: RenderOptions = {}, ): string { - const n = diags.length; + const n = diags.filter((d) => !isNodeIslandNoteCode(d.code)).length; + const rendered = renderDiagnostics([...diags], sourceTextByFile, opts); + if (n === 0) return rendered; return ( - renderDiagnostics([...diags], sourceTextByFile, opts) + + rendered + `\n\n${n} warning${n === 1 ? "" : "s"}: the program compiled, but ${n === 1 ? "this site" : "these sites"} can behave differently from Node.` ); } diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index f2b955f85..2629a65d5 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -4,6 +4,7 @@ import { frontendDisposed } from "../frontend/dispose-hook.js"; import { checkerPanicDiag, nodeIslandNoteDiag, + nodeIslandRetryFailedDiag, npmStaticRefusalDiag, iceDiag, isCheckerPanic, @@ -70,9 +71,10 @@ export function prepareExecutableModule( timing("frontend-island-reload"); } let lowered: LowerResult; - let fenceEdges: readonly PackageEdge[] | undefined; let sourceTexts: Map; let disposed = false; + let fenceGrown: Map | null = null; + const fenceSure = new Set(); // The frontend (and its tsgo server) is released as soon as lowering // ends — clang and the link never hold it open. try { @@ -165,6 +167,31 @@ export function prepareExecutableModule( retryFences, ); } + // A failed program whose compiled packages need the runtime or a + // mutable object model (NI-2's report): try them as islands. If that + // build fails too, its failure is the one reported. This build typed + // those packages from their JavaScript, where the types they only + // declare (interfaces, aliases such as `Static`) are `any`, so its + // diagnostics describe a program the island build does not compile: + // there the packages' declarations type the user code. + const marked = retryFences + ? fenceIslands(entryPath, opts, fe, lowered, lowerIslands, false) + : null; + if (marked !== null) { + fe.dispose(); + frontendDisposed(); + disposed = true; + return prepareExecutableModule( + entryPath, + opts, + ffi, + buildPlatform, + createFrontend, + timing, + marked, + false, + ); + } return fail(lowered.diagnostics); } @@ -187,10 +214,6 @@ 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); @@ -198,6 +221,8 @@ export function prepareExecutableModule( if (checked.diagnostics.length > 0) return fail(checked.diagnostics); if (checked.contracts) lowered.module.contracts = checked.contracts; } + if (retryFences) + fenceGrown = fenceIslands(entryPath, opts, fe, lowered, lowerIslands, true, fenceSure); } finally { if (!disposed) { fe.dispose(); @@ -218,17 +243,21 @@ export function prepareExecutableModule( ...(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) { + // Packages compiled with runtime fences that need the JavaScript + // runtime or a mutable object model (fenceIslands): build again with + // them as islands, and keep this build when that one cannot compile. + // If that build fails, retry with only the packages whose fences fire + // for sure (module-init or runtime fences); if that fails too, the native + // build carries an SC8023 warning with the island build's first error, + // never a silent fallback. + if (fenceGrown !== null) { + const attempts = [fenceGrown]; + const sure = new Map( + [...fenceGrown].filter(([name]) => lowerIslands.has(name) || fenceSure.has(name)), + ); + if (sure.size > lowerIslands.size && sure.size < fenceGrown.size) attempts.push(sure); + let firstError: ScrDiagnostic | null = null; + for (const islands of attempts) { const islanded = prepareExecutableModule( entryPath, opts, @@ -236,21 +265,22 @@ export function prepareExecutableModule( buildPlatform, createFrontend, timing, - grown, + islands, false, ); if (islanded.ok) return islanded; + firstError ??= islanded.diagnostics[0] ?? null; + } + if (firstError !== null) { + const added = [...fenceGrown.keys()].filter((name) => !lowerIslands.has(name)); + prepared.warnings.push( + nodeIslandRetryFailedDiag(added, firstError, { file: entryPath, start: 0, end: 0 }), + ); } } 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 @@ -473,3 +503,109 @@ function preflightIslands( } return grown.size === known.size ? null : grown; } + +/** Packages compiled natively with runtime fences (refusals deferred to the + * operation that reaches them) that should run in islands instead: those + * with a fence that surely fires (one in top-level module code, which runs + * when the module evaluates) or that itself needs the runtime (R1). Other + * fences stay deferred refusals until decision 8 (D6) retires them, so + * typed packages like jose stay native. With `requireFence` false (after a + * failed build) the package's island class by NI-2's report decides + * instead: runtime (R1) or object-model (R2). `--node-islands=auto` only; + * null for none. */ +function fenceIslands( + entryPath: string, + opts: CompileRequestOptions, + fe: ReturnType, + lowered: LowerResult, + known: ReadonlyMap, + requireFence = true, + sure?: Set, +): Map | null { + if (fe.nodeIslandScan === undefined) return null; + if (requireFence && lowered.runtimeFences.length === 0) return null; + const policy = nodeIslandPolicy(readProjectConfig(entryPath), opts.nodeIslands); + if (policy.mode !== "auto" || known.size > 32) return null; + // An explicit --npm-static request keeps its package native. + const explicit = new Set( + Array.isArray(opts.npmStatic) ? opts.npmStatic.map((n) => n.replace(/\.\.\.$/, "")) : [], + ); + const compiled = new Set( + fe.npmStatic + .filter((e) => e.status === "static" && !explicit.has(e.package)) + .map((e) => e.package), + ); + const fenced = new Map(); + for (const d of lowered.runtimeFences) { + const at = packageDirOf(d.loc.file); + if (at === null || !compiled.has(at.name) || known.has(at.name)) continue; + if (policy.forcedNative.has(at.name) || policy.unloaded.has(at.name)) continue; + // A fence in top-level module code throws when the module evaluates: + // it counts like one that needs the runtime. + const runtime = d.moduleInit === true || classifyDiagnostic(d, "fence").class === "runtime"; + fenced.set(at.name, (fenced.get(at.name) ?? false) || runtime); + if (runtime) sure?.add(at.name); + } + if (requireFence && fenced.size === 0) return null; + const scan = fe.nodeIslandScan({ userJs: false }); + const report = buildNodeIslandReport({ + entry: entryPath, + scan, + npm: fe.npmStatic, + diagnostics: lowered.diagnostics, + fences: lowered.runtimeFences, + policy, + }); + const grown = new Map(known); + for (const island of report.islands) { + const runtimeFence = requireFence + ? fenced.get(island.package) + : (fenced.get(island.package) ?? false); + if (runtimeFence === undefined || island.path === null) continue; + if (!compiled.has(island.package) || known.has(island.package)) continue; + if (policy.forcedNative.has(island.package) || policy.unloaded.has(island.package)) continue; + // With fences (requireFence), only fences that surely fire count: a + // gap fence inside a function body may never run (jose's typed paths + // stay native, DECISIONS.md 2026-10-21). After a failed build + // (!requireFence), the package's report class decides. + if (requireFence && !runtimeFence) continue; + if (!runtimeFence && island.class !== "runtime" && island.class !== "object-model") continue; + grown.set(island.package, { + name: island.package, + version: island.version, + dir: island.path, + class: island.class, + reason: island.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 = true; added;) { + added = false; + for (const edge of scan.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}'`, + }); + // It goes wherever that island goes, the sure-fence retry included. + if (known.has(name) || sure?.has(name) === true) sure?.add(at.name); + added = true; + } + } + return grown.size === known.size ? null : grown; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index de5bab8b7..fcdce59f0 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4919,6 +4919,9 @@ function lowerArrayIsArrayCall( // itself instead of folding on the extracted type (typeof's rule). if (arg.kind === "dynCheck" && arg.value.type.kind === "dyn") arg = arg.value; if (arg.type.kind === "dyn") { + // With Node islands the value may be an island array (scope-B NI-4). + if (lowerer.nodeIslands !== null) + return { kind: "libCall", fn: "nodeIsland.isArray", args: [arg], type: BOOL, loc }; return { kind: "dynTest", test: "array", value: arg, type: BOOL, loc }; } if (arg.type.kind === "union") { @@ -5142,11 +5145,22 @@ function lowerUnionToStringCall( access: ts.PropertyAccessExpression, ): IrExpr | null { if (call.questionDotToken || access.questionDotToken) return null; - if (access.name.text !== "toString" || call.arguments.nodes.length !== 0) return null; + if (access.name.text !== "toString") return null; + // `.toString("utf8")` on `string | Buffer` (a Buffer's default decode; + // String.prototype.toString ignores its arguments): the same helper. + const args = call.arguments.nodes; + const utf8Arg = + args.length === 1 && ts.isStringLiteralLike(args[0]!) && /^utf-?8$/i.test(args[0].text); + if (args.length !== 0 && !utf8Arg) return null; const recvT = lowerer.mapTypeOf(lowerer.typeOf(access.expression)); if (recvT?.kind !== "union") return null; if (!lowerer.isStdlibMember(access)) return null; const def = lowerer.unions.get(recvT.unionId); + if ( + utf8Arg && + !def?.arms.every((a) => a.kind === "string" || (a.kind === "bytes" && a.elem === "u8")) + ) + return null; const stringable = def?.arms.every( (a) => a.kind === "string" || diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 9e04cc246..3b2110290 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -3,7 +3,7 @@ import { isIslandSubclassDecl } from "../../node-islands/boundary.js"; 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 { collectIslandImport, collectIslandRequires } 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"; @@ -732,7 +732,10 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { } } } - for (const fp of parts) collectDynamicImports(lowerer, fp.sf); + for (const fp of parts) { + collectDynamicImports(lowerer, fp.sf); + collectIslandRequires(lowerer, fp.sf); + } } /** Literal dynamic-import collection: every diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 739918e12..46354dedc 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -413,10 +413,32 @@ export function collectIslandImport( // module evaluated. const clause = ts.isImportDeclaration(stmt) ? stmt.importClause : undefined; const named = clause?.namedBindings; + const snapshotNames: string[] = []; 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; + snapshotNames.push(el.propertyName?.text ?? el.name.text); + } + } + // A CommonJS namespace import's members are the same snapshot: the + // members the file reads (`ns.name`). + if (!binding.esm && named !== undefined && ts.isNamespaceImport(named)) { + const nsSymbol = lowerer.checker.getSymbolAtLocation(named.name); + ts.walkPreorder(sf, (node) => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + ts.isIdentifier(node.name) && + node.expression.text === named.name.text && + lowerer.checker.getSymbolAtLocation(node.expression) === nsSymbol && + !snapshotNames.includes(node.name.text) + ) + snapshotNames.push(node.name.text); + return undefined; + }); + } + { + for (const name of snapshotNames) { if (name === "default") continue; let g = binding.snapshots.get(name); if (g === undefined) { @@ -451,6 +473,7 @@ function islandGet(target: IrExpr, key: string, loc: SrcLoc): IrExpr { } type IslandBinding = + | { kind: "required"; module: IslandModuleBinding } | { kind: "namespace"; module: IslandModuleBinding } | { kind: "default"; module: IslandModuleBinding } | { kind: "named"; module: IslandModuleBinding; name: string }; @@ -487,7 +510,13 @@ function islandBindingOf(lowerer: Lowerer, ident: ts.Identifier): IslandBinding const name = decl.propertyName?.text ?? decl.name.text; binding = name === "default" ? "default" : { name }; } else { - return null; + // `const x = require("pkg")` (a CommonJS module of a native package + // requiring an island package): x is module.exports. + const spec = requireSpecOf(lowerer, decl); + if (spec === null) return null; + const path = islandModuleFile(islands, ts.getSourceFileOfNode(decl).fileName, spec, "require"); + const module = path === null ? undefined : lowerer.islandModules.get(path); + return module === undefined ? null : { kind: "required", module }; } const spec = moduleSpecifierOf(importDecl); if (spec === null || importDecl === undefined) return null; @@ -514,6 +543,8 @@ function islandBindingValue( ): IrExpr { const ns = varRef(b.module.global.id, DYN, loc); switch (b.kind) { + case "required": + return ns; case "namespace": if (!b.module.esm) boundaryFail(lowerer, "the namespace object of a CommonJS island module as a value", node); @@ -624,6 +655,8 @@ function copiedType(lowerer: Lowerer, t: IrType): boolean { case "bigint": case "nullT": case "undefinedT": + // Buffers and typed arrays share their memory with the V8 view. + case "bytes": return true; case "union": { const def = lowerer.unions.get(t.unionId); @@ -844,11 +877,20 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { // NI-5: the runtime exports dyn objects by reference and copies only // primitives, strings and (for now) arrays. return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); - case "refused": + case "refused": { + const type = lowerer.typeOf(node); + // A type parameter the instance binds to dyn (a generic function + // over island types, ``): its values are dyn + // values, and they cross the way `any` values do. + if ( + (type.flags & ts.TypeFlags.TypeParameter) !== 0 && + lowerer.typeParamResolver(type)?.kind === "dyn" + ) + return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN); // NI-5: closures, promises, records and class instances cross by // reference (scr_node_island_export.c): their dyn boxes keep the // native referent, and the closure's thunk checks its arguments. - if (exportedByReference(lowerer, lowerer.typeOf(node))) { + if (exportedByReference(lowerer, type)) { const lowered = lowerer.lowerExpr(node); const value = lowerer.coerceToExpected(lowered, DYN); return lowered.type.kind === "object" @@ -857,9 +899,10 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr { } return boundaryFail( lowerer, - `a '${lowerer.checker.typeToString(lowerer.typeOf(node))}' value (a native object would cross as a copy and lose its identity)`, + `a '${lowerer.checker.typeToString(type)}' value (a native object would cross as a copy and lose its identity)`, node, ); + } } } @@ -1041,6 +1084,11 @@ function freshLiteral( node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression, ): IrExpr { const loc = locOf(node); + // Spreads, methods, accessors and computed keys: the literal lowers as + // the program's own object, which crosses by reference (NI-5 exports); + // nothing else holds it, so that is exact. + if (!plainLiteral(node)) + return lowerer.coerceToExpected(lowerer.lowerExprExpecting(node, DYN), DYN); if (ts.isArrayLiteralExpression(node)) { const elems = node.elements.nodes.map((el) => { if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) @@ -1324,7 +1372,7 @@ export function lowerIslandExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr | if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) { if (!isForeignExpr(lowerer, callee.expression)) return null; lowerer.usesNodeIsland = true; - const target = islandReceiver(lowerer, callee.expression, callee.name.text, loc); + const target = islandReceiver(lowerer, callee.expression, callee.name.text, loc, true); if (target.direct !== undefined) { return fromIsland( lowerer, @@ -1603,12 +1651,14 @@ function islandCompoundAssign( /** 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. */ + * module.exports itself), answered directly; a call of a member keeps + * module.exports as its receiver, so it goes through the method call. */ function islandReceiver( lowerer: Lowerer, receiver: ts.Expression, member: string, loc: SrcLoc, + call = false, ): { value: IrExpr; direct?: IrExpr } { const inner = stripTypeWrappers(receiver); if (ts.isIdentifier(inner)) { @@ -1616,6 +1666,9 @@ function islandReceiver( 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 }; + const snapshot = b.module.snapshots.get(member); + if (snapshot !== undefined && !call) + return { value: exports, direct: varRef(snapshot.id, DYN, loc) }; return { value: exports }; } } @@ -1943,6 +1996,82 @@ export function lowerIslandSuperMethodCall( return fromIsland(lowerer, raw, call); } +/** A literal freshLiteral copies member by member: identifier or string + * keys with values, shorthand properties, array elements without spreads + * or holes. */ +function plainLiteral(node: ts.ObjectLiteralExpression | ts.ArrayLiteralExpression): boolean { + if (ts.isArrayLiteralExpression(node)) + return node.elements.nodes.every( + (el) => !ts.isSpreadElement(el) && !ts.isOmittedExpression(el), + ); + return node.properties.nodes.every( + (p) => + (ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) || + ts.isShorthandPropertyAssignment(p), + ); +} + +/** The specifier of `x = require("spec")` (a variable declaration binding + * an identifier to a literal require), or null. */ +function requireSpecOf(lowerer: Lowerer, decl: ts.Node): string | null { + if ( + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + decl.initializer === undefined + ) + return null; + // Only a const keeps the module: a let or var can be reassigned. + const list = decl.parent; + if (!ts.isVariableDeclarationList(list) || (list.flags & ts.NodeFlags.Const) === 0) return null; + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if ( + !ts.isCallExpression(init) || + !ts.isIdentifier(init.expression) || + init.expression.text !== "require" || + init.arguments.nodes.length !== 1 + ) + return null; + // The global require, not a function of the program named require. + if ( + lowerer.checker + .getSymbolAtLocation(init.expression) + ?.declarations.some((d) => !ts.getSourceFileOfNode(d).isDeclarationFile) + ) + return null; + const arg = init.arguments.nodes[0]!; + return ts.isStringLiteralLike(arg) ? arg.text : null; +} + +/** Top-level `const x = require("pkg")` statements of island packages in a + * compiled module (a native package requiring an island package): load the + * island module in the module's %init prelude (shortcut: before the body, + * not at the statement). */ +export function collectIslandRequires(lowerer: Lowerer, sf: ts.SourceFile): void { + const islands = lowerer.nodeIslands; + if (islands === null) return; + for (const stmt of sf.statements.nodes) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations.nodes) { + const spec = requireSpecOf(lowerer, decl); + if (spec === null) continue; + const path = islandModuleFile(islands, sf.fileName, spec, "require"); + if (path === null) continue; + lowerer.usesNodeIsland = true; + const loc = locOf(decl); + 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, + }); + } + } +} + /* `x instanceof Error` for values that may be island objects (NI-5c). */ const INTRINSIC_ERRORS = new Set([ "Error", diff --git a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts index 78d970d21..ea0cfb702 100644 --- a/packages/compiler/src/frontend/lowering/lower-shared-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-shared-modules.ts @@ -341,6 +341,11 @@ export function statefulReason(sf: ts.SourceFile): string | null { ) continue; if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) continue; // directives + // CommonJS export statements (`module.exports = f`, `exports.x = v`, + // `module.exports.x = v`) and expandos on the module's own functions + // (`f.sync = g`) declare the module's surface; they are not effects. + if (ts.isExpressionStatement(stmt) && isCjsDeclaringAssignment(stmt.expression, functions(sf))) + continue; return "its evaluation has effects beyond declarations"; } // Writes from function bodies to module-level bindings. @@ -600,3 +605,39 @@ export function sharedModuleRegistrations(lowerer: Lowerer): IrStmt[] { }); return out; } + +/** The names of a module's top-level function declarations. */ +function functions(sf: ts.SourceFile): Set { + const names = new Set(); + for (const stmt of sf.statements.nodes) + if (ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) names.add(stmt.name.text); + return names; +} + +/** `module.exports = …`, `exports.x = …`, `module.exports.x = …` (also + * chained: `exports = module.exports = …`), or `f.x = …` for a top-level + * function `f`. */ +function isCjsDeclaringAssignment(e: ts.Expression, fns: Set): boolean { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return false; + const left = e.left; + const isModuleExports = (n: ts.Expression): boolean => + ts.isPropertyAccessExpression(n) && + ts.isIdentifier(n.expression) && + n.expression.text === "module" && + ts.isIdentifier(n.name) && + n.name.text === "exports"; + const target = + (ts.isIdentifier(left) && left.text === "exports") || + isModuleExports(left) || + (ts.isPropertyAccessExpression(left) && + ((ts.isIdentifier(left.expression) && + (left.expression.text === "exports" || fns.has(left.expression.text))) || + isModuleExports(left.expression))); + if (!target) return false; + // A chained assignment declares too. + const right = e.right; + if (ts.isBinaryExpression(right) && right.operatorToken.kind === ts.SyntaxKind.EqualsToken) + return isCjsDeclaringAssignment(right, fns); + return true; +} diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 348ca658e..7836b8918 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -6410,7 +6410,11 @@ function preflight7(load: LoadResult): { // optional-dependency try/require pattern), so no fence. // Binding forms keep the fence: their downstream reads // would need the module that never loads. - if (req.decl !== null || probeNodeRequireRefusal(sf.fileName, req.spec) === null) { + if ( + (req.decl !== null || probeNodeRequireRefusal(sf.fileName, req.spec) === null) && + // A Node island package: loaded in Node at the require (NI-4). + !isFrontendIsland(packageNameOfSpecifier(req.spec)) + ) { diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(req.spec))); } } @@ -6467,7 +6471,10 @@ function preflight7(load: LoadResult): { if (canonicalBuiltinModule(spec) === null && !processModuleAliasRequire7(spec, null)) { // Binding-less by construction — same require-site throw // channel as the statement-level form above. - if (probeNodeRequireRefusal(sf.fileName, spec) === null) { + if ( + probeNodeRequireRefusal(sf.fileName, spec) === null && + !isFrontendIsland(packageNameOfSpecifier(spec)) + ) { diags.push(unsupportedDiag("SC1010", loc, unsupportedModuleFeatureOf(spec))); } } diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 024f11277..35f195aff 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -204,6 +204,9 @@ function buildLibFnSignatures() { "nodeIsland.applyTo": { argTypes: [DYN, DYN, DYN], result: DYN }, "nodeIsland.construct": { argTypes: [DYN, DYN], result: DYN }, "nodeIsland.instanceof": { argTypes: [DYN, DYN], result: BOOL }, + /** Array.isArray of a dyn value in a program with Node islands: island + * arrays answer true too. */ + "nodeIsland.isArray": { argTypes: [DYN], result: BOOL }, /** NI-5: a class instance crossing into an island, with the builder of * its class's method table (run once per class). Answers the value. */ "nodeIsland.classMethods": { argTypes: [DYN, DYN], result: DYN }, diff --git a/packages/compiler/src/node-islands/libnode.ts b/packages/compiler/src/node-islands/libnode.ts index d0d561b84..40f946b18 100644 --- a/packages/compiler/src/node-islands/libnode.ts +++ b/packages/compiler/src/node-islands/libnode.ts @@ -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 = 8; +export const NODE_BRIDGE_ABI = 9; /** The prefix of a recorded path relative to the executable's directory. */ export const ORIGIN = "$ORIGIN/"; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json new file mode 100644 index 000000000..faf99c163 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6818-node-island-mixed/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6818-node-island-mixed/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json new file mode 100644 index 000000000..16d9cc12b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6819-node-island-fenced/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6819-node-island-fenced/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json new file mode 100644 index 000000000..e2367ec2e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6825-node-island-init-fence/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6825-node-island-init-fence/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json new file mode 100644 index 000000000..a864c36e1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6826-node-island-undefined-args/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6826-node-island-undefined-args/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..8a467a09c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6829-node-island-declared-types/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6829-node-island-declared-types/main.ts" + ], + "diags": [] +} diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index a233becaf..7bd8c4bbc 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -1441,6 +1441,11 @@ Local ExportValue(Isolate *isolate, Local context, const ScrNode } else { obj = ExportTemplate(isolate)->NewInstance(context).ToLocalChecked(); obj->SetInternalField(0, data); + // A template instance inherits from its implicit constructor's own + // prototype object; a native plain object is a plain object to island + // code (`Object.getPrototypeOf(o) === Object.prototype`, as jose's + // isObject checks), so it inherits Object.prototype directly. + obj->SetPrototypeV2(context, v8::Object::New(isolate)->GetPrototypeV2()).FromMaybe(false); } if (!obj->IsProxy()) obj->SetPrivate(context, ExportKey(isolate), data).Check(); Global &slot = g_exports[id]; @@ -1608,6 +1613,41 @@ Local ToV8(Isolate *isolate, Local context, const ScrNodeValue * } } +// Node's arrow header for an exception's throw site, recorded once on the +// error object (as Node decorates an error the first time it is thrown): +// ":\n\n", the caret under the +// message's columns with tabs kept. Printed by the runtime when the error +// ends the program uncaught (ErrorInfo's `arrow`). +Local ArrowKey(Isolate *isolate) { + return v8::Private::ForApi(isolate, Utf8(isolate, "scriptc.node-island.arrow")); +} + +void RecordArrow(Isolate *isolate, Local context, const TryCatch &try_catch, + Local exception) { + if (!exception->IsObject()) return; + Local error = exception.As(); + Local key = ArrowKey(isolate); + if (error->HasPrivate(context, key).FromMaybe(true)) return; + // Node decorates from the error's own message (its construction site, + // as Exception::CreateMessage answers for an Error with a stack), not + // from where the throw left the call. + Local message = v8::Exception::CreateMessage(isolate, exception); + if (message.IsEmpty()) message = try_catch.Message(); + if (message.IsEmpty()) return; + Local source; + if (!message->GetSourceLine(context).ToLocal(&source)) return; + std::string line = *String::Utf8Value(isolate, source); + std::string resource = *String::Utf8Value(isolate, message->GetScriptResourceName()); + int linenum = message->GetLineNumber(context).FromMaybe(0); + int start = message->GetStartColumn(context).FromMaybe(0); + int end = message->GetEndColumn(context).FromMaybe(start + 1); + std::string arrow = resource + ":" + std::to_string(linenum) + "\n" + line + "\n"; + for (int i = 0; i < start; i++) + arrow += (i < static_cast(line.size()) && line[i] == '\t') ? '\t' : ' '; + for (int i = start; i < end || i == start; i++) arrow += '^'; + (void)error->SetPrivate(context, key, Utf8(isolate, arrow.data(), arrow.size())); +} + // 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 { @@ -1625,6 +1665,7 @@ struct Op { } if (try_catch.HasCaught() && !try_catch.HasTerminated()) { Local exception = try_catch.Exception(); + RecordArrow(e.isolate, e.context, try_catch, exception); try_catch.Reset(); FromV8(e.isolate, e.context, exception, out); } else { @@ -1886,6 +1927,9 @@ void ErrorInfo(uint32_t h, ScrNodeErrorInfo *out) { read("message", &out->message, &out->message_len, false); read("stack", &out->stack, &out->stack_len, true); read("code", &out->code, &out->code_len, true); + Local arrow; + if (error->GetPrivate(op.e.context, ArrowKey(op.e.isolate)).ToLocal(&arrow) && arrow->IsString()) + out->arrow = Scratch(Wtf8(op.e.isolate, arrow.As()), &out->arrow_len); } void DropHandle(uint32_t h) { diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 72e888dce..d83d8393c 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -68,7 +68,9 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_assert.c", any("assert", "regex", "symbol")], ["scr_inspect.c", "inspect"], ["scr_console_native.c", "inspect"], - ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch")], + // Island libCalls on native values typed by island declarations invoke + // through the checked-dynamic path (scr_node_island_handle.c). + ["scr_dyn_invoke.c", any("dynInvoke", "nativeFetch", "nodeIsland")], // Buffer.prototype methods called on checked-dynamic values. ["scr_bytes_dyn.c", all("dynBuffer", "dynInvoke")], // Builtin functions read as values in JavaScript dependencies. @@ -248,8 +250,13 @@ export function variantsFor(source) { 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") { + // util.inspect and instanceof of Node island handles; Node's report for + // an uncaught island error. + if ( + source === "scr_inspect.c" || + source === "scr_dyn_builtin_class.c" || + source === "scr_exception.c" + ) { variants.push({ id: "node-island", when: { nodeIsland: true }, diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index 9dcad5b67..f1c4348d7 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -9,6 +9,9 @@ * dummy returns); this file only stores, renders, and releases the value. */ #include "scr_runtime.h" +#ifdef SCR_NODE_ISLANDS +#include "scr_node_island.h" +#endif #include #include @@ -700,6 +703,16 @@ void scr_exc_print_uncaught(void) { /* Settle any runtime-internal stdout fragment before the stderr line when * both share an fd. JavaScript-visible writes already flush themselves. */ fflush(stdout); +#ifdef SCR_NODE_ISLANDS + /* An error thrown by Node island code: Node's own report (the throw + * site's source line, the stack, the Node.js version). */ + if (scr_exc_kind == SCR_EXC_OBJ && scr_error_is(scr_exc_payload) && + scr_ni_report_uncaught((ScrError *)scr_exc_payload)) { + scr_exc_reset(); /* the payload counts as live until released */ + scr_exc_cell_changed(); + return; + } +#endif fputs("Uncaught ", stderr); switch (scr_exc_kind) { case SCR_EXC_F64: { diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 2ecc99d9c..f3b2d3dc0 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -5060,6 +5060,9 @@ static bool scr_native_iterator_over(const ScrDyn *value, unsigned kind); /* Node util.types reads internal brands, never user properties or prototypes. */ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) { if (!value) return false; +#ifdef SCR_NODE_ISLANDS + if (scr_ni_is_handle(value)) return scr_ni_util_type_is(value, probe); +#endif #define PROBE(name) (probe->len == sizeof(name) - 1 && memcmp(probe->data, name, sizeof(name) - 1) == 0) if (PROBE("isAnyArrayBuffer")) return scr_buffer_storage_is(value); diff --git a/packages/runtime/src/scr_node_bridge.h b/packages/runtime/src/scr_node_bridge.h index 969477b58..830b67193 100644 --- a/packages/runtime/src/scr_node_bridge.h +++ b/packages/runtime/src/scr_node_bridge.h @@ -17,7 +17,7 @@ #include #include -#define SCR_NODE_BRIDGE_ABI 8u +#define SCR_NODE_BRIDGE_ABI 9u /* The symbol a bridge library exports: `const ScrNodeBridge *(void)`. */ #define SCR_NODE_BRIDGE_ENTRY "scr_node_bridge_v1" @@ -149,6 +149,11 @@ typedef struct ScrNodeErrorInfo { uint32_t kind; const char *name, *message, *stack, *code; size_t name_len, message_len, stack_len, code_len; + /* Node's arrow header for the throw site (":\n\n"), recorded when the error was thrown out of island + * code; NULL when none. */ + const char *arrow; + size_t arrow_len; } ScrNodeErrorInfo; /* What the runtime provides to the bridge. */ diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 1a96754a8..08ee22f8e 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -87,6 +87,13 @@ ScrDyn *scr_ni_call_method(ScrDyn *obj, const char *key, size_t len, ScrDyn *con size_t argc); ScrDyn *scr_ni_construct(ScrDyn *ctor, ScrDyn *const *args, size_t argc); ScrStr *scr_ni_typeof(ScrDyn *value); +/* util.types.(value) answered by Node for an island handle. */ +bool scr_ni_util_type_is(const ScrDyn *value, const ScrStr *probe); +/* Array.isArray of a dyn value, island arrays included. */ +bool scr_ni_is_array(ScrDyn *value); +/* Prints Node's uncaught-exception report for an error thrown by island + * code (true), or nothing (false: not an island error). */ +bool scr_ni_report_uncaught(ScrError *error); int scr_ni_instanceof(ScrDyn *value, ScrDyn *ctor); bool scr_ni_is_handle(const ScrDyn *value); /* ToPrimitive of an island object (hint SCR_DYN_HINT_*), run by V8: @@ -195,6 +202,7 @@ ScrDyn *scr_node_island_apply_to(ScrDyn *fn, ScrDyn *this_, ScrDyn *args); ScrDyn *scr_node_island_construct(ScrDyn *ctor, ScrDyn *args); void scr_node_island_define_field(ScrDyn *self, ScrStr *key, ScrDyn *value); ScrDyn *scr_node_island_get(ScrDyn *obj, ScrStr *key); +ScrDyn *scr_node_island_get_key(ScrDyn *obj, ScrDyn *key); bool scr_node_island_instanceof(ScrDyn *value, ScrDyn *ctor); bool scr_node_island_instanceof_global(ScrDyn *value, ScrStr *name); ScrDyn *scr_node_island_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *args); diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 631a19b8c..5b66d2bbc 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -1457,6 +1457,58 @@ void scr_ni_handles_install(void) { scr_atexit(nh_cleanup); } +/* ── NI-4d: util.types, Array.isArray and uncaught reports ─────────────── */ + +static ScrDyn *nh_util_types = NULL; /* Node's util.types (held to exit) */ + +static void nh_util_types_cleanup(void) { + scr_dyn_release(nh_util_types); + nh_util_types = NULL; +} + +bool scr_ni_util_type_is(const ScrDyn *value, const ScrStr *probe) { + if (nh_util_types == NULL) { + ScrStr *path = scr_str_new("node:util", 9); + ScrDyn *util = scr_ni_require(path); + scr_str_release(path); + if (util == NULL) return false; + nh_util_types = scr_ni_get_str(util, "types", 5); + if (nh_util_types != NULL) scr_atexit(nh_util_types_cleanup); + scr_dyn_release(util); + if (nh_util_types == NULL) return false; + } + ScrDyn *args[1] = {(ScrDyn *)value}; + ScrDyn *r = scr_ni_call_method(nh_util_types, probe->data, probe->len, args, 1); + if (r == NULL) return false; + bool answer = r->kind == SCR_DYN_BOOL && r->v.b; + scr_dyn_release(r); + return answer; +} + +bool scr_ni_is_array(ScrDyn *value) { + if (value == NULL) return false; + if (value->kind == SCR_DYN_ARR) return true; + if (!scr_ni_is_handle(value)) return value->kind == SCR_DYN_TYPED_REF && scr_dyn_ref_is_array(value); + return (((ScrNodeRef *)value->v.handle.ptr)->flags & SCR_NVF_ARRAY) != 0; +} + +bool scr_ni_report_uncaught(ScrError *error) { + ScrNodeRef *ref = nh_pinned(error, NH_PIN_ERROR); + const ScrNodeBridge *bridge = scr_ni_bridge_loaded(); + if (ref == NULL || ref->id == 0 || bridge == NULL) return false; + ScrNodeErrorInfo info; + bridge->error_info(ref->id, &info); + if (info.stack == NULL) return false; + if (info.arrow != NULL && info.arrow_len > 0) { + fwrite(info.arrow, 1, info.arrow_len, stderr); + fputs("\n\n", stderr); + } + fwrite(info.stack, 1, info.stack_len, stderr); + fprintf(stderr, "\n\nNode.js v%s\n", bridge->node_version); + fflush(stderr); + return true; +} + /* ── NI-4a: libCall entry points of compiled island code ───────────────── * The compiler (frontend/lowering/lower-node-island.ts) lowers member * operations on island values to `nodeIsland.*` libCalls, which pass @@ -1473,28 +1525,61 @@ static size_t ni_libcall_args(ScrDyn *args, ScrDyn *const **items) { return args->v.arr.len; } +/* Routing follows the value: a value typed by an island's declarations + * may still be a native object (an island interface annotating a native + * literal, a native error handed to an island callback). Those take the + * ordinary checked-dynamic operations instead of the bridge. */ +ScrDyn *scr_dyn_reflect_get(ScrDyn *target, ScrDyn *raw_key, ScrDyn *receiver); + +static bool ni_lc_island(const ScrDyn *v) { return v != NULL && scr_ni_is_handle(v); } + +/* A NUL-terminated copy of `key` for the dyn invoke path (heap only for long names). */ +static ScrDyn *ni_lc_native_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *const *items, size_t argc) { + char small[128]; + char *name = key->len < sizeof small ? small : malloc(key->len + 1); + if (name == NULL) scr_trap("scriptc: out of memory\n"); + memcpy(name, key->data, key->len); + name[key->len] = '\0'; + ScrDyn *result = scr_dyn_invoke(obj, name, items, argc, name); + if (name != small) free(name); + return result; +} + ScrDyn *scr_node_island_get(ScrDyn *obj, ScrStr *key) { - return scr_ni_get_str(obj, key->data, key->len); + if (ni_lc_island(obj)) return scr_ni_get_str(obj, key->data, key->len); + ScrDyn *k = scr_dyn_new_str(key); + ScrDyn *value = scr_dyn_reflect_get(obj, k, obj); + scr_dyn_release(k); + return value; +} + +ScrDyn *scr_node_island_get_key(ScrDyn *obj, ScrDyn *key) { + if (ni_lc_island(obj)) return scr_ni_get(obj, key); + return scr_dyn_reflect_get(obj, key, obj); } void scr_node_island_set(ScrDyn *obj, ScrStr *key, ScrDyn *value) { - (void)scr_ni_set_str(obj, key->data, key->len, value); + if (ni_lc_island(obj)) (void)scr_ni_set_str(obj, key->data, key->len, value); + else scr_dyn_key_set(obj, key, value); } void scr_node_island_set_key(ScrDyn *obj, ScrDyn *key, ScrDyn *value) { - (void)scr_ni_set(obj, key, value); + if (ni_lc_island(obj)) (void)scr_ni_set(obj, key, value); + else scr_dyn_key_set_computed(obj, key, value); } ScrDyn *scr_node_island_invoke(ScrDyn *obj, ScrStr *key, ScrDyn *args) { ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); - return scr_ni_call_method(obj, key->data, key->len, items, argc); + if (ni_lc_island(obj)) return scr_ni_call_method(obj, key->data, key->len, items, argc); + return ni_lc_native_invoke(obj, key, items, argc); } ScrDyn *scr_node_island_apply(ScrDyn *fn, ScrDyn *args) { ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); - return scr_ni_call(fn, NULL, items, argc); + if (ni_lc_island(fn)) return scr_ni_call(fn, NULL, items, argc); + return scr_dyn_call(fn, items, argc, "value"); } ScrDyn *scr_node_island_apply_to(ScrDyn *fn, ScrDyn *this_, ScrDyn *args) { @@ -1505,6 +1590,12 @@ ScrDyn *scr_node_island_apply_to(ScrDyn *fn, ScrDyn *this_, ScrDyn *args) { } ScrDyn *scr_node_island_construct(ScrDyn *ctor, ScrDyn *args) { + if (!ni_lc_island(ctor)) { + ScrStr *what = scr_str_new("value", 5); + ScrDyn *result = scr_dyn_construct(ctor, args, what); + scr_str_release(what); + return result; + } ScrDyn *const *items; size_t argc = ni_libcall_args(args, &items); return scr_ni_construct(ctor, items, argc); diff --git a/tests/corpus/6818-node-island-mixed/main.ts b/tests/corpus/6818-node-island-mixed/main.ts new file mode 100644 index 000000000..aa3c3076c --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/main.ts @@ -0,0 +1,51 @@ +// @requires-libnode +// A Node island package used from both sides: a natively compiled package +// requires it; island-declared interfaces type native objects too (member +// operations follow the value, not the declared type); options built with +// spread and method members cross in by reference; a `string | Buffer` +// member reads back from an island object; a rejection's error is read in +// a native callback. +import { localName, swapped, twice } from "wraplib"; +import * as lib from "islandlib"; +import { + countFiles, + describeFile, + blob, + text, + render, + failLater, + type Files, + type File, + type FailError, +} from "islandlib"; + +console.log("twice", twice(5)); +console.log("swapped", swapped(), localName(), "ns call", lib.getValue()); + +const files: Files = {}; +files["a.txt"] = { mode: 420, data: "alpha" }; +files["b.txt"] = blob("beta"); +console.log("files", countFiles(files), files["a.txt"]!.mode, Object.keys(files).join(",")); +const local: File = files["a.txt"]!; +console.log("local", local.mode, describeFile(local), describeFile(files["b.txt"]!)); + +const fromIsland = blob("gamma"); +const data = fromIsland.data; +console.log("buffer", typeof data === "string" ? data : data.toString("utf8"), describeFile(text("delta"))); + +const base = { name: "job", retries: 1 }; +console.log( + "render", + render({ + ...base, + retries: 3, + format(name: string): string { + return name.toUpperCase(); + }, + }), +); + +failLater("nope").catch((e: unknown) => { + const err = e as FailError; + console.log("caught", err.message, err.code, (e as { message?: string }).message); +}); diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts new file mode 100644 index 000000000..9cbf018ed --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.d.ts @@ -0,0 +1,29 @@ +export declare class Counter { + constructor(start: number); + value: number; + inc(): number; +} +export interface File { + mode: number; + data: string | Buffer; +} +export interface Files { + [path: string]: File; +} +export interface RenderOptions { + name: string; + retries: number; + format(name: string): string; +} +export interface FailError extends Error { + code: string; +} +export declare function makeCounter(start: number): Counter; +export declare function countFiles(files: Files): number; +export declare function describeFile(file: File): string; +export declare function blob(text: string): File; +export declare function text(text: string): File; +export declare function render(opts: RenderOptions): string; +export declare function failLater(message: string): Promise; +export declare const value: number; +export declare function getValue(): number; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js new file mode 100644 index 000000000..b7c662ac2 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/index.js @@ -0,0 +1,22 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. +const { createRequire } = require("node:module"); +const load = createRequire(__filename); +const { Counter } = load(["./lib", "counter"].join("/")); + +exports.makeCounter = (start) => new Counter(start); +// A method that reads its receiver. +exports.value = 7; +exports.getValue = function () { + return this.value; +}; +exports.Counter = Counter; +// A files map: native callers build their own with the same interface. +exports.countFiles = (files) => Object.keys(files).length; +exports.describeFile = (file) => `${file.mode}:${file.data.toString("utf8")}`; +exports.blob = (text) => ({ mode: 420, data: Buffer.from(text) }); +exports.text = (text) => ({ mode: 384, data: text }); +// Takes options built from spread and method members. +exports.render = (opts) => `${opts.name}/${opts.retries}/${opts.format(opts.name)}`; +// Settles a promise with an Error the caller inspects in .catch. +exports.failLater = (message) => Promise.reject(Object.assign(new Error(message), { code: "EFAIL" })); diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js new file mode 100644 index 000000000..def5f80e4 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/lib/counter.js @@ -0,0 +1,10 @@ +"use strict"; +class Counter { + constructor(start) { + this.value = start; + } + inc() { + return ++this.value; + } +} +exports.Counter = Counter; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json new file mode 100644 index 000000000..b3a5a4e8b --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/islandlib/package.json @@ -0,0 +1 @@ +{ "name": "islandlib", "version": "2.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts new file mode 100644 index 000000000..398cd5446 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.d.ts @@ -0,0 +1,3 @@ +export declare function twice(start: number): number; +export declare function swapped(): number; +export declare function localName(): string; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js new file mode 100644 index 000000000..939e45521 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/index.js @@ -0,0 +1,15 @@ +"use strict"; +// A plain package scriptc compiles natively that requires an island package. +const islandlib = require("islandlib"); + +// A binding the module reassigns after requiring: reads follow the binding. +let swappable = require("islandlib"); +swappable = { makeCounter: () => ({ inc: () => 100 }) }; +exports.swapped = () => swappable.makeCounter(0).inc(); +exports.localName = require("./local").localName; + +exports.twice = function twice(start) { + const c = islandlib.makeCounter(start); + c.inc(); + return c.inc(); +}; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/local.js b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/local.js new file mode 100644 index 000000000..7ac847936 --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/local.js @@ -0,0 +1,7 @@ +"use strict"; +// A module function named require: calls of it are its own. +function require(name) { + return { name: `local ${name}` }; +} +const fake = require("islandlib"); +exports.localName = () => fake.name; diff --git a/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json new file mode 100644 index 000000000..5679ba18c --- /dev/null +++ b/tests/corpus/6818-node-island-mixed/node_modules/wraplib/package.json @@ -0,0 +1 @@ +{ "name": "wraplib", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "islandlib": "2.0.0" } } diff --git a/tests/corpus/6819-node-island-fenced/main.ts b/tests/corpus/6819-node-island-fenced/main.ts new file mode 100644 index 000000000..7416c5ce4 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/main.ts @@ -0,0 +1,10 @@ +// @requires-libnode +// A package scriptc compiles natively only with runtime fences that need +// a mutable object model (getters over live bindings, a constructor +// function with prototype methods) runs in a Node island automatically. +import { bump, current, Tally } from "fencedkit"; +import kit from "fencedkit"; + +console.log(current, bump(), bump(), kit.current); +const t = new Tally(1); +console.log(t.add(2) === t, t.add(3).total, t instanceof Tally); diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts new file mode 100644 index 000000000..817efabc9 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.d.ts @@ -0,0 +1,7 @@ +export declare const current: number; +export declare function bump(): number; +export declare class Tally { + constructor(start: number); + total: number; + add(n: number): Tally; +} diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js new file mode 100644 index 000000000..a6dac5623 --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/index.js @@ -0,0 +1,29 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings, +// plus a constructor function with prototype methods (R2.1). scriptc +// compiles it natively only with runtime fences, so it runs in a Node +// island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Tally: () => Tally, + current: () => current, + bump: () => bump, +}); +module.exports = src_exports; +let current = 0; +function bump() { + current += 1; + return current; +} +function Tally(start) { + this.total = start; +} +Tally.prototype.add = function (n) { + this.total += n; + return this; +}; +0 && (module.exports = { Tally, bump, current }); diff --git a/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json new file mode 100644 index 000000000..51fc5639c --- /dev/null +++ b/tests/corpus/6819-node-island-fenced/node_modules/fencedkit/package.json @@ -0,0 +1 @@ +{ "name": "fencedkit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6825-node-island-init-fence/main.ts b/tests/corpus/6825-node-island-init-fence/main.ts new file mode 100644 index 000000000..7e5ba8391 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/main.ts @@ -0,0 +1,16 @@ +// @requires-libnode +// A package whose top-level code always hits a runtime fence (pump's +// `var` function exported by reference), reached through a natively +// compiled package, runs in a Node island automatically; a `string | +// Buffer` member read from an island object decodes with toString("utf8"); +// a CommonJS helper required on both sides is duplicated quietly (its +// exports are declarations). +import { pack } from "tarlike"; +import { fromText, fromBytes, onceTotal } from "bloblike"; + +console.log("pack", pack([1, 2, 3])); +for (const blob of [fromText("plain"), fromBytes("bytes")]) { + const data = blob.data; + console.log("blob", blob.mode.toString(8), data.toString("utf8"), typeof data === "string"); +} +console.log("once", onceTotal([4, 5])); diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts new file mode 100644 index 000000000..3858e4a0c --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.d.ts @@ -0,0 +1,8 @@ +export declare class FileBlob { + constructor(data: string | Buffer); + data: string | Buffer; + mode: number; +} +export declare function fromText(text: string): FileBlob; +export declare function fromBytes(text: string): FileBlob; +export declare function onceTotal(values: number[]): number; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js new file mode 100644 index 000000000..31ccad123 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/index.js @@ -0,0 +1,19 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. It +// also requires the CommonJS helper the native side uses. +const { createRequire } = require("node:module"); +const once = createRequire(__filename)(["once", "like"].join("")); + +class FileBlob { + constructor(data) { + this.data = data; + this.mode = 0o644; + } +} +exports.FileBlob = FileBlob; +exports.fromText = (text) => new FileBlob(text); +exports.fromBytes = (text) => new FileBlob(Buffer.from(text)); +exports.onceTotal = (values) => { + const sum = once(() => values.reduce((a, b) => a + b, 0)); + return sum() + sum(); +}; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json new file mode 100644 index 000000000..b9db808e4 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/bloblike/package.json @@ -0,0 +1 @@ +{ "name": "bloblike", "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "dependencies": { "oncelike": "1.4.0" } } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js new file mode 100644 index 000000000..b1f19e69a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/once.js @@ -0,0 +1,17 @@ +// A CommonJS helper both native code and an island require: its exports +// are declarations, so each side's copy is unobservable. +module.exports = once; +once.strict = onceStrict; +function once(fn) { + var f = function () { + if (f.called) return f.value; + f.called = true; + return (f.value = fn.apply(this, arguments)); + }; + f.called = false; + return f; +} +function onceStrict(fn) { + var f = once(fn); + return f; +} diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json new file mode 100644 index 000000000..852d69c5a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/oncelike/package.json @@ -0,0 +1 @@ +{ "name": "oncelike", "version": "1.4.0", "main": "once.js" } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js new file mode 100644 index 000000000..01bf4da9e --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/index.js @@ -0,0 +1,17 @@ +// pump's shape: a function stored in a module-level `var` and exported by +// reference after it is defined. +var once = require("oncelike"); + +var noop = function () {}; + +var pumpish = function () { + var steps = Array.prototype.slice.call(arguments); + var done = once(typeof steps[steps.length - 1] === "function" ? steps.pop() : noop); + var total = steps.reduce(function (acc, n) { + return acc + n; + }, 0); + done(total); + return total; +}; + +module.exports = pumpish; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json new file mode 100644 index 000000000..40b2cc4e2 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/pumplike/package.json @@ -0,0 +1 @@ +{ "name": "pumplike", "version": "3.0.3", "main": "index.js", "dependencies": { "oncelike": "1.4.0" } } diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts new file mode 100644 index 000000000..9de17307a --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.d.ts @@ -0,0 +1 @@ +export declare function pack(sizes: number[]): string; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js new file mode 100644 index 000000000..22eb47020 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/index.js @@ -0,0 +1,13 @@ +"use strict"; +// A typed package scriptc compiles natively that requires pump's shape. +const pump = require("pumplike"); +const once = require("oncelike"); + +exports.pack = function pack(sizes) { + let reported = 0; + const report = once((n) => { + reported = n; + }); + const total = pump(...sizes, report); + return `${total}/${reported}`; +}; diff --git a/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json new file mode 100644 index 000000000..877fe5f27 --- /dev/null +++ b/tests/corpus/6825-node-island-init-fence/node_modules/tarlike/package.json @@ -0,0 +1 @@ +{ "name": "tarlike", "version": "2.1.5", "main": "index.js", "types": "index.d.ts", "dependencies": { "pumplike": "3.0.3", "oncelike": "1.4.0" } } diff --git a/tests/corpus/6826-node-island-undefined-args/main.ts b/tests/corpus/6826-node-island-undefined-args/main.ts new file mode 100644 index 000000000..5f6fb53e9 --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/main.ts @@ -0,0 +1,85 @@ +// @requires-libnode +// Arguments crossing into a Node island keep JavaScript's exact shape: +// explicit `undefined` arguments count (arguments.length, default +// parameters), an omitted optional field is absent while one holding +// `undefined` is present (`in`, hasOwnProperty, Object.keys), and native +// number arrays arrive as arrays. +import { + probe, + defaults, + version, + inspect, + nodeVersionFor, + cwd, + envValue, + plain, + type Config, +} from "argprobe"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +interface Settings { + nodeVersion?: string; +} +function settingsFor(flag: boolean): Settings | undefined { + return flag ? { nodeVersion: "20.x" } : undefined; +} + +console.log(probe()); +console.log(probe(undefined)); +console.log(probe("a", undefined)); +console.log(probe("a", {}, undefined)); +console.log(probe("a", { nodeVersion: undefined }, undefined, [18, 20, 22])); +const settings = settingsFor(false); +console.log(probe("a", { nodeVersion: settings?.nodeVersion }, undefined, [22])); +console.log(probe("a", { label: "x", nodeVersion: undefined })); + +console.log(defaults()); +console.log(defaults(undefined)); +console.log(defaults(undefined, "y")); +console.log(defaults("x", undefined, "r")); + +const available: number[] = [18, 20, 22]; +console.log(version("/work", "unused", { nodeVersion: settings?.nodeVersion }, undefined, available)); +console.log(version("/work", "unused", { nodeVersion: settingsFor(true)?.nodeVersion }, undefined, available)); +console.log(version("/work", undefined, undefined, undefined, available)); +console.log(inspect({ nodeVersion: undefined, list: [1, undefined, 3] })); + +// A native object typed by the island's own interface, read with `?.`. +function projectSettings(flag: boolean): Config | undefined { + return flag ? { nodeVersion: "20.x", label: "proj" } : undefined; +} +const project = projectSettings(true); +const none = projectSettings(false); +console.log(version("/work", "unused", { nodeVersion: project?.nodeVersion }, undefined, available)); +console.log(version("/work", "unused", { nodeVersion: none?.nodeVersion }, undefined, available)); +console.log(probe("p", { nodeVersion: project?.nodeVersion }), probe("n", { nodeVersion: none?.nodeVersion })); +const claims = { role: "admin" }; +console.log("plain", plain({ sub: "alice" }), plain(claims)); + +async function main(): Promise { + // A package.json the program writes just before the island reads it. + const dir = mkdtempSync(join(tmpdir(), "argprobe-")); + try { + writeFileSync(join(dir, "package.json"), JSON.stringify({ engines: { node: "22.x" } })); + console.log(await nodeVersionFor(dir, "unused", { nodeVersion: settings?.nodeVersion }, undefined, available)); + rmSync(join(dir, "package.json")); + console.log(await nodeVersionFor(dir, "unused", { nodeVersion: "18.x" }, { isDev: true })); + try { + await nodeVersionFor(dir, "unused", { nodeVersion: settings?.nodeVersion }, undefined, available); + } catch (e) { + console.log("rejected", (e as Error).message); + } + // Process state the program changes after Node loaded is Node's too. + const before = process.cwd(); + process.chdir(dir); + console.log("cwd", cwd() === process.cwd(), cwd() === before); + process.chdir(before); + process.env["ARGPROBE_SETTING"] = "set-natively"; + console.log("env", envValue("ARGPROBE_SETTING")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} +void main(); diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts new file mode 100644 index 000000000..6ce43de4d --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.d.ts @@ -0,0 +1,24 @@ +export interface Config { + nodeVersion?: string; + label?: string; +} +export declare function probe(a?: unknown, b?: Config, c?: unknown, d?: number[]): string; +export declare function defaults(x?: string, y?: string, ...rest: string[]): string; +export declare function version( + path: string, + fallback?: string, + config?: Config, + meta?: { nodeVersion?: string }, + available?: number[], +): string; +export declare function inspect(v: unknown): string; +export declare function nodeVersionFor( + dir: string, + fallback?: string, + config?: Config, + meta?: { isDev?: boolean }, + available?: number[], +): Promise; +export declare function cwd(): string; +export declare function envValue(name: string): string | undefined; +export declare function plain(v: object): string; diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js new file mode 100644 index 000000000..5cf8f9301 --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/index.js @@ -0,0 +1,78 @@ +"use strict"; +// Loads its parts with a computed specifier (R1.2): a Node island. Its +// functions report exactly what arrives: argument counts, explicit +// undefined, key presence of undefined-valued fields, arrays. +const { createRequire } = require("node:module"); +const util = createRequire(__filename)(["node", "util"].join(":")); + +function describeValue(v) { + if (v === undefined) return "undefined"; + if (Array.isArray(v)) return `array(${v.length}:${v.map(describeValue).join(",")})`; + if (v !== null && typeof v === "object") + return `{${Object.keys(v) + .map((k) => `${k}=${describeValue(v[k])}`) + .join(",")}}`; + return JSON.stringify(v); +} + +exports.probe = function probe(a, b, c, d) { + return [ + `len=${arguments.length}`, + `a=${describeValue(a)}`, + `b=${describeValue(b)}`, + `in=${b !== undefined && b !== null && "nodeVersion" in b}`, + `own=${b !== undefined && b !== null && Object.prototype.hasOwnProperty.call(b, "nodeVersion")}`, + `c=${describeValue(c)}`, + `d=${describeValue(d)}`, + ].join(" "); +}; + +exports.defaults = function defaults(x = "dx", y = "dy", ...rest) { + return `${x}|${y}|rest=${rest.length}|len=${arguments.length}`; +}; + +exports.version = function version(path, fallback = "unused", config = {}, meta, available) { + const wanted = config.nodeVersion ?? (meta && meta.nodeVersion) ?? null; + const list = Array.isArray(available) ? available : []; + if (wanted === null) { + const latest = Math.max(...list); + return `${path}: ${list.includes(latest) ? `${latest}.x` : fallback}`; + } + return `${path}: ${wanted}`; +}; + +exports.inspect = (v) => util.inspect(v); + +// A version-lookup helper's shape: async, defaults for omitted +// arguments, engines.node from the package.json in `dir` first. +exports.nodeVersionFor = async function nodeVersionFor( + dir, + fallback = "unused", + config = {}, + meta = {}, + available = [16], +) { + const fs = require("node:fs/promises"); + const path = require("node:path"); + let engines = null; + try { + engines = JSON.parse(await fs.readFile(path.join(dir, "package.json"), "utf8")).engines ?? null; + } catch { + engines = null; + } + if (engines && engines.node) return `engines:${engines.node}:${Math.max(...available)}`; + const configured = config.nodeVersion || fallback; + if (configured === "unused") throw new Error(`Found invalid or discontinued Node.js Version: "${configured}"`); + return `configured:${configured}:${meta.isDev === true}`; +}; + +exports.cwd = () => process.cwd(); +exports.envValue = (name) => process.env[name]; + +// A native plain object is a plain object here (jose's isObject). +exports.plain = (v) => + [ + Object.getPrototypeOf(v) === Object.prototype, + Object.prototype.toString.call(v), + Object.getPrototypeOf(Object.getPrototypeOf(v)) === null, + ].join(" "); diff --git a/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json new file mode 100644 index 000000000..4b7cb717e --- /dev/null +++ b/tests/corpus/6826-node-island-undefined-args/node_modules/argprobe/package.json @@ -0,0 +1 @@ +{ "name": "argprobe", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6829-node-island-declared-types/main.ts b/tests/corpus/6829-node-island-declared-types/main.ts new file mode 100644 index 000000000..52c2e5c40 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/main.ts @@ -0,0 +1,39 @@ +// @requires-libnode +// Typed code names an island package's declared interfaces and generic +// aliases: the package's declarations type it, while its values cross the +// boundary as handles. +import { Arr, Num, Str, check, type Static, type TArray, type TNumber, type TSchema, type TString } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const sum: Tool> = { + name: "sum", + parameters: Arr(Num()), + execute: (id, params) => `${id}: ${params.reduce((a, b) => a + b, 0)}`, +}; +const shout: Tool = { + name: "shout", + parameters: Str(), + execute: (id, params) => `${id}: ${params.toUpperCase()}`, +}; + +function run(tool: Tool, input: unknown): string { + if (!check(tool.parameters, input)) return `${tool.name}: rejected ${JSON.stringify(input)}`; + return tool.execute(tool.name, input); +} + +function describeAll(schemas: TSchema[]): string[] { + return schemas.map((s) => `${s.kind} ${s.describe()}`); +} + +console.log(run(sum, [1, 2, 3])); +console.log(run(sum, [1, "2"])); +console.log(run(shout, "quiet")); +console.log(run(shout, 7)); +console.log(describeAll([sum.parameters, shout.parameters, Arr(Arr(Str()))]).join(" | ")); +const tools: Tool[] = [sum, shout]; +console.log(tools.map((t) => t.name).join(",")); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts new file mode 100644 index 000000000..d64602f56 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.d.ts @@ -0,0 +1,27 @@ +// Types the package only declares: its JavaScript has no interfaces and no +// conditional aliases, so code typed from the JavaScript sees `any` here. +export interface TSchema { + readonly kind: string; + describe(): string; +} +export interface TNumber extends TSchema { + readonly kind: "number"; +} +export interface TString extends TSchema { + readonly kind: "string"; +} +export interface TArray extends TSchema { + readonly kind: "array"; + readonly items: T; +} +export type Static = T extends TNumber + ? number + : T extends TString + ? string + : T extends TArray + ? Static[] + : unknown; +export declare function Num(): TNumber; +export declare function Str(): TString; +export declare function Arr(items: T): TArray; +export declare function check(schema: T, value: unknown): value is Static; diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js new file mode 100644 index 000000000..9fb64f5c3 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/index.js @@ -0,0 +1,37 @@ +"use strict"; +// The esbuild CommonJS bundle prologue: getters over live `let` bindings +// and a constructor function with prototype methods. scriptc compiles it +// natively only with runtime fences, so it runs in a Node island. +var __defProp = Object.defineProperty; +var __export = (target, all) => { + for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); +}; +var src_exports = {}; +__export(src_exports, { + Num: () => Num, + Str: () => Str, + Arr: () => Arr, + check: () => check, +}); +module.exports = src_exports; +function Schema(kind, items) { + this.kind = kind; + if (items !== undefined) this.items = items; +} +Schema.prototype.describe = function () { + return this.items === undefined ? this.kind : this.kind + "<" + this.items.describe() + ">"; +}; +function Num() { + return new Schema("number"); +} +function Str() { + return new Schema("string"); +} +function Arr(items) { + return new Schema("array", items); +} +function check(schema, value) { + if (schema.kind === "array") return Array.isArray(value) && value.every((v) => check(schema.items, v)); + return typeof value === schema.kind; +} +0 && (module.exports = { Num, Str, Arr, check }); diff --git a/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json new file mode 100644 index 000000000..2ccb6f961 --- /dev/null +++ b/tests/corpus/6829-node-island-declared-types/node_modules/schemakit/package.json @@ -0,0 +1 @@ +{ "name": "schemakit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/node-island/declared-types.ts b/tests/fixtures/node-island/declared-types.ts new file mode 100644 index 000000000..05835c751 --- /dev/null +++ b/tests/fixtures/node-island/declared-types.ts @@ -0,0 +1,27 @@ +// A failed build whose package runs in a Node island reports the island +// build's errors: there the package's declarations type the program. The +// test builds it beside corpus 6829's schemakit package. +import { Num, check, type Static, type TNumber, type TSchema } from "schemakit"; + +interface Tool { + name: string; + parameters: T; + execute(id: string, params: Static): string; +} + +const double: Tool = { + name: "double", + parameters: Num(), + execute: (id, params) => `${id}: ${params * 2}`, +}; +const input: unknown = 4; +if (check(double.parameters, input)) console.log(double.execute(double.name, input)); +// The build's own error: member decorators have no native lowering. +function logged(method: () => number, context: ClassMethodDecoratorContext): void {} +class Counter { + @logged + next(): number { + return 1; + } +} +console.log(new Counter().next()); diff --git a/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json new file mode 100644 index 000000000..c2f7906fb --- /dev/null +++ b/tests/harness/corpus-costs/6829-node-island-declared-types/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3400, "san": 62000 } diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 2aca7c86d..e3e82216c 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -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 8)\n`, + `scriptc: the libnode pack at ${dir} has an incompatible bridge (ABI 999; this program needs 9)\n`, ); }); @@ -355,6 +355,31 @@ test("recursive JSON types cross into Node", async () => { expect(ir).toContain("nodeIsland.callSync"); }, 300_000); +// When a build fails and the packages it compiled with runtime fences then +// fail as islands too, the island build's errors are reported. That build +// types the user code from the packages' declarations; the native one +// typed it from their JavaScript, where declared-only types (`Static`) +// are `any`. Needs no libnode pack: both builds fail before linking. +test("a failed build reports the island build's errors", async () => { + const dir = join(outDir, "declared-types"); + rmSync(dir, { recursive: true, force: true }); + cpSync( + join(repoRoot, "tests/corpus/6829-node-island-declared-types/node_modules"), + join(dir, "node_modules"), + { recursive: true }, + ); + copyFileSync( + join(repoRoot, "tests/fixtures/node-island/declared-types.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; + const messages = result.diagnostics.map((d) => `${d.code}: ${d.message}`); + expect(messages.some((m) => m.startsWith("SC1090: method decorators"))).toBe(true); + expect(messages.filter((m) => /Tool|Static|'check'/.test(m))).toEqual([]); +}, 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"); diff --git a/tests/harness/npm-suite.ts b/tests/harness/npm-suite.ts index 1463e3354..d31d96ac9 100644 --- a/tests/harness/npm-suite.ts +++ b/tests/harness/npm-suite.ts @@ -47,7 +47,7 @@ type Known = const KNOWN: Record = { "assert-shims": { run: "SC1043" }, "buffer-shims": { run: "SC2020" }, - "bytes-exit": { build: "SC2004" }, + "bytes-exit": { island: true }, "cjs-esm-named": { island: true }, "cjs-lexer-aligned": { island: true }, "commander-calc": { run: "SC1090", argvs: [["--help"]] }, @@ -214,6 +214,43 @@ async function build(entry: string, shapeOverlay: boolean | undefined): Promise< }; } +/** Node's uncaught-error report ends with `Node.js v`: the island + * prints libnode's release, which may differ from the reference Node's + * patch release. */ +function normalizeNodeVersion([a, b]: [string, string]): [string, string] { + const re = /^Node\.js v\d+\.\d+\.\d+$/m; + return [a.replace(re, "Node.js v"), b.replace(re, "Node.js v")]; +} + +/** The two stderr texts with their stack frame runs cut to the frames + * they share: an uncaught error from a Node island prints Node's report, + * whose frames match Node's from the throw site up to the first native + * frame, which has no Node-identical spelling (DESIGN.md §2.10). Every + * other line, and each run's common leading frames, compare exactly. */ +function sharedFrames(native: Buffer, node: Buffer): [string, string] { + const frame = (line: string): boolean => /^ {4}at /.test(line); + const a = native.toString("utf8").split("\n"); + const b = node.toString("utf8").split("\n"); + const outA: string[] = []; + const outB: string[] = []; + let i = 0; + let j = 0; + while (i < a.length || j < b.length) { + if (i < a.length && j < b.length && frame(a[i]!) && frame(b[j]!)) { + while (i < a.length && j < b.length && frame(a[i]!) && a[i] === b[j]) { + outA.push(a[i++]!); + outB.push(b[j++]!); + } + while (i < a.length && frame(a[i]!)) i++; + while (j < b.length && frame(b[j]!)) j++; + continue; + } + if (i < a.length) outA.push(a[i++]!); + if (j < b.length) outB.push(b[j++]!); + } + return [outA.join("\n"), outB.join("\n")]; +} + /** A Node island's modules run from the module tree the program ships * (node-islands/modules.ts), extracted under the user's cache directory, so * a path they print names the tree's copy. The tree's root is the fixtures' @@ -223,14 +260,14 @@ function fixturePaths(out: Buffer): Buffer { return Buffer.from(out.toString("utf8").replace(tree, join(fixturesRoot, "npm")), "utf8"); } -async function expectMatchesNode(c: NpmCase, binary: string, island = false): Promise { +async function expectMatchesNode(c: NpmCase, binary: string, islandFrames = false): Promise { const cwd = dirname(c.entry); for (const argv of c.argvs ?? [[]]) { const [nodeRes, ran] = await Promise.all([ runBinary(process.execPath, [c.entry, ...argv], cwd), runBinary(binary, argv, cwd), ]); - const nativeRes = island + const nativeRes = islandFrames ? { ...ran, stdout: fixturePaths(ran.stdout), stderr: fixturePaths(ran.stderr) } : ran; const label = argv.join(" "); @@ -239,7 +276,13 @@ async function expectMatchesNode(c: NpmCase, binary: string, island = false): Pr expect.unreachable("stdout differed at byte level but not after utf8 decode"); } expect(nativeRes.exitCode, label).toBe(nodeRes.exitCode); - if (c.compareStderr) expect(programStderr(nativeRes.stderr), label).toEqual(nodeRes.stderr); + if (c.compareStderr && islandFrames) { + const [native, node] = normalizeNodeVersion( + sharedFrames(programStderr(nativeRes.stderr), nodeRes.stderr), + ); + expect(native, label).toBe(node); + } else if (c.compareStderr) + expect(programStderr(nativeRes.stderr), label).toEqual(nodeRes.stderr); } }