diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 3f2124e28..bf107323b 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1258,6 +1258,9 @@ interface Response { * direct use and points at bytes(). */ arrayBuffer(): Promise; bytes(): Promise; + /* A copy whose body is one branch of the body's tee; the original keeps + * the other. */ + clone(): Response; } interface ResponseInit { headers?: Headers | Record | readonly (readonly [string, string])[]; @@ -1335,6 +1338,7 @@ interface Request { text(): Promise; arrayBuffer(): Promise; bytes(): Promise; + clone(): Request; } interface RequestInit { method?: string; diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 39756418e..dc2810c11 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -22,6 +22,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), + ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index b00700230..7ec61f2a2 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -306,6 +306,7 @@ export const LIB_FN_SYMS: Record = { "process.builtinModule": "scr_process_builtin_module", "process.builtinUnsupported": "scr_process_builtin_unsupported", "module.namespaceValue": "scr_module_namespace_value", + "module.namespaceDyn": "scr_module_namespace_dyn_value", "fs.callbackValue": "scr_fs_callback_value", "fs.callbackCall": "scr_fs_callback_call", "process.hrtimeValue": "scr_process_hrtime_value", diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 49681c986..6c282b74d 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -40,6 +40,9 @@ export interface NativeLinkFeatures { /** Module namespace objects as checked-dynamic values link * scr_module_namespace.c; absent (false) otherwise. */ moduleNamespace?: boolean; + /** A module namespace boxed for typed code links + * scr_module_namespace_dyn.c; absent (false) otherwise. */ + moduleNamespaceDyn?: boolean; zlib: boolean; /** node:zlib's Brotli forms: scr_brotli.c and the vendored brotli archive. */ brotli?: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index ba475be61..370bde77e 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -164,6 +164,9 @@ export interface CcOptions { /** Module namespace objects as checked-dynamic values: compiles * scr_module_namespace.c. */ moduleNamespace?: boolean; + /** A module namespace boxed for typed code: compiles + * scr_module_namespace_dyn.c. */ + moduleNamespaceDyn?: boolean; /** The program uses the process-events surface (signal/exit listeners, * stdin events, for-await over stdin — moduleUsesProcessEvents on the * IR): compiles scr_events.c into the binary. Event-free binaries keep diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 21563871f..5743d8106 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -205,6 +205,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [ unit((u) => u.opts.wasm, "scr_wasm.c"), // Module namespace objects as checked-dynamic values. unit((u) => u.opts.moduleNamespace === true, "scr_module_namespace.c"), + // A module namespace as the frozen null-prototype object typed code sees. + unit((u) => u.opts.moduleNamespaceDyn === true, "scr_module_namespace_dyn.c"), unit( (u) => u.opts.dynAsync || diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index af4c9f874..a4884300c 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -103,6 +103,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures { nodeIsland: boolean; brotli: boolean; moduleNamespace: boolean; + moduleNamespaceDyn: boolean; nativeFetch: boolean; abortSignalOnly: boolean; netEffective: boolean; @@ -312,6 +313,7 @@ export function effectiveRuntimeFeatures( nodeIsland: features.nodeIsland === true, brotli: features.brotli === true, moduleNamespace: features.moduleNamespace === true, + moduleNamespaceDyn: features.moduleNamespaceDyn === true, nativeFetch, // The fetch unit has AbortController and AbortSignal; scr_abort.c is // the same implementation without the fetch transport. diff --git a/packages/compiler/src/compat/fetch-profile.ts b/packages/compiler/src/compat/fetch-profile.ts index 83d809ee1..07f5f09c8 100644 --- a/packages/compiler/src/compat/fetch-profile.ts +++ b/packages/compiler/src/compat/fetch-profile.ts @@ -231,7 +231,7 @@ function buildFetchCompatProfile() { "body", "bodyUsed", ], - responseCalls: ["json", "text", "bytes"], + responseCalls: ["json", "text", "bytes", "clone"], readableStreamReads: ["locked"], readableStreamCalls: ["cancel", "getReader"], }, @@ -287,6 +287,13 @@ function buildFetchCompatProfile() { facets: ["body-consumption", "promise-settlement", "state-machine", "error-shape"], evidence: [fixture("static-request")], })), + { + id: "stdlib.request.clone", + name: "Request.clone", + kind: "method", + facets: ["body-consumption", "identity", "state-machine", "error-shape"], + evidence: [fixture("static-clone")], + }, { id: "stdlib.response.constructor", name: "Response constructor", @@ -538,6 +545,13 @@ function buildFetchCompatProfile() { ? [fixture("static")] : [fixture("static"), fixture("static-stream")], })), + { + id: "stdlib.response.clone", + name: "Response.clone", + kind: "method", + facets: ["body-consumption", "identity", "state-machine", "error-shape"], + evidence: [fixture("static-clone")], + }, ], inventory: { interfaces: [ @@ -672,7 +686,7 @@ function buildFetchCompatProfile() { "bytes", "attribute", ].map((member) => - ["clone", "blob", "formData", "attribute"].includes(member) + ["blob", "formData", "attribute"].includes(member) ? unsupportedEntry( `stdlib.request.${member}`, "Request", @@ -706,7 +720,8 @@ function buildFetchCompatProfile() { (member) => staticEntry(`stdlib.response.${member}`, "Response", member, "prototype"), ), staticEntry("stdlib.response.arrayBuffer", "Response", "arrayBuffer", "prototype"), - ...["clone", "blob", "formData"].map((member) => + staticEntry("stdlib.response.clone", "Response", "clone", "prototype"), + ...["blob", "formData"].map((member) => unsupportedEntry( `stdlib.response.${member}`, "Response", diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index f9fa04211..a1a322e48 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -67,6 +67,7 @@ export interface EarlyExecutableNativeFeatures { stdioExtended?: boolean; nodeIsland?: boolean; moduleNamespace?: boolean; + moduleNamespaceDyn?: boolean; zlib: boolean; brotli?: boolean; assert: boolean; diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index 7ef9b9b0e..c73217ef8 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -700,8 +700,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // String(NaN) is "NaN" in the number formatter; `NaN !== NaN` holds // because f64 compares are IEEE compares). if (expr.text === "Infinity" || expr.text === "NaN") { - const sym = lowerer.checker.getSymbolAtLocation(expr); - if (lowerer.isStdlibSymbol(sym)) { + if (lowerer.isStdlibGlobal(expr, expr.text)) { return { kind: "numLit", value: expr.text === "NaN" ? NaN : Infinity, type: F64, loc }; } } @@ -729,7 +728,7 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: return { kind: "libCall", fn: "dyn.arrayConstructor", args: [], type: DYN, loc }; if ( Object.values(BYTES_ELEMENT_NAME).includes(expr.text) && - lowerer.isStdlibSymbol(lowerer.checker.getSymbolAtLocation(expr)) + lowerer.isStdlibGlobal(expr, expr.text) ) { return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/global-values.ts b/packages/compiler/src/frontend/lowering/global-values.ts index 21593203d..91f705fef 100644 --- a/packages/compiler/src/frontend/lowering/global-values.ts +++ b/packages/compiler/src/frontend/lowering/global-values.ts @@ -16,6 +16,7 @@ import * as ts from "../ts7/adapter.js"; import { BYTES_ELEMENT_NAME, DYN, + F64, STRING, UNDEFINED_T, type IrExpr, @@ -133,6 +134,8 @@ export function jsGlobalValueByName(lowerer: Lowerer, name: string, loc: SrcLoc) /** jsGlobalValueByName for the globals other than the interned builtin * functions and constructors (builtinGlobalValue). */ export function jsSpecialGlobalValue(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr | null { + if (name === "NaN" || name === "Infinity") + return { kind: "numLit", value: name === "NaN" ? NaN : Infinity, type: F64, loc }; if (name === "String" || name === "Number" || name === "Boolean" || name === "BigInt") return primitiveCtorClosure(lowerer, name, loc, true); if (name === "ArrayBuffer") diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 9fe21d779..f18c2b160 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -41,7 +41,11 @@ import { type UnitEvents, type WaveHost, } from "./unit-journal.js"; -import { moduleNamespaceObject, moduleNamespaceValue } from "./module-namespace-elements.js"; +import { + moduleNamespaceDynValue, + moduleNamespaceObject, + moduleNamespaceValue, +} from "./module-namespace-elements.js"; import { keyedHelperName, moduleTagNumber, portableRelativePath } from "./stable-names.js"; import { positionTag, StructuralPositions } from "./structural-positions.js"; import { @@ -201,6 +205,7 @@ import { captureContextBinding, declareContextLocal, declareContextThis, + newFnCtx, type FnCtx, } from "./function-context.js"; import type { FrontendServices } from "../services.js"; @@ -5256,6 +5261,79 @@ export class Lowerer { return this.moduleOrder.find((sf) => tsgoPath(resolve(sf.fileName)) === path) ?? null; } + /** A module namespace taken as a checked-dynamic value (`const u: unknown + * = ns`, `take(ns)`): the runtime's namespace object — one per module, + * Node's sorted export keys, live reads through a compiled getter, and + * Node's read-only write errors — built by `moduleNamespaceDynValue`. + * `node:` builtins have no compiled source and no complete export table, + * so they keep their fence. */ + moduleNamespaceDyn(ns: IrExpr & { type: IrType & { kind: "moduleNs" } }): IrExpr | null { + if (!ns.type.moduleId.startsWith("file:")) return null; + return moduleNamespaceDynValue(this, ns); + } + + /** A `Promise` taken as `Promise` (or as + * `unknown`): one lifted async mapper per module awaits the promise and + * answers the same namespace object a bare namespace value boxes to, so + * the settlement is Node's namespace while the value stays a promise. + * The import() promise reaches these slots alone, and the runtime's + * per-module namespace cache keeps `await mapped === await original`; a + * rejection propagates unchanged. */ + moduleNamespacePromiseDynValue( + value: IrExpr & { type: IrType & { kind: "promise" } }, + ): IrExpr | null { + const inner = value.type.inner; + if (inner.kind !== "moduleNs" || !inner.moduleId.startsWith("file:")) return null; + const loc = value.loc; + const name = `%module.promise:${inner.moduleId}`; + const promiseT: IrType & { kind: "promise" } = { kind: "promise", inner }; + if (!this.liftedFns.some((fn) => fn.name === name)) { + const context = newFnCtx(false, null, null, DYN); + context.isAsync = true; + const previousClass = this.currentClass; + this.currentClass = null; + this.fnStack.push(context); + let namespace: IrExpr | null = null; + try { + const awaited: IrExpr & { type: IrType & { kind: "moduleNs" } } = { + kind: "awaitExpr", + value: varRef("promise", promiseT, loc), + type: inner, + loc, + }; + namespace = moduleNamespaceDynValue(this, awaited); + if (namespace) { + const fn: IrFunction = { + name, + params: [{ localId: "promise", name: "promise", type: promiseT }], + returnType: DYN, + locals: [ + { id: "promise", name: "promise", type: promiseT, mutable: false }, + ...context.locals, + ], + body: [{ kind: "return", value: namespace, loc }], + async: true, + loc, + }; + this.liftedFns.push(fn); + } + } finally { + this.fnStack.pop(); + this.currentClass = previousClass; + } + // A namespace with no compiled source has no dynamic value: keep the + // promise unconverted so the destination's own fence reports it. + if (!namespace) return null; + } + return { + kind: "call", + callee: name, + args: [value], + type: { kind: "promise", inner: DYN }, + loc, + }; + } + builtinErrorInfoOf(symbol: ts.Symbol | null | undefined): ClassInfo | null { return builtinErrorInfoOf(this, symbol); } @@ -12010,6 +12088,21 @@ export class Lowerer { const result = this.coerceToExpected(expr.result, expected); return { ...expr, result, type: result.type }; } + // A `Promise` widening into `Promise`, or + // into an `unknown` slot: map the payload through the namespace's + // dynamic value (moduleNamespacePromiseDynValue). A destination that + // wants the namespace type itself keeps the promise unchanged; the + // union path reuses this through its single-promise-arm conversion. + if ( + expr.type.kind === "promise" && + expr.type.inner.kind === "moduleNs" && + (expected.kind === "dyn" || (expected.kind === "promise" && expected.inner.kind === "dyn")) + ) { + const mapped = this.moduleNamespacePromiseDynValue( + expr as IrExpr & { type: IrType & { kind: "promise" } }, + ); + if (mapped) expr = mapped; + } // An instance of a class declared `extends Array` is its array (the // same object); an array slot of another element type converts it as // an array. @@ -12157,6 +12250,16 @@ export class Lowerer { const object = moduleNamespaceObject(this, ns) ?? moduleNamespaceValue(this, ns); if (object) return object; } + // A TypeScript source's module namespace as an untyped value: the + // runtime's namespace object with live reads, Node's sorted keys and + // read-only writes. `node:` builtins have no compiled source or + // complete export table here and keep their fence below. + if (expr.type.kind === "moduleNs") { + const boxed = this.moduleNamespaceDyn( + expr as IrExpr & { type: IrType & { kind: "moduleNs" } }, + ); + if (boxed) return boxed; + } if ( expr.type.kind === "moduleNs" && expr.type.moduleId === "builtin:events" && diff --git a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts index 2c8e6a475..405d2af5f 100644 --- a/packages/compiler/src/frontend/lowering/module-namespace-elements.ts +++ b/packages/compiler/src/frontend/lowering/module-namespace-elements.ts @@ -4,6 +4,7 @@ import * as ts from "../ts7/adapter.js"; import { DYN, STRING, + SYMBOL_T, type IrExpr, type IrFunction, type IrStmt, @@ -173,28 +174,28 @@ export function moduleNamespaceObject( }; } -/** A module namespace taken as an untyped value whose exports a copy - * cannot represent: a binding that can change after initialization (`let`, - * `var`), or an export the copy cannot name. The value is the runtime's - * namespace object (scr_module_namespace_value): one per module, whose - * property reads call a compiled getter for the export's current value and - * whose writes throw Node's TypeError. An export whose value has no - * checked-dynamic form throws its fence when it is read, and listing the - * object's keys or inspecting it throws the "not supported yet" error. */ -export function moduleNamespaceValue( +/** The compiled getter behind a namespace object: one lifted function per + * module that answers an export's current value by name, plus the module's + * sorted export names. An export whose value has no checked-dynamic form + * throws its fence when it is read. */ +function moduleNamespaceGetter( lowerer: Lowerer, ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, -): IrExpr | null { +): { name: string; keys: string[] } | null { const source = lowerer.sourceFileOfModuleNamespace(ns.type); if (!source) return null; const symbol = lowerer.checker.getSymbolAtLocation(source); if (!symbol) return null; - const exports: { key: string; declaration: ts.Node | undefined }[] = []; + const exports: { key: string; declaration: ts.Node | undefined; symbol: ts.Symbol }[] = []; for (const exported of lowerer.checker.getExportsOfModule(symbol)) { const target = exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported; if (!(target.flags & ts.SymbolFlags.Value)) continue; - exports.push({ key: ts.symbolName(exported), declaration: target.valueDeclaration }); + exports.push({ + key: ts.symbolName(exported), + declaration: target.valueDeclaration, + symbol: target, + }); } exports.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); const name = `%module.namespace:${ns.type.moduleId}`; @@ -206,24 +207,37 @@ export function moduleNamespaceValue( lowerer.fnStack.push(context); try { const body: IrStmt[] = []; - for (const { key, declaration } of exports) { + for (const { key, declaration, symbol: exportSymbol } of exports) { const node = declaration ?? source; const diagsBefore = lowerer.diags.length; let read: IrStmt[]; try { const identifier = declaration?.name as ts.Node | undefined; - if (!identifier || !ts.isIdentifier(identifier)) { - lowerer.unsupported( - "SC1090", - node, - `reading the unnamed export '${key}' through a module namespace object`, - ); + let value: IrExpr; + if (identifier && ts.isIdentifier(identifier)) { + value = lowerer.lowerExprExpecting(identifier, DYN); + } else { + // `export default ` and the anonymous default + // class/function have no named binding to read. The module's + // pre-registered default global holds the value the export + // statement snapshotted — the same storage a default import + // reads — so the namespace property answers it instead of + // fencing. A default whose type has no checked-dynamic form + // still fails the coercion below and keeps its fence. + const storage = lowerer.globalsBySymbol.get(exportSymbol); + if (!storage) { + lowerer.unsupported( + "SC1090", + node, + `reading the unnamed export '${key}' through a module namespace object`, + ); + } + value = lowerer.coerceToExpected(varRef(storage.id, storage.type, loc), DYN); } - const value = lowerer.lowerExprExpecting(identifier, DYN); if (value.type.kind !== "dyn") { lowerer.unsupported( "SC1101", - identifier, + identifier ?? node, `reading the export '${key}' ('${lowerer.fmt(value.type)}') through a module namespace object`, ); } @@ -263,6 +277,24 @@ export function moduleNamespaceValue( lowerer.currentClass = previousClass; } } + return { name, keys: exports.map(({ key }) => key) }; +} + +/** A module namespace taken as an untyped value whose exports a copy + * cannot represent: a binding that can change after initialization (`let`, + * `var`), or an export the copy cannot name. The value is the runtime's + * namespace object (scr_module_namespace_value): one per module, whose + * property reads call a compiled getter for the export's current value and + * whose writes throw Node's TypeError. An export whose value has no + * checked-dynamic form throws its fence when it is read, and listing the + * object's keys or inspecting it throws the "not supported yet" error. */ +export function moduleNamespaceValue( + lowerer: Lowerer, + ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, +): IrExpr | null { + const getter = moduleNamespaceGetter(lowerer, ns); + if (!getter) return null; + const loc = ns.loc; const value: IrExpr = { kind: "libCall", fn: "module.namespaceValue", @@ -270,13 +302,82 @@ export function moduleNamespaceValue( strLit(ns.type.moduleId, loc), { kind: "arrayLit", - elems: exports.map(({ key }) => strLit(key, loc)), + elems: getter.keys.map((key) => strLit(key, loc)), type: arrayOf(STRING), loc, }, { kind: "dynFrom", - value: { kind: "closure", fnName: name, captures: [], type: funcOf([STRING], DYN), loc }, + value: { + kind: "closure", + fnName: getter.name, + captures: [], + type: funcOf([STRING], DYN), + loc, + }, + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }; + if (ns.kind === "moduleNsRef") return value; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: ns, loc }], + result: value, + type: DYN, + loc, + }; +} + +/** A module namespace boxed for typed code through a dynamic slot + * (`const u: unknown = ns`, `take(ns)`): Node's namespace object as + * scr_module_namespace_dyn.c builds it — one non-extensible null-prototype + * object per module whose own sorted export names are enumerable read-only + * accessors, whose `Symbol.toStringTag` is "Module", whose reads call the same + * compiled getter, and whose writes and coerces throw Node's TypeErrors. A + * `node:` builtin has no compiled export table, so it keeps its fence. */ +export function moduleNamespaceDynValue( + lowerer: Lowerer, + ns: IrExpr & { type: IrType & { kind: "moduleNs" } }, +): IrExpr | null { + const getter = moduleNamespaceGetter(lowerer, ns); + if (!getter) return null; + const loc = ns.loc; + const value: IrExpr = { + kind: "libCall", + fn: "module.namespaceDyn", + args: [ + strLit(ns.type.moduleId, loc), + { + kind: "arrayLit", + elems: getter.keys.map((key) => strLit(key, loc)), + type: arrayOf(STRING), + loc, + }, + { + kind: "dynFrom", + value: { + kind: "closure", + fnName: getter.name, + captures: [], + type: funcOf([STRING], DYN), + loc, + }, + type: DYN, + loc, + }, + { + kind: "dynFrom", + value: { + kind: "libCall", + fn: "sym.wellKnown", + args: [strLit("toStringTag", loc)], + type: SYMBOL_T, + loc, + }, type: DYN, loc, }, diff --git a/packages/compiler/src/frontend/lowering/receiver-returns.ts b/packages/compiler/src/frontend/lowering/receiver-returns.ts index 5e83f236a..d1c160d7f 100644 --- a/packages/compiler/src/frontend/lowering/receiver-returns.ts +++ b/packages/compiler/src/frontend/lowering/receiver-returns.ts @@ -7,8 +7,10 @@ * (`%dyn.class.call.N`) that calls such a method already holds a capsule * of the same instance at its exact class: its receiver after * normalization. The helper now calls a copy of the method that returns - * nothing and answers its own receiver, so the call boxes nothing. Other - * callers (vtables, typed code, method values) keep the method itself. */ + * nothing and answers its own receiver, so the call boxes nothing. A + * direct call whose result nothing reads calls the copy too. Other callers + * (vtables, method values, calls that use the result) keep the method + * itself. */ import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js"; import { VOID } from "../../ir/ir.js"; import { varRef } from "../../ir/build.js"; @@ -57,6 +59,20 @@ export function returnDispatchReceivers(lowerer: Lowerer, functions: IrFunction[ ]; } } + // A direct call whose result nothing reads (`builder.set(1);` in typed + // code) needs no capsule at all. + for (const fn of [...functions]) { + everyStmtList(fn.body, { + stmt: (s) => { + if (s.kind === "exprStmt" && s.expr.kind === "call") { + const copy = copyOf(s.expr.callee); + if (copy) s.expr = { ...s.expr, callee: copy.name, type: VOID }; + } + return true; + }, + expr: () => true, + }); + } } /** The helper's receiver is assigned only by its normalization, so it is diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 7f9566425..e46e69434 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -702,6 +702,7 @@ async function compileExecutableNative( ...(features.stdioExtended ? { stdioExtended: true } : {}), ...(features.nodeIsland ? { nodeIsland: true } : {}), ...(features.moduleNamespace ? { moduleNamespace: true } : {}), + ...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}), zlib: features.zlib, ...(features.brotli ? { brotli: true } : {}), assert: features.assert, diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index a0a482b05..45c332d63 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3629,6 +3629,11 @@ function buildLibFnSignatures() { * (scr_module_namespace_value): the module id, its sorted export names * and the compiled getter. One object per module. Never throws. */ "module.namespaceValue": { argTypes: [STRING, arrayOf(STRING), DYN], result: DYN }, + /** A module namespace as typed code sees it through a dynamic slot + * (scr_module_namespace_dyn_value): the module id, its sorted export + * names, the compiled getter, and the Symbol.toStringTag key. Draws + * Node's frozen null-prototype object, one per module. Never throws. */ + "module.namespaceDyn": { argTypes: [STRING, arrayOf(STRING), DYN, DYN], result: DYN }, "fs.callbackValue": { argTypes: [STRING], result: DYN }, "fs.callbackCall": { argTypes: [STRING, DYN], result: DYN }, "process.hrtimeValue": { argTypes: [], result: DYN }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 58fce50b6..ed97ca935 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1197,6 +1197,8 @@ const RUNTIME_FEATURE_RULES = { /** Module namespace objects as checked-dynamic values * (scr_module_namespace.c). */ moduleNamespace: rule({ fns: ["module.namespaceValue"] }), + /** A module namespace boxed for typed code (scr_module_namespace_dyn.c). */ + moduleNamespaceDyn: rule({ fns: ["module.namespaceDyn"] }), /** The WebAssembly API (scr_wasm.c). */ wasm: rule({ prefixes: ["wasm."] }), /** crypto.subtle importKey/sign/verify/digest and CryptoKey: @@ -1296,6 +1298,7 @@ function noRuntimeFeatures(): RuntimeFeatures { urlIdna: false, stdioExtended: false, moduleNamespace: false, + moduleNamespaceDyn: false, stream: false, streamDyn: false, handleProps: false, diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index f66381659..1a1262490 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -34,6 +34,7 @@ const features = { nodeIsland: false, brotli: false, moduleNamespace: false, + moduleNamespaceDyn: false, regex: true, copying: false, textDecoderLegacy: false, diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 2237619ef..ea8295406 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -5130,9 +5130,8 @@ "id": "stdlib.request.clone", "kind": "stdlib", "name": "Request.clone", - "status": "unsupported", - "code": "SC2020", - "note": "Node 24.22.0 / Undici 7.29.1; typed source has no compiler bridge for this interface" + "status": "static", + "note": "Node 24.22.0 / Undici 7.29.1; facets: body-consumption, identity, state-machine, error-shape; differential evidence: fixture:static-clone" }, { "id": "stdlib.request.constructor", @@ -5329,9 +5328,8 @@ "id": "stdlib.response.clone", "kind": "stdlib", "name": "Response.clone", - "status": "unsupported", - "code": "SC2020", - "note": "Node 24.22.0 / Undici 7.29.1; the native fetch runtime does not implement this Response operation" + "status": "static", + "note": "Node 24.22.0 / Undici 7.29.1; facets: body-consumption, identity, state-machine, error-shape; differential evidence: fixture:static-clone" }, { "id": "stdlib.response.constructor", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json new file mode 100644 index 000000000..464e68307 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8412-module-namespace-untyped/main.ts.json @@ -0,0 +1,8 @@ +{ + "order": [ + "/tests/corpus/8412-module-namespace-untyped/mod.ts", + "/tests/corpus/8412-module-namespace-untyped/mod-default.ts", + "/tests/corpus/8412-module-namespace-untyped/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json new file mode 100644 index 000000000..97d131a90 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dynamic-method-receiver-statements.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dynamic-method-receiver-statements.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone-untyped.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone-untyped.mjs.json new file mode 100644 index 000000000..e0bf41cc0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone-untyped.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fetch-clone-untyped.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone.ts.json new file mode 100644 index 000000000..fc2cd158e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/fetch-clone.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/fetch-clone.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json new file mode 100644 index 000000000..1b3fb5b25 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/shorthand-builtin-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json new file mode 100644 index 000000000..1f444c73d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/shorthand-builtin-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/shorthand-builtin-values.ts" + ], + "diags": [] +} diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index bcea05b9c..35ca2f171 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -95,6 +95,8 @@ export const OPTIONAL_RUNTIME_SOURCES = [ ["scr_perf_loop.c", "perfLoop"], // Module namespace objects as checked-dynamic values. ["scr_module_namespace.c", "moduleNamespace"], + // The same namespace as the frozen null-prototype object typed code sees. + ["scr_module_namespace_dyn.c", "moduleNamespaceDyn"], // Blob and File box their reads as dyn promises through scr_async_dyn.c. ["scr_blob.c", "dynAsync"], ["scr_zlib.c", "zlib"], diff --git a/packages/runtime/src/scr_blob.h b/packages/runtime/src/scr_blob.h index 0882e41b6..7c7893322 100644 --- a/packages/runtime/src/scr_blob.h +++ b/packages/runtime/src/scr_blob.h @@ -34,6 +34,13 @@ ScrDyn *scr_process_builtin_unsupported(ScrStr *id, ScrStr *member); * one handle per module `id`, whose reads call `getter` with the export * name when `keys` lists it. Borrows all three; +1 handle. */ ScrDyn *scr_module_namespace_value(ScrStr *id, ScrArr *keys, ScrDyn *getter); +/* A module namespace as checked-dynamic code sees it + * (scr_module_namespace_dyn.c): one non-extensible null-prototype object per + * module `id`, Node's sorted export names as enumerable read-only accessors + * whose getters call `getter` with the name, `Symbol.toStringTag` "Module" + * (the well-known symbol is `tag_key`), and Node's strict-mode write + * TypeErrors. Borrows all four; +1 object. */ +ScrDyn *scr_module_namespace_dyn_value(ScrStr *id, ScrArr *keys, ScrDyn *getter, ScrDyn *tag_key); ScrDyn *scr_global_native(ScrArr *known); ScrDyn *scr_global_native_init(ScrArr *known, ScrDyn *(*console_get)(void)); ScrDyn *scr_console_native(void); diff --git a/packages/runtime/src/scr_module_namespace_dyn.c b/packages/runtime/src/scr_module_namespace_dyn.c new file mode 100644 index 000000000..632a09905 --- /dev/null +++ b/packages/runtime/src/scr_module_namespace_dyn.c @@ -0,0 +1,143 @@ +/* Module namespace objects handed to typed code as checked-dynamic values. + * + * In Node a module namespace is a non-extensible null-prototype object whose + * own properties are the module's exports in sorted order, which are + * enumerable and read-only, whose Symbol.toStringTag is "Module", and whose + * ToPrimitive finds no method. Every generic dynamic operation — + * Object.keys/values/entries/getOwnPropertyNames, for-in, `in`, + * JSON.stringify, identity, member reads and calls — already handles that + * shape through scr_json.c's object + * dispatch, so this unit only builds the object and links only into programs + * that box a namespace. + * + * The exports stay live: each property is an accessor whose getter calls the + * compiled namespace getter with the export name (the same getter the + * JavaScript-source representation uses), and whose setter throws Node's + * strict-mode read-only TypeError. An unknown name is not a member, so the + * object's own not-extensible state answers Node's "Cannot add property". + * One object per module keeps its identity. + * + * Node models the exports as writable data properties on a sealed object, so + * Object.isFrozen answers false and Object.isSealed true. Live reads need + * accessors here, and the accessor shape cannot be sealed without also + * freezing: Object.isFrozen and Object.isExtensible answer as Node's do, + * Object.isSealed does not. */ +#include "scr_runtime.h" + +#include +#include + +typedef struct ScrModuleNamespaceDyn { + ScrStr *id; + ScrDyn *object; /* retained for the program's lifetime */ + struct ScrModuleNamespaceDyn *next; +} ScrModuleNamespaceDyn; + +static SCR_TL ScrModuleNamespaceDyn *scr_module_namespace_dyns; +static SCR_TL bool scr_module_namespace_dyn_cleanup_registered; + +static void scr_module_namespace_dyn_cleanup(void) { + while (scr_module_namespace_dyns) { + ScrModuleNamespaceDyn *ns = scr_module_namespace_dyns; + scr_module_namespace_dyns = ns->next; + scr_str_release(ns->id); + scr_dyn_release(ns->object); + free(ns); + } +} + +/* An export's current value: the compiled getter takes the export name as a + * dyn string and answers it +1. */ +static ScrDyn *scr_module_namespace_dyn_read(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ScrDyn *getter = scr_box_get_ref(clo->caps[0]); + ScrDyn *name = scr_box_get_ref(clo->caps[1]); + ScrDyn *argument = scr_dyn_retain(name); + ScrDyn *value = scr_dyn_call(getter, &argument, 1, "module namespace export"); + scr_dyn_release(argument); + scr_dyn_release(name); + scr_dyn_release(getter); + return value; +} + +/* A namespace export is read-only: Node's strict-mode TypeError names the + * export and the namespace's [object Module] tag. */ +static ScrDyn *scr_module_namespace_dyn_write(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ScrDyn *name = scr_box_get_ref(clo->caps[0]); + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "Cannot assign to read only property '"); + scr_jb_write(&text, name->v.str->data, name->v.str->len); + scr_jb_puts(&text, "' of object '[object Module]'"); + ScrStr *message = scr_jb_finish(&text); + scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len); + scr_str_release(message); + scr_dyn_release(name); + return NULL; +} + +/* `thunk` as a dynamic function over one or two captured dynamic values. */ +static ScrDyn *scr_module_namespace_dyn_fn(ScrDynThunk thunk, ScrDyn *first, ScrDyn *second) { + ScrClosure *closure = scr_closure_new(NULL, second ? 2 : 1); + closure->caps[0] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(closure->caps[0], scr_dyn_retain(first)); + if (second) { + closure->caps[1] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + scr_box_set_ref(closure->caps[1], scr_dyn_retain(second)); + } + return scr_dyn_new_func(closure, thunk, 0, "native:module namespace", "module namespace export"); +} + +/* The namespace object: null prototype, one enumerable read-only accessor per + * export in `keys` order, a non-writable Symbol.toStringTag of "Module", and + * not extensible. */ +static ScrDyn *scr_module_namespace_dyn_build(ScrArr *keys, ScrDyn *getter, ScrDyn *tag_key) { + ScrDyn *object = scr_dyn_new_obj_null_proto(); + for (size_t i = 0; i < keys->len; i++) { + ScrStr *key = scr_arr_get_ref(keys, (double)i); /* +1 */ + ScrDyn *name = scr_dyn_new_str(key); + ScrDyn *read = scr_module_namespace_dyn_fn(scr_module_namespace_dyn_read, getter, name); + ScrDyn *write = scr_module_namespace_dyn_fn(scr_module_namespace_dyn_write, name, NULL); + scr_dyn_obj_set_accessor(object, key->data, key->len, read, write); + scr_dyn_release(name); + scr_str_release(key); + } + ScrStr *tag = scr_str_new("Module", 6); + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "value", 5, scr_dyn_new_str(tag)); + scr_dyn_obj_set(descriptor, "writable", 8, scr_dyn_new_bool(false)); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool(false)); + scr_dyn_obj_set(descriptor, "configurable", 12, scr_dyn_new_bool(false)); + scr_dyn_release(scr_dyn_define_property(object, tag_key, descriptor)); + scr_str_release(tag); + scr_dyn_release(descriptor); + scr_dyn_release(scr_dyn_prevent_extensions(object)); + if (scr_exc_pending()) { + /* The tag key is a well-known symbol, so this cannot fail in practice; a + * pending exception still must not be cached. */ + scr_dyn_release(object); + return NULL; + } + return object; +} + +ScrDyn *scr_module_namespace_dyn_value(ScrStr *id, ScrArr *keys, ScrDyn *getter, ScrDyn *tag_key) { + for (ScrModuleNamespaceDyn *ns = scr_module_namespace_dyns; ns; ns = ns->next) { + if (ns->id->len == id->len && memcmp(ns->id->data, id->data, id->len) == 0) + return scr_dyn_retain(ns->object); + } + if (!scr_module_namespace_dyn_cleanup_registered) { + scr_atexit(scr_module_namespace_dyn_cleanup); + scr_module_namespace_dyn_cleanup_registered = true; + } + ScrDyn *object = scr_module_namespace_dyn_build(keys, getter, tag_key); + if (!object) return NULL; + ScrModuleNamespaceDyn *ns = malloc(sizeof(*ns)); + if (!ns) scr_trap("scriptc: out of memory\n"); + *ns = (ScrModuleNamespaceDyn){ scr_str_retain(id), scr_dyn_retain(object), scr_module_namespace_dyns }; + scr_module_namespace_dyns = ns; + return object; +} diff --git a/tests/corpus/6811-node-island-uncaught-listener/island.cjs b/tests/corpus/6811-node-island-uncaught-listener/island.cjs index 650c7f3f3..ae988f8c0 100644 --- a/tests/corpus/6811-node-island-uncaught-listener/island.cjs +++ b/tests/corpus/6811-node-island-uncaught-listener/island.cjs @@ -1,4 +1,4 @@ exports.timers = () => { - setTimeout(() => console.log("island timer 1"), 15); - setTimeout(() => console.log("island timer 2"), 40); + setTimeout(() => console.log("island timer 1"), 150); + setTimeout(() => console.log("island timer 2"), 250); }; diff --git a/tests/corpus/6811-node-island-uncaught-listener/main.mjs b/tests/corpus/6811-node-island-uncaught-listener/main.mjs index 9195e7baa..05397c2e4 100644 --- a/tests/corpus/6811-node-island-uncaught-listener/main.mjs +++ b/tests/corpus/6811-node-island-uncaught-listener/main.mjs @@ -7,12 +7,14 @@ import { callSync } from "@scriptc/node"; process.on("uncaughtException", (error) => { console.log("caught", error.message); }); +// The island's timers (150 and 250 ms) are scheduled before these (10 and +// 50 ms), with gaps wide enough that a slow run keeps the delay order. callSync("./island.cjs", "timers"); setTimeout(() => { console.log("native timer throws"); throw new Error("native boom"); -}, 5); -setTimeout(() => console.log("native timer after"), 10); +}, 10); +setTimeout(() => console.log("native timer after"), 50); setImmediate(() => { queueMicrotask(() => { throw new Error("from a microtask"); diff --git a/tests/corpus/8412-module-namespace-untyped/main.ts b/tests/corpus/8412-module-namespace-untyped/main.ts new file mode 100644 index 000000000..fcb84a729 --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/main.ts @@ -0,0 +1,77 @@ +// A module namespace taken as a checked-dynamic value: Node's export keys, +// live reads, function typeof, member calls, `in`, for-in, JSON, the +// [object Module] tag, the read-only write TypeErrors and the "Cannot +// convert object to primitive value" coercion, plus `Promise` +// widening into `Promise` and `unknown`. +import * as declared from "./mod.ts"; +import * as expressionDefault from "./mod-default.ts"; + +function keys(value: unknown): string { + return Object.keys(value as any).join(","); +} + +function describe(value: unknown): void { + const dyn = value as any; + console.log("keys", keys(value)); + console.log("kinds", valueKinds(value)); + console.log("names", Object.getOwnPropertyNames(dyn).join(",")); + console.log("alpha", dyn.alpha, typeof dyn.beta, typeof dyn.default); + console.log("call", dyn.beta()); + console.log("tag", Object.prototype.toString.call(dyn)); + console.log("in", "alpha" in dyn, "toString" in dyn); + const forIn: string[] = []; + for (const key in dyn) forIn.push(key); + console.log("for-in", forIn.join(",")); + console.log("json", JSON.stringify(dyn)); + try { + console.log("string", String(dyn)); + } catch (error) { + console.log("string threw", (error as Error).message); + } + try { + dyn.alpha = 2; + console.log("write", dyn.alpha); + } catch (error) { + console.log("write threw", (error as Error).message); + } + try { + dyn.gamma = 2; + console.log("add", dyn.gamma); + } catch (error) { + console.log("add threw", (error as Error).message); + } + console.log("live", dyn.alpha); +} + +function valueKinds(value: unknown): string { + const dyn = value as any; + const kinds: string[] = []; + for (const key of Object.keys(dyn)) kinds.push(typeof dyn[key]); + return kinds.join(","); +} + +function bump(value: unknown): void { + const dyn = value as any; + dyn.bump(); + console.log("after bump", dyn.count); +} + +async function main(): Promise { + // An expression default is snapshotted once by the export statement; + // the boxed namespace's `default` read answers that value, like Node. + const boxedDefault: unknown = expressionDefault; + const dynDefault = boxedDefault as any; + console.log("expression default", dynDefault.default, typeof dynDefault.default); + console.log("expression keys", keys(boxedDefault), expressionDefault.default); + describe(declared); + bump(declared); + const loaded = await import("./mod.ts"); + console.log("same", loaded === declared); + describe(loaded); + const widened: Promise | undefined = import("./mod.ts"); + if (widened) console.log("widened", keys(await widened)); + const boxed: unknown = import("./mod.ts"); + console.log("unknown", keys(await boxed)); +} + +main(); diff --git a/tests/corpus/8412-module-namespace-untyped/mod-default.ts b/tests/corpus/8412-module-namespace-untyped/mod-default.ts new file mode 100644 index 000000000..a0af818a4 --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/mod-default.ts @@ -0,0 +1,4 @@ +// An EXPRESSION default (`export default `): Node evaluates the +// expression once, at the export statement, and the namespace object's +// `default` property answers that snapshotted value. +export default 42; diff --git a/tests/corpus/8412-module-namespace-untyped/mod.ts b/tests/corpus/8412-module-namespace-untyped/mod.ts new file mode 100644 index 000000000..be7be7d4a --- /dev/null +++ b/tests/corpus/8412-module-namespace-untyped/mod.ts @@ -0,0 +1,12 @@ +export const alpha = 1; +export let count = 0; +export function beta(): string { + return "b" + String(count); +} +export function bump(): void { + count += 1; +} +export const obj = { k: alpha }; +export default class Gamma { + readonly tag = "g"; +} diff --git a/tests/corpus/dynamic-method-receiver-statements.mjs b/tests/corpus/dynamic-method-receiver-statements.mjs new file mode 100644 index 000000000..80c125475 --- /dev/null +++ b/tests/corpus/dynamic-method-receiver-statements.mjs @@ -0,0 +1,46 @@ +// Untyped methods that return their receiver, called as statements on +// receivers whose class is known: side effects, throws, overrides, and +// results that are used. +class Builder { + constructor() { + this.parts = []; + this.count = 0; + } + add(part) { + if (part === "") throw new TypeError("empty part"); + this.parts.push(part); + this.count++; + return this; + } + reset() { + this.parts.length = 0; + return this; + } +} +class LoudBuilder extends Builder { + add(part) { + super.add(part.toUpperCase()); + return this; + } +} +const b = new Builder(); +for (let i = 0; i < 1000; i++) { + b.add("p" + (i % 3)); + if (i % 100 === 99) b.reset(); +} +console.log(b.count, b.parts.join(",")); +b.add("x").add("y"); +const same = b.add("z"); +console.log(same === b, b.parts.join(",")); +try { + b.add(""); +} catch (e) { + console.log(e instanceof TypeError, e.message, b.count); +} +const loud = new LoudBuilder(); +loud.add("a"); +loud.add("b").add("c"); +console.log(loud.parts.join(","), loud.count, loud instanceof Builder); +const builders = [new Builder(), new LoudBuilder()]; +for (const each of builders) each.add("q"); +console.log(builders.map((each) => each.parts.join("")).join("|")); diff --git a/tests/corpus/fetch-clone-untyped.mjs b/tests/corpus/fetch-clone-untyped.mjs new file mode 100644 index 000000000..61748d8a0 --- /dev/null +++ b/tests/corpus/fetch-clone-untyped.mjs @@ -0,0 +1,28 @@ +// Response.clone() and Request.clone() in JavaScript on values the checker +// types as Response and Request: the clone's metadata, headers and body +// branch, and Node's TypeErrors for a used body. +const describe = (e) => `${e.name}: ${e.message}`; + +const original = new Response("from js", { status: 202, headers: { "x-c": "3" } }); +const copy = original.clone(); +console.log("meta", copy.status, copy.headers.get("x-c"), copy instanceof Response); +console.log("bodies", await original.text(), await copy.text()); +try { + copy.clone(); +} catch (e) { + console.log("used", describe(e)); +} + +const request = new Request("http://example.com/js", { method: "POST", body: "js body" }); +const requestCopy = request.clone(); +console.log("request", requestCopy.method, requestCopy.url, await requestCopy.text(), await request.text()); +try { + request.clone(); +} catch (e) { + console.log("request used", describe(e)); +} + +const viewed = new Response("viewed"); +const [left] = viewed.clone().body.tee(); +const reader = left.getReader(); +console.log("tee of clone", new TextDecoder().decode((await reader.read()).value), await viewed.text()); diff --git a/tests/corpus/fetch-clone.ts b/tests/corpus/fetch-clone.ts new file mode 100644 index 000000000..d602c9179 --- /dev/null +++ b/tests/corpus/fetch-clone.ts @@ -0,0 +1,80 @@ +// Response.clone() and Request.clone() on typed values: the clone keeps the +// status, status text, URL and a copy of the headers, its body is one +// branch of the original's tee, a used or locked body refuses with Node's +// TypeError, and a Request clone keeps the method, URL, headers and body. +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; + +const describe = (e: unknown): string => `${(e as Error).name}: ${(e as Error).message}`; + +async function responses(): Promise { + const original = new Response("hello", { status: 201, statusText: "Made", headers: { "x-a": "1" } }); + const copy = original.clone(); + console.log("meta", copy.status, copy.statusText, copy.ok, copy.headers.get("x-a"), copy.bodyUsed, copy.body === original.body); + copy.headers.set("x-a", "changed"); + console.log("headers", original.headers.get("x-a"), copy.headers.get("x-a")); + console.log("bodies", await copy.text(), await original.text(), original.bodyUsed, copy.bodyUsed); + try { + original.clone(); + } catch (e) { + console.log("used", describe(e)); + } + + const chained = new Response("chain").clone().clone(); + console.log("chained", await chained.text()); + + const twice = new Response("twice"); + const first = twice.clone(); + const second = twice.clone(); + console.log("twice", await second.text(), await first.text(), await twice.text()); + + const locked = new Response("locked"); + locked.body!.getReader(); + try { + locked.clone(); + } catch (e) { + console.log("locked", describe(e)); + } + + const empty = new Response(null, { status: 204 }); + const emptyCopy = empty.clone(); + console.log("null body", emptyCopy.status, emptyCopy.body === null, await emptyCopy.text() === ""); + + const json = Response.json({ n: 1 }); + const jsonCopy = json.clone(); + console.log("json", JSON.stringify(await jsonCopy.json()), jsonCopy.headers.get("content-type")); +} + +async function requests(): Promise { + const request = new Request("http://example.com/path?q=1", { method: "PUT", body: "payload", headers: { "x-b": "2" } }); + const copy = request.clone(); + console.log("request", copy.method, copy.url, copy.headers.get("x-b"), copy.bodyUsed); + console.log("request bodies", await copy.text(), await request.text()); + try { + request.clone(); + } catch (e) { + console.log("request used", describe(e)); + } + const get = new Request("http://example.com/"); + const getCopy = get.clone(); + console.log("get", getCopy.method, getCopy.body === null, getCopy.redirect); +} + +async function fetched(): Promise { + const server = createServer((_req: IncomingMessage, res: ServerResponse) => { + res.setHeader("x-served", "yes"); + res.write("part one;"); + setTimeout(() => res.end("part two"), 5); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/`); + const copy = response.clone(); + console.log("fetched", copy.status, copy.headers.get("x-served"), copy.url === response.url); + console.log("fetched bodies", await copy.text(), await response.text()); + server.close(); +} + +await responses(); +await requests(); +await fetched(); diff --git a/tests/corpus/shorthand-builtin-values.cjs b/tests/corpus/shorthand-builtin-values.cjs new file mode 100644 index 000000000..eeee0ff4e --- /dev/null +++ b/tests/corpus/shorthand-builtin-values.cjs @@ -0,0 +1,97 @@ +// A builtin global in a shorthand property (`{ Uint8Array }`) holds the +// value the long form (`{ Uint8Array: Uint8Array }`) and a plain read hold. + +const short = { + Uint8Array, + Int8Array, + Uint8ClampedArray, + Uint16Array, + Int16Array, + Uint32Array, + Int32Array, + Float32Array, + Float64Array, + NaN, + Infinity, + String, + Number, + Boolean, + BigInt, + ArrayBuffer, + Array, + Map, + JSON, + Math, + encodeURIComponent, + decodeURI, + crypto, + Buffer, + fetch, + console, + setTimeout, +}; +const long = { + Uint8Array: Uint8Array, + Int8Array: Int8Array, + Uint8ClampedArray: Uint8ClampedArray, + Uint16Array: Uint16Array, + Int16Array: Int16Array, + Uint32Array: Uint32Array, + Int32Array: Int32Array, + Float32Array: Float32Array, + Float64Array: Float64Array, + NaN: NaN, + Infinity: Infinity, + String: String, + Number: Number, + Boolean: Boolean, + BigInt: BigInt, + ArrayBuffer: ArrayBuffer, + Array: Array, + Map: Map, + JSON: JSON, + Math: Math, + encodeURIComponent: encodeURIComponent, + decodeURI: decodeURI, + crypto: crypto, + Buffer: Buffer, + fetch: fetch, + console: console, + setTimeout: setTimeout, +}; + +for (const key of Object.keys(long)) { + const same = Number.isNaN(long[key]) ? Number.isNaN(short[key]) : short[key] === long[key]; + console.log(key, same, typeof short[key]); +} +console.log(short.Uint8Array === Uint8Array, short.Float64Array === Float64Array); +console.log(Object.keys(short).length, Object.keys(short)[0], Object.keys(short)[9]); + +// The typed-array constructors construct and keep their names. +const bytes = new short.Uint8Array([1, 2, 300]); +console.log(bytes, bytes instanceof Uint8Array, short.Uint8Array.name, short.Int32Array.BYTES_PER_ELEMENT); +console.log(new short.Float64Array(2).length, short.Int16Array.from([1, -2])); +console.log(short.NaN !== short.NaN, short.Infinity > 1e308, -short.Infinity); + +// Shorthand destructuring from the global object reads the same values. +function fromGlobal() { + const { Uint8Array, Float32Array, NaN, Infinity, Buffer } = globalThis; + return [Uint8Array === long.Uint8Array, Float32Array === long.Float32Array, Number.isNaN(NaN), Infinity, Buffer === long.Buffer]; +} +console.log(fromGlobal()); + +// A local binding with a builtin's name is the local. +function shadowed() { + const Uint8Array = "local"; + const NaN = 5; + return { Uint8Array, NaN }; +} +console.log(shadowed()); + +// The global object's NaN and Infinity are numbers, read or destructured. +function numbersFromGlobal() { + const { NaN: notANumber, Infinity: infinite } = globalThis; + return [typeof notANumber, notANumber !== notANumber, infinite, infinite === Infinity]; +} +console.log(typeof globalThis.NaN, globalThis.NaN !== globalThis.NaN, globalThis.Infinity, -globalThis.Infinity); +console.log(numbersFromGlobal()); diff --git a/tests/corpus/shorthand-builtin-values.ts b/tests/corpus/shorthand-builtin-values.ts new file mode 100644 index 000000000..ad943b56e --- /dev/null +++ b/tests/corpus/shorthand-builtin-values.ts @@ -0,0 +1,51 @@ +// A builtin global in a shorthand property (`{ Uint8Array }`) holds the +// value the long form (`{ Uint8Array: Uint8Array }`) holds. + +const short: Record = { + Uint8Array, + Int8Array, + Uint8ClampedArray, + Uint16Array, + Int16Array, + Uint32Array, + Int32Array, + Float32Array, + Float64Array, + NaN, + Infinity, + String, + ArrayBuffer, + encodeURIComponent, +}; +const long: Record = { + Uint8Array: Uint8Array, + Int8Array: Int8Array, + Uint8ClampedArray: Uint8ClampedArray, + Uint16Array: Uint16Array, + Int16Array: Int16Array, + Uint32Array: Uint32Array, + Int32Array: Int32Array, + Float32Array: Float32Array, + Float64Array: Float64Array, + NaN: NaN, + Infinity: Infinity, + String: String, + ArrayBuffer: ArrayBuffer, + encodeURIComponent: encodeURIComponent, +}; + +for (const key of Object.keys(long)) { + const value = long[key]; + const same = + typeof value === "number" && Number.isNaN(value) + ? Number.isNaN(short[key]) + : short[key] === value; + console.log(key, same, typeof short[key]); +} + +const numbers = { NaN, Infinity }; +console.log(numbers.NaN !== numbers.NaN, numbers.Infinity > 1e308, -numbers.Infinity); + +const ctors = { Uint8Array, Float64Array }; +const bytes = new ctors.Uint8Array([1, 2, 300]); +console.log(bytes, ctors.Uint8Array.name, new ctors.Float64Array(3).length); diff --git a/tests/fixtures/fetch/static-clone/main.mts b/tests/fixtures/fetch/static-clone/main.mts new file mode 100644 index 000000000..48f3f699a --- /dev/null +++ b/tests/fixtures/fetch/static-clone/main.mts @@ -0,0 +1,44 @@ +// Response.clone() and Request.clone() in a static build: fetched +// responses (a whole body, and one that arrives in chunks across a +// multibyte character), constructed responses and requests, the clone's +// metadata and header copy, a clone of a clone, and Node's TypeErrors for a +// used or locked body. +const base = process.argv[2]!; +const describe = (e: unknown): string => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)); + +const fetched = await fetch(`${base}/text`); +const fetchedCopy = fetched.clone(); +console.log(fetchedCopy.status, fetchedCopy.headers.get("x-kind"), fetchedCopy.url === fetched.url, fetchedCopy.redirected); +console.log(await fetchedCopy.text(), await fetched.text(), fetched.bodyUsed, fetchedCopy.bodyUsed); +try { + fetched.clone(); +} catch (e) { + console.log("used", describe(e)); +} + +const chunked = await fetch(`${base}/chunked`); +const chunkedCopy = chunked.clone().clone(); +console.log(await chunked.text(), await chunkedCopy.text()); + +const made = new Response("made", { status: 203, statusText: "Copied", headers: { "x-a": "1" } }); +const madeCopy = made.clone(); +madeCopy.headers.set("x-a", "2"); +console.log(madeCopy.status, madeCopy.statusText, made.headers.get("x-a"), madeCopy.headers.get("x-a")); +console.log(await made.text(), await madeCopy.text()); + +const locked = new Response("locked"); +locked.body!.getReader(); +try { + locked.clone(); +} catch (e) { + console.log("locked", describe(e)); +} + +const request = new Request(`${base}/post-echo`, { method: "POST", body: "request body", headers: { "x-b": "3" } }); +const requestCopy = request.clone(); +console.log(requestCopy.method, requestCopy.headers.get("x-b"), await requestCopy.text(), await request.text()); +try { + request.clone(); +} catch (e) { + console.log("request used", describe(e)); +} diff --git a/tests/fixtures/node-test/cases/mock-timers.ts b/tests/fixtures/node-test/cases/mock-timers.ts index 1e5e5883f..c31b16aa1 100644 --- a/tests/fixtures/node-test/cases/mock-timers.ts +++ b/tests/fixtures/node-test/cases/mock-timers.ts @@ -271,21 +271,24 @@ describe("imports", () => { it("named and namespace imports keep the loop's timers", async (t) => { t.mock.timers.enable({ apis: ["setTimeout"] }); const log: string[] = []; + // The three timers are real and scheduled one after another, so their + // delays are 100 ms apart: a slow run (a sanitized build on a busy + // machine) still sees them fire in delay order. await Promise.all([ - sleep(5).then(() => { + sleep(300).then(() => { log.push("named promise"); }), new Promise((resolve) => namedSetTimeout(() => { log.push("named"); resolve(); - }, 2), + }, 100), ), new Promise((resolve) => timersNamespace.setTimeout(() => { log.push("namespace"); resolve(); - }, 3), + }, 200), ), ]); t.mock.timers.tick(10); diff --git a/tests/fixtures/node-types/fenced.ts b/tests/fixtures/node-types/fenced.ts index d94c7c19a..33cc56778 100644 --- a/tests/fixtures/node-types/fenced.ts +++ b/tests/fixtures/node-types/fenced.ts @@ -25,7 +25,7 @@ fetch("https://example.invalid/", fetchInit); async function inspectResponse(url: string): Promise { const response = await fetch(url); console.log(response.type); - response.clone(); + response.formData(); } void inspectResponse; ReadableStream.from(new Set([1, 2])); diff --git a/tests/harness/__snapshots__/node-types-fenced.txt b/tests/harness/__snapshots__/node-types-fenced.txt index a7618fa58..5808b9502 100644 --- a/tests/harness/__snapshots__/node-types-fenced.txt +++ b/tests/harness/__snapshots__/node-types-fenced.txt @@ -21,15 +21,15 @@ fenced.ts:27:15 - error SC2020: 'Response.type' is typed by @types/node but has 26 | const response = await fetch(url); 27 | console.log(response.type); | ^~~~~~~~~~~~~ - 28 | response.clone(); + 28 | response.formData(); hint: the member is outside the native static handle projection -fenced.ts:28:3 - error SC2020: 'Response.clone' is typed by @types/node but has no scriptc lowering yet +fenced.ts:28:3 - error SC2020: 'Response.formData' is typed by @types/node but has no scriptc lowering yet 27 | console.log(response.type); - 28 | response.clone(); - | ^~~~~~~~~~~~~~ + 28 | response.formData(); + | ^~~~~~~~~~~~~~~~~ 29 | } hint: the native fetch runtime does not implement this Response operation diff --git a/tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json b/tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json new file mode 100644 index 000000000..6a0493eb5 --- /dev/null +++ b/tests/harness/corpus-costs/8412-module-namespace-untyped/main.ts.json @@ -0,0 +1 @@ +{ "plain": 4451, "san": 57918 } diff --git a/tests/harness/corpus-costs/fetch-clone-untyped.mjs.json b/tests/harness/corpus-costs/fetch-clone-untyped.mjs.json new file mode 100644 index 000000000..b65433b72 --- /dev/null +++ b/tests/harness/corpus-costs/fetch-clone-untyped.mjs.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 48000 } diff --git a/tests/harness/corpus-costs/fetch-clone.ts.json b/tests/harness/corpus-costs/fetch-clone.ts.json new file mode 100644 index 000000000..b65433b72 --- /dev/null +++ b/tests/harness/corpus-costs/fetch-clone.ts.json @@ -0,0 +1 @@ +{ "plain": 2600, "san": 48000 } diff --git a/tests/harness/corpus-costs/shorthand-builtin-values.cjs.json b/tests/harness/corpus-costs/shorthand-builtin-values.cjs.json new file mode 100644 index 000000000..74a3101e8 --- /dev/null +++ b/tests/harness/corpus-costs/shorthand-builtin-values.cjs.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 4000 } diff --git a/tests/harness/corpus-costs/shorthand-builtin-values.ts.json b/tests/harness/corpus-costs/shorthand-builtin-values.ts.json new file mode 100644 index 000000000..f71f3eb98 --- /dev/null +++ b/tests/harness/corpus-costs/shorthand-builtin-values.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 2400 } diff --git a/tests/harness/fetch.test.ts b/tests/harness/fetch.test.ts index 337eafec6..1df7965f6 100644 --- a/tests/harness/fetch.test.ts +++ b/tests/harness/fetch.test.ts @@ -164,6 +164,7 @@ describe(`static fetch differential${sanitize ? " (sanitized)" : ""}`, () => { "static-abort-throw", "static-dispatch-throw", "static-response-json", + "static-clone", ] as const; test.for( staticCases.flatMap((name) => (["llvm"] as const).map((backend) => [name, backend] as const)), diff --git a/tests/harness/static-spatial.test.ts b/tests/harness/static-spatial.test.ts index b0f40ee4e..e9bb23b1c 100644 --- a/tests/harness/static-spatial.test.ts +++ b/tests/harness/static-spatial.test.ts @@ -29,6 +29,9 @@ const cases = [ ].flatMap((fixture) => ["native", "wasm32-wasi"].map((target) => ({ fixture, target }))); test.for(cases)( "published $fixture runs statically on $target", + // Bundling three.js and building it with the sanitizer can pass the + // default 300 s on a busy runner, as in three.test.ts. + { timeout: 1_200_000 }, async ({ fixture, target }, context) => { if (target === "wasm32-wasi" && !hasZig) context.skip(); const dir = await mkdtemp("/tmp/scriptc-spatial-"); diff --git a/tests/harness/surface-manifest.test.ts b/tests/harness/surface-manifest.test.ts index d854db3e0..710cc529b 100644 --- a/tests/harness/surface-manifest.test.ts +++ b/tests/harness/surface-manifest.test.ts @@ -388,7 +388,7 @@ test("fetch interface object bindings retain the inventory fence code", () => { source: '/// \n' + "function headers(value: Headers): void { const { entries } = value; }\n" + - "function response(value: Response): void { const { clone } = value; }\n" + + "function response(value: Response): void { const { blob } = value; }\n" + "function stream(value: ReadableStream): void { const { tee } = value; }\n" + "function assign(value: Headers, entries: unknown): void { ({ entries } = value); }\n" + "void headers; void response; void stream; void assign;\n", @@ -447,7 +447,7 @@ test("unimplemented Response and ReadableStream calls stay fenced", () => { source: '/// \n' + "function response(value: Response): void {\n" + - " void value.clone(); void value.blob(); void value.formData();\n" + + " void value.blob(); void value.formData();\n" + "}\n" + "function stream(value: ReadableStream): void {\n" + " void value.tee(); void value.pipeTo(null as never);\n" + @@ -458,7 +458,7 @@ test("unimplemented Response and ReadableStream calls stay fenced", () => { const coverage = analyze(file, {}).coverage; expect(coverage.preflightFailed).toBe(false); expect([...new Set(coverage.diagnostics.map((d) => d.code))]).toEqual(["SC2020"]); - expect(coverage.diagnostics.filter((d) => d.code === "SC2020")).toHaveLength(5); + expect(coverage.diagnostics.filter((d) => d.code === "SC2020")).toHaveLength(4); } });