Close Next.js proxy gaps: fetch header and URL-error parity, JSON undefined, and twelve compile gaps (#1341)

This commit is contained in:
Chris Tate
2026-10-18 21:54:08 -05:00
committed by GitHub
59 changed files with 1386 additions and 86 deletions
@@ -316,6 +316,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"date.getUTCDate": "scr_date_get_date_utc",
"date.getDay": "scr_date_get_day_local",
"date.getUTCDay": "scr_date_get_day_utc",
"date.toUTCString": "scr_date_to_utc_string",
"date.getHours": "scr_date_get_hours_local",
"date.getUTCHours": "scr_date_get_hours_utc",
"date.getMinutes": "scr_date_get_minutes_local",
@@ -820,6 +821,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
// ambient-this read never throw. The fs dyn read is the sync-fs story.
"fetch.start": "scr_fetch_static",
"fetch.responseNew": "scr_fetch_response_new",
"fetch.responseJsonStatic": "scr_fetch_response_json_static",
"fetch.input": "scr_fetch_input",
"abort.controllerNew": "scr_fetch_abort_controller_new",
"event.targetNew": "scr_event_target_new",
+11 -1
View File
@@ -518,6 +518,15 @@ export const NODE24_FETCH_COMPAT_PROFILE = {
evidence: [fixture("static")],
}),
),
{
id: "stdlib.response.static.json",
name: "Response.json",
kind: "static-method",
facets: ["webidl-conversion", "error-shape"],
scope:
"data that crosses the checked-dynamic boundary, serialized with JSON.stringify (toJSON hooks included); ResponseInit as for the constructor",
evidence: [fixture("static-response-json")],
},
...["json", "text", "bytes", "arrayBuffer"].map((member): FetchCompatOperation => ({
id: `stdlib.response.${member}`,
name: `Response.${member}`,
@@ -681,7 +690,8 @@ export const NODE24_FETCH_COMPAT_PROFILE = {
),
staticEntry("stdlib.response.constructor", "Response", "constructor", "constructor"),
...["error", "json", "redirect"].map((member) =>
staticEntry("stdlib.response.static.json", "Response", "json", "static"),
...["error", "redirect"].map((member) =>
unsupportedEntry(
`stdlib.response.static.${member}`,
"Response",
@@ -34,7 +34,7 @@ const DATE_GETTER_FNS: Readonly<Record<string, IrLibFn>> = {
};
const DATE_METHOD_HINT =
"getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering";
"getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering";
/** The Date slice: statics plus read-only Date values backed by one
* TimeClip'd millisecond scalar. Construction lives in lowerNew;
@@ -113,6 +113,9 @@ export function lowerDateCall(
if (name === "toISOString") {
return { kind: "libCall", fn: "date.toISOStringValue", args: [receiver], type: STRING, loc };
}
if (name === "toUTCString") {
return { kind: "libCall", fn: "date.toUTCString", args: [receiver], type: STRING, loc };
}
const fn = own(DATE_GETTER_FNS, name);
if (fn !== undefined) {
return { kind: "libCall", fn, args: [receiver], type: F64, loc };
@@ -19,6 +19,7 @@ import {
isUnitType,
} from "../../../ir/ir.js";
import { optionalStringTags } from "./arguments.js";
import { constituentTypes } from "../../ts7/checker.js";
/** JSON parse produces checked-dynamic data; a native reviver walks it
* bottom-up before any checked cast. Stringify without a callback keeps
@@ -152,9 +153,18 @@ export function lowerJsonMethodCall(
}
const optionalString = lowerOptionalStringifyRoot(lowerer, value, indent, loc);
if (optionalString) return optionalString;
// A program class instance whose class (or a base) declares toJSON:
// JSON.stringify serializes whatever toJSON(key) returns, so the
// instance crosses into the runtime walker, which calls the hook
// through the checked-dynamic class view.
const toJsonClass =
value.type.kind === "object" &&
lowerer.findMethodOn(lowerer.classes.get(value.type.className) ?? null, "toJSON") !== null &&
lowerer.dynConvertible(value.type);
if (
value.type.kind === "url" ||
value.type.kind === "dyn" ||
toJsonClass ||
isJsonStringifyDynamicType(
value.type,
(id) => lowerer.shapes.get(id),
@@ -191,9 +201,11 @@ export function lowerJsonMethodCall(
type: DYN,
loc,
};
// Preserve the existing dyn-root string ABI, including its textual
// "undefined" result, while letting the runtime honor toJSON hooks.
return value.type.kind === "dyn"
// A dyn root can serialize to undefined (undefined, a function, a
// symbol). Where the program can observe that (jsonResultObserved),
// keep the actual undefined; elsewhere the dyn-root string ABI
// stays, whose undefined result prints the same text.
return (value.type.kind === "dyn" || toJsonClass) && !jsonResultObserved(lowerer, call)
? { kind: "toString", operand: raw, type: STRING, loc }
: { kind: "dynCheck", value: raw, type: lowerer.withUndefinedArm(STRING), loc };
}
@@ -290,6 +302,106 @@ function lowerOptionalStringifyRoot(
return { kind: "call", callee: helper, args: [value], type: resultT, loc };
}
/** True when a `JSON.stringify` result is used where JavaScript can tell
* undefined from the text "undefined": compared with undefined/null,
* `typeof`, `??`, `?.`, truth tests, a return from a function declaring
* an undefined result, or an unannotated local binding whose references
* do one of these. The lib types say `string`, so other uses (concatenation,
* printing, string parameters) cannot tell the difference. */
function jsonResultObserved(lowerer: Lowerer, call: ts.CallExpression): boolean {
let node: ts.Node = call;
while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
if (observesUndefined(node as ts.Expression)) return true;
const parent = node.parent;
if (ts.isReturnStatement(parent)) {
let fn: ts.Node | undefined = parent.parent;
while (fn && !ts.isFunctionLike(fn)) fn = fn.parent;
const annotation = fn && ts.isFunctionLike(fn) ? fn.type : undefined;
if (!annotation) return false;
const type = lowerer.checker.getTypeAtLocation(annotation);
const parts = type.flags & ts.TypeFlags.Union ? constituentTypes(type) : [type];
return parts.some((part) => (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0);
}
// Module-scope bindings are globals whose slot type comes from the
// checker's `string`; only function- and block-scoped locals adopt the
// initializer's checked `string | undefined`.
if (
!ts.isVariableDeclaration(parent) ||
parent.initializer !== node ||
parent.type ||
!ts.isIdentifier(parent.name) ||
(ts.isVariableDeclarationList(parent.parent) &&
ts.isVariableStatement(parent.parent.parent) &&
ts.isSourceFile(parent.parent.parent.parent))
)
return false;
const name = parent.name;
const symbol = lowerer.checker.getSymbolAtLocation(name);
if (!symbol) return false;
let scope: ts.Node = parent;
while (!ts.isBlock(scope.parent) && !ts.isSourceFile(scope.parent) && scope.parent)
scope = scope.parent;
let observed = false;
const visit = (current: ts.Node): void => {
if (observed) return;
if (ts.isIdentifier(current)) {
if (
current !== name &&
current.text === name.text &&
lowerer.checker.getSymbolAtLocation(current) === symbol &&
observesUndefined(current)
)
observed = true;
return;
}
ts.forEachChild(current, visit);
};
visit(scope.parent ?? scope);
return observed;
}
function observesUndefined(expr: ts.Expression): boolean {
let node: ts.Node = expr;
while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
const parent = node.parent;
if (ts.isTypeOfExpression(parent)) return true;
if (ts.isPrefixUnaryExpression(parent) && parent.operator === ts.SyntaxKind.ExclamationToken)
return true;
if (
(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) &&
parent.expression === node &&
parent.questionDotToken
)
return true;
if (
(ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent)) &&
parent.expression === node
)
return true;
if (ts.isConditionalExpression(parent) && parent.condition === node) return true;
if (ts.isBinaryExpression(parent)) {
const op = parent.operatorToken.kind;
if (op === ts.SyntaxKind.QuestionQuestionToken) return parent.left === node;
if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken)
return parent.left === node;
if (
op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
op === ts.SyntaxKind.EqualsEqualsToken ||
op === ts.SyntaxKind.ExclamationEqualsToken
) {
let other: ts.Node = parent.left === node ? parent.right : parent.left;
while (ts.isParenthesizedExpression(other)) other = other.expression;
return (
other.kind === ts.SyntaxKind.NullKeyword ||
(ts.isIdentifier(other) && other.text === "undefined") ||
ts.isVoidExpression(other)
);
}
}
return false;
}
/** True when a class field type holds a RegExp or a Promise outside a
* nested class instance (whose own fields are checked separately): Node
* writes both as {}, and the runtime JSON traversal has no writer for them. */
@@ -13,7 +13,13 @@ import {
typeEquals,
} from "../../ir/ir.js";
import { isJsSourceFile, isNodeEsmFile } from "../program.js";
import { genResultRecord, isAnyArrayType, jsOpenObjectType } from "../type-mapper.js";
import {
genResultRecord,
isAnyArrayType,
isUnitOnlyTsType,
jsOpenObjectType,
unitOnlyUnion,
} from "../type-mapper.js";
import { dynFallbackType } from "./lowerer.js";
import { builtinModuleFnOf } from "./surfaces.js";
import { mixinFnShapeOf } from "./lower-mixins.js";
@@ -380,7 +386,7 @@ export function paramShapes(
const tsType = symbol
? lowerer.checker.getTypeOfSymbol(symbol)
: lowerer.typeOf(declParam.name);
const mapped = jsOpenObjectType(
let mapped = jsOpenObjectType(
declParam,
lowerer.runtimeOptionalBindingType(
declParam.name,
@@ -389,6 +395,11 @@ export function paramShapes(
lowerer.shapes,
lowerer.unions,
);
// An instantiation with a literal `undefined` argument (`id(undefined)`
// binds T = undefined): standalone undefined maps to VOID (the return
// convention), and a parameter is a value position, so it takes the
// unit-only slot like a declared `v: undefined` parameter.
if (mapped?.kind === "void" && isUnitOnlyTsType(tsType)) mapped = unitOnlyUnion(lowerer.unions);
if (!mapped || mapped.kind === "void")
lowerer.badType(blameOf?.(declParam, i) ?? declParam.name, tsType);
if (declParam.dotDotDotToken) {
@@ -1,5 +1,11 @@
import * as ts from "../ts7/adapter.js";
import type { IrExpr, IrFunction } from "../../ir/ir.js";
import {
type IrExpr,
type IrFunction,
type IrType,
callSiteReturnType,
typeEquals,
} from "../../ir/ir.js";
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
import type { ClassInfo } from "./lower-classes.js";
import type { Lowerer } from "./lowerer.js";
@@ -56,6 +62,9 @@ export function devirtualizeVirtualCalls(
class ConstructedClasses {
private readonly lowered = new Set<string>();
/** Lowered bodies' return types: an override may return a subclass
* (`set(): this` resolves per declaring class). */
private readonly returnTypes = new Map<string, IrType>();
/** Classes some retained IR allocates or exposes as a class value. */
private readonly allocated = new Set<string>();
private readonly exportedNames: ReadonlySet<string>;
@@ -74,6 +83,7 @@ class ConstructedClasses {
};
for (const fn of functions) {
this.lowered.add(fn.name);
this.returnTypes.set(fn.name, callSiteReturnType(fn));
everyStmtList(fn.body, {
stmt: () => true,
expr: (e) => {
@@ -102,7 +112,7 @@ class ConstructedClasses {
this.facts.reachable.has(`%${candidate.def.name}.${e.method}`),
)
)
return this.direct(e, info, nearest.declarer);
return this.direct(e, info, nearest.declarer) ?? e;
if (this.open(info)) return e;
const targets: ClassInfo[] = [];
for (const candidate of this.descendants(info)) {
@@ -117,7 +127,7 @@ class ConstructedClasses {
const callee = `%${target.def.name}.${e.method}`;
if (!this.facts.reachable.has(callee) || !this.lowered.has(callee)) return e;
}
if (targets.length === 1) return this.direct(e, info, targets[0]!);
if (targets.length === 1) return this.direct(e, info, targets[0]!) ?? e;
// A small closed set: the backend tests the loaded slot against each
// implementation and calls it directly (no indirect fallback; every
// instance that can reach this site resolves to one of them).
@@ -125,7 +135,19 @@ class ConstructedClasses {
return { ...e, targets: targets.map((t) => t.def.name) };
}
private direct(e: VirtualCall, info: ClassInfo, target: ClassInfo): IrExpr {
/** The direct call, or null when the target's return type cannot stand
* for the site's: an override returning a subclass object (a polymorphic
* `this` return) upcasts to the site's class; any other mismatch keeps
* the virtual call. */
private direct(e: VirtualCall, info: ClassInfo, target: ClassInfo): IrExpr | null {
const callee = `%${target.def.name}.${e.method}`;
const ret = this.returnTypes.get(callee) ?? e.type;
const upcast =
!typeEquals(ret, e.type) &&
ret.kind === "object" &&
e.type.kind === "object" &&
this.lowerer.isSubclassOf(ret.className, e.type.className);
if (!typeEquals(ret, e.type) && !upcast) return null;
const receiver = e.args[0]!;
const typed: IrExpr =
target === info || this.lowerer.isSubclassOf(info.def.name, target.def.name)
@@ -139,13 +161,16 @@ class ConstructedClasses {
type: { kind: "object", className: target.def.name },
loc: receiver.loc,
};
return {
const call: IrExpr = {
kind: "call",
callee: `%${target.def.name}.${e.method}`,
callee,
args: [typed, ...e.args.slice(1)],
type: e.type,
type: ret,
loc: e.loc,
};
return upcast && e.type.kind === "object"
? this.lowerer.upcastTo(call, e.type.className)
: call;
}
/** The class and every class below it, in the isSubclassOf sense
@@ -823,7 +823,7 @@ export function lowerObjectLiteral(
// `k?: ...` — which desugar to one conditional field at the
// spread's own position (any position: they introduce one fresh
// name, collision-fenced below). Everything else stays fenced.
const cs = conditionalSpreadOf(prop.expression);
const cs = conditionalSpreadOf(prop.expression) ?? andSpreadOf(lowerer, prop.expression);
if (cs === "unsupported" || (cs && cs.props.length !== 1)) {
lowerer.unsupported(
"SC1090",
@@ -1264,6 +1264,7 @@ export function lowerObjectLiteral(
expr.properties.length >= 2 &&
ts.isSpreadAssignment(expr.properties[0]!) &&
!conditionalSpreadOf(expr.properties[0]!.expression) &&
!andSpreadOf(lowerer, expr.properties[0]!.expression) &&
expr.properties
.slice(1)
.every(
@@ -1340,7 +1341,7 @@ export function lowerObjectLiteral(
let mergeable = true;
for (const prop of expr.properties) {
if (ts.isSpreadAssignment(prop)) {
const cs = conditionalSpreadOf(prop.expression);
const cs = conditionalSpreadOf(prop.expression) ?? andSpreadOf(lowerer, prop.expression);
if (cs === "unsupported" || (cs && cs.props.length !== 1)) {
lowerer.unsupported(
"SC1090",
@@ -1481,7 +1482,12 @@ export function lowerObjectLiteral(
!shape.tuple &&
shape.fields.length > 0 &&
expr.properties.length > 0 &&
expr.properties.every((p) => ts.isSpreadAssignment(p) && !conditionalSpreadOf(p.expression)) &&
expr.properties.every(
(p) =>
ts.isSpreadAssignment(p) &&
!conditionalSpreadOf(p.expression) &&
!andSpreadOf(lowerer, p.expression),
) &&
expr.properties.some((p) => {
const t = lowerer.mapTypeOf(lowerer.typeOf((p as ts.SpreadAssignment).expression));
return (
@@ -1599,7 +1605,7 @@ export function lowerObjectLiteral(
}
}
if (ts.isSpreadAssignment(prop)) {
const cs = conditionalSpreadOf(prop.expression);
const cs = conditionalSpreadOf(prop.expression) ?? andSpreadOf(lowerer, prop.expression);
if (cs && cs !== "unsupported") {
// `...(c ? { k: v } : {})` — ONE conditional field at the
// spread's own position: cond evaluates exactly once, `v` only
@@ -1728,7 +1734,8 @@ export function lowerObjectLiteral(
const laterNames = new Set<string>();
for (const later of expr.properties.slice(expr.properties.indexOf(prop) + 1)) {
if (ts.isSpreadAssignment(later)) {
if (conditionalSpreadOf(later.expression)) continue;
if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression))
continue;
const lt = laterSpreadType(lowerer, later.expression);
if (lt?.kind === "record") {
// A field the later source may lack defines nothing.
@@ -1963,7 +1970,8 @@ export function lowerObjectLiteral(
const laterNames = new Set<string>();
for (const later of expr.properties.slice(expr.properties.indexOf(prop) + 1)) {
if (ts.isSpreadAssignment(later)) {
if (conditionalSpreadOf(later.expression)) continue;
if (conditionalSpreadOf(later.expression) || andSpreadOf(lowerer, later.expression))
continue;
const lt = laterSpreadType(lowerer, later.expression);
if (lt?.kind === "record") {
// A field the later source may lack defines nothing.
@@ -2517,6 +2525,54 @@ function lowerUnionRecordClone(lowerer: Lowerer, expr: ts.ObjectLiteralExpressio
};
}
/** `...(c && { k: v })`: spreading a falsy primitive (false, 0, "", NaN)
* copies nothing, so the idiom is `...(c ? { k: v } : {})` with `c`
* evaluated once. Recognized only where the spread's own type mixes the
* record with primitive arms; a record or record-or-unit spread keeps the
* ordinary source-copy path. */
export function andSpreadOf(
lowerer: Lowerer,
expr: ts.Expression,
): { cond: ts.Expression; props: CondSpreadProp[]; whenTrue: boolean } | "unsupported" | null {
let e: ts.Expression = expr;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken)
return null;
let right: ts.Expression = e.right;
while (ts.isParenthesizedExpression(right)) right = right.expression;
if (!ts.isObjectLiteralExpression(right)) return null;
const spreadType = lowerer.mapTypeOf(lowerer.typeOf(e));
if (spreadType?.kind !== "union") return null;
const arms = lowerer.unions.get(spreadType.unionId)?.arms ?? [];
if (
arms.filter((arm) => arm.kind === "record").length !== 1 ||
!arms.some((arm) => arm.kind === "bool" || arm.kind === "f64" || arm.kind === "string") ||
!arms.every(
(arm) =>
arm.kind === "record" ||
arm.kind === "bool" ||
arm.kind === "f64" ||
arm.kind === "string" ||
isUnitType(arm),
)
)
return null;
if (right.properties.length === 0) return "unsupported";
const props: CondSpreadProp[] = [];
for (const p of right.properties) {
if (
(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) &&
p.name &&
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))
) {
props.push(p as CondSpreadProp);
} else {
return "unsupported";
}
}
return { cond: e.left, props, whenTrue: true };
}
/** A conditional spread's carrier property: `name: value` or shorthand,
* with an identifier/string-literal name. */
export type CondSpreadProp = (ts.PropertyAssignment | ts.ShorthandPropertyAssignment) & {
@@ -4,6 +4,8 @@ import { DYN, STRING, UNDEFINED_T, VOID, isUnitType, typeEquals } from "../../..
import type { IrExpr, IrStmt, SrcLoc } from "../../../ir/ir.js";
import { isNodeEsmFile, locOf } from "../../program.js";
import type { Lowerer } from "../lowerer.js";
import { tryLowerExpression } from "./try-lower-expression.js";
import { constituentTypes } from "../../ts7/checker.js";
/** One optional-chain STEP: `a?.b`, `a?.m(...)`, `a?.[i]` (the token on
* the member access) and `f?.()` (the token on the call). The receiver
@@ -135,6 +137,31 @@ export function hasOptionalChainGuard(expr: ts.Expression): boolean {
}
}
/** `recv.m` where `m` is a required method the standard library declares
* and `recv` itself is a non-optional, never-nullish typed receiver. */
function alwaysPresentStdlibMethod(lowerer: Lowerer, node: ts.Expression): boolean {
if (!ts.isPropertyAccessExpression(node) || node.questionDotToken) return false;
if (hasOptionalChainGuard(node.expression)) return false;
const symbol = lowerer.checker.getSymbolAtLocation(node.name);
if (!symbol || !(symbol.flags & ts.SymbolFlags.Method) || symbol.flags & ts.SymbolFlags.Optional)
return false;
if (!lowerer.isStdlibSymbol(symbol)) return false;
const nullable =
ts.TypeFlags.Any |
ts.TypeFlags.Unknown |
ts.TypeFlags.Undefined |
ts.TypeFlags.Null |
ts.TypeFlags.Void;
const receiverType = lowerer.checker.getTypeAtLocation(node.expression);
const calleeType = lowerer.checker.getTypeAtLocation(node);
const parts = (type: ts.Type): readonly ts.Type[] =>
type.flags & ts.TypeFlags.Union ? constituentTypes(type) : [type];
return (
parts(receiverType).every((part) => !(part.flags & nullable)) &&
parts(calleeType).every((part) => !(part.flags & nullable))
);
}
export function lowerOptionalChain(
lowerer: Lowerer,
expr: ts.CallExpression | ts.PropertyAccessExpression | ts.ElementAccessExpression,
@@ -181,7 +208,25 @@ export function lowerOptionalChain(
dotNode = tail;
recvNode = tail.expression;
}
const loweredReceiver = lowerer.lowerExpr(recvNode);
// `h.getSetCookie?.()`: a required standard-library METHOD on a
// never-nullish receiver always exists, so `?.()` is the plain call.
// Native handle methods have no first-class value to guard; when the
// method read itself has no lowering, re-dispatch the plain call (which
// reports its own diagnostics) instead of failing on the value read.
let loweredReceiver: IrExpr | null;
if (dotNode === expr && alwaysPresentStdlibMethod(lowerer, recvNode)) {
loweredReceiver = tryLowerExpression(lowerer, recvNode);
if (loweredReceiver === null) {
lowerer.chainHandled.add(dotNode);
try {
return lowerer.lowerExpr(expr);
} finally {
lowerer.chainHandled.delete(dotNode);
}
}
} else {
loweredReceiver = lowerer.lowerExpr(recvNode);
}
const receiver = lowerer.runtimeOptionalSourceValue(recvNode, loweredReceiver) ?? loweredReceiver;
if (receiver.type.kind === "dyn") {
if (dotNode === expr && ts.isCallExpression(expr)) {
@@ -1,6 +1,7 @@
import { guardNullProto, nullProtoRecordShape } from "./null-prototype.js";
import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js";
import { lowerHashStreamCall } from "./builtins/hash-stream.js";
import { lowerResponseStaticJson } from "./lower-stdlib.js";
import { lowerArrayCallbackCall } from "./containers/array-callback-call.js";
/** Dispatch source calls, builtin members and receiver-specific operations.
* Signature collection, argument completion and function bodies have their
@@ -1464,6 +1465,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
// compile-time-known, so a literal key folds to a constant.
lowerClassHasOwnPropertyCall(lowerer, expr, expr.expression) ??
lowerObjectOwnMethodCall(lowerer, expr, expr.expression) ??
// Static Response.json(data, init?) builds a native Response.
lowerResponseStaticJson(lowerer, expr) ??
// Refuse Response constructor operations before generic-call
// fallbacks can report an unrelated SC1090 diagnostic.
lowerer.fenceUnsupportedFetchConstructorMember(expr.expression) ??
@@ -4320,8 +4323,15 @@ function lowerUnionToStringCall(
(a.kind === "bytes" && a.elem === "u8"),
);
if (!stringable) return null;
const operand = lowerer.lowerExpr(access.expression);
let operand = lowerer.lowerExpr(access.expression);
if (operand.type.kind !== "union") return null;
// A flow-narrowed read keeps its wider storage union (`string | number`
// after `!Array.isArray(h.host)` over a `string | number | string[]`
// slot): retag it to the proven union before the per-union helper.
if (operand.type.unionId !== recvT.unionId) {
operand = lowerer.coerceInto(access.expression, operand, recvT);
if (operand.type.kind !== "union" || operand.type.unionId !== recvT.unionId) return null;
}
return { kind: "toString", operand, type: STRING, loc: locOf(call) };
}
@@ -2661,9 +2661,15 @@ export function collectClassShapeInner(
? String(Number(member.name.text))
: member.name.text;
const fieldName = symbolKey?.fieldName ?? stringKey;
// A TypeScript field without an initializer takes its declared
// type and the identifier fields' definite-assignment rule (an
// unconditional `this[key] = ...` at the constructor's top
// level), checked with the unguarded fields below.
const declaredOnly =
!member.initializer && !isJsSourceFile(member.getSourceFile()) && !!member.type;
if (
fieldName === null ||
(!member.initializer && !isJsSourceFile(member.getSourceFile()))
(!member.initializer && !isJsSourceFile(member.getSourceFile()) && !declaredOnly)
) {
lowerer.unsupported(
"SC1090",
@@ -2676,9 +2682,10 @@ export function collectClassShapeInner(
member.type || !member.initializer
? declared
: lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(member.initializer));
const mapped = member.initializer
? (lowerer.mapTypeOf(inferred) ?? dynFallbackType(lowerer, member, inferred))
: DYN;
const mapped =
member.initializer || declaredOnly
? (lowerer.mapTypeOf(inferred) ?? dynFallbackType(lowerer, member, inferred))
: DYN;
let type =
mapped?.kind === "record" && isJsSourceFile(member.getSourceFile()) && !member.type
? DYN
@@ -2690,7 +2697,7 @@ export function collectClassShapeInner(
type.kind === "void" ||
(type.kind === "dyn" &&
!isJsSourceFile(member.getSourceFile()) &&
!lowerer.classImplementedProtocol(inferred))
!lowerer.classFieldStoresDyn(inferred))
)
lowerer.badType(member, inferred);
if (isJsSourceFile(member.getSourceFile())) {
@@ -2698,7 +2705,24 @@ export function collectClassShapeInner(
if (base?.fields.get(fieldName)?.kind === "dyn") type = DYN;
if (base && !fields.has(fieldName) && lowerer.dynConvertible(type)) type = DYN;
}
const undefinedInitializer = !member.initializer ? locOf(member) : undefined;
const undefinedInitializer =
!member.initializer && (!declaredOnly || type.kind === "dyn")
? locOf(member)
: undefined;
if (
declaredOnly &&
type.kind !== "dyn" &&
!(
type.kind === "union" &&
lowerer.unions.get(type.unionId)?.arms.some((a) => a.kind === "undefinedT")
)
) {
unguardedFields.push({
node: member.name,
name: fieldName,
why: `initializer-less computed fields not assigned at the constructor's top level (nothing guarantees '${symbolKey ? symbolFieldDisplayName(fieldName) : fieldName}' is assigned before a read — assign it unconditionally at the top of the constructor, give it an initializer, or include undefined in its type)`,
});
}
const previous = fields.get(fieldName);
if (previous) {
if (symbolKey && !symbolFields.has(symbolKey.identity)) {
@@ -3458,7 +3482,17 @@ export function collectClassShapeInner(
}
let sig: { params: ParamShape[]; ret: IrType };
if (isGet) {
const ret = lowerer.declaredReturnType(member, member.name);
let ret = lowerer.declaredReturnType(member, member.name);
// A getter that never returns a value: typed `never` (it only
// throws, Next.js's removed `page`/`ua` getters) or, unannotated
// with no return statement, inferred `void`. A read answers
// undefined or throws, so the property takes the checked-dynamic
// slot instead of VOID.
if (
ret.kind === "void" &&
(lowerer.typeOf(member.name).flags & (ts.TypeFlags.Never | ts.TypeFlags.Void)) !== 0
)
ret = DYN;
if (ret.kind === "void") lowerer.badType(member.name, lowerer.typeOf(member.name));
sig = { params: [], ret };
} else {
@@ -3605,6 +3639,16 @@ export function collectClassShapeInner(
stmt.expression.left.expression.kind === ts.SyntaxKind.ThisKeyword
) {
topAssigned.add(stmt.expression.left.name.text);
} else if (
ts.isExpressionStatement(stmt) &&
ts.isBinaryExpression(stmt.expression) &&
stmt.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
ts.isElementAccessExpression(stmt.expression.left) &&
stmt.expression.left.expression.kind === ts.SyntaxKind.ThisKeyword
) {
const key = stmt.expression.left.argumentExpression;
const name = classSymbolKeyOf(lowerer, key)?.fieldName ?? lowerer.foldedStringKeyOf(key);
if (name !== null) topAssigned.add(name);
}
}
for (const f of unguardedFields) {
@@ -13,6 +13,7 @@ import {
type IrExpr,
type IrStmt,
type IrType,
type SrcLoc,
STRING,
VOID,
canConvertToDyn,
@@ -1932,11 +1933,39 @@ export function lowerResponseNew(lowerer: Lowerer, expr: ts.NewExpression): IrEx
if (stdlibGlobalNameOf(lowerer, expr.expression) !== "Response") return null;
const args = expr.arguments ?? [];
if (args.some(ts.isSpreadElement)) return null;
const loc = locOf(expr);
return lowerResponseConstruction(lowerer, args, locOf(expr), false);
}
/** Static `Response.json(data, init?)`: the data crosses into the runtime's
* checked-dynamic JSON serializer (toJSON hooks included) after ResponseInit
* converts, then the response initializes as the constructor does. */
export function lowerResponseStaticJson(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null {
if (call.questionDotToken || !ts.isPropertyAccessExpression(call.expression)) return null;
const access = call.expression;
if (access.questionDotToken || access.name.text !== "json") return null;
if (!lowerer.isStdlibGlobal(access.expression, "Response")) return null;
if (call.arguments.some(ts.isSpreadElement) || call.arguments.length === 0) return null;
return lowerResponseConstruction(lowerer, call.arguments, locOf(call), true);
}
function lowerResponseConstruction(
lowerer: Lowerer,
args: readonly ts.Expression[],
loc: SrcLoc,
json: boolean,
): IrExpr {
const toDyn = (node: ts.Expression | undefined, value: IrExpr, what: string): IrExpr => {
if (node === undefined) return dynUndefinedExpr(loc);
if (value.type.kind === "dyn") return value;
if (what === "JSON data") {
const boxed = lowerer.coerceToExpected(value, DYN);
if (boxed.type.kind === "dyn") return boxed;
lowerer.noLowering(
`Response.json of '${lowerer.fmt(value.type)}' values`,
node,
"the data must cross the checked-dynamic boundary (number, string, boolean, null, records, arrays, and 'unknown')",
);
}
/* A user class can satisfy ResponseInit structurally through plain
* fields. Project just the declared dictionary members into a record,
* then use the ordinary checked-dynamic copy. objRecordWidthHelper
@@ -2059,8 +2088,11 @@ export function lowerResponseNew(lowerer: Lowerer, expr: ts.NewExpression): IrEx
}
const result: IrExpr = {
kind: "libCall",
fn: "fetch.responseNew",
args: [toDyn(bodyNode, bodyRef, "a body"), toDyn(initNode, initRef, "an init value")],
fn: json ? "fetch.responseJsonStatic" : "fetch.responseNew",
args: [
toDyn(bodyNode, bodyRef, json ? "JSON data" : "a body"),
toDyn(initNode, initRef, "an init value"),
],
type: DYN,
loc,
};
@@ -1,4 +1,5 @@
import { dynUndefinedExpr, numLit, varRef } from "../../ir/build.js";
import { lowerCollectionSpread } from "./containers/collection-methods.js";
import { lowerEventObjectPropertyWrite } from "./lower-event-target.js";
import { lowerDynObjectLiteral } from "./expressions/object-literals.js";
import { importMetaBindingSource } from "./import-meta.js";
@@ -2515,6 +2516,39 @@ export function lowerBindingPattern(
});
return;
}
// Map and Set sources (`const [[k, v], ...rest] = map`): the iterator
// yields the collection's entries (Map pairs) or values in order, so
// the source snapshots ONCE into that array — the spread's own
// machinery — and the pattern lowers as an array pattern over it,
// like the string case above. Element defaults keep a fence: they
// run between iterator steps and could mutate the live collection.
if (srcType.kind === "map" || srcType.kind === "set") {
const defaulted = pattern.elements.find(
(el): el is ts.BindingElement =>
ts.isBindingElement(el) && el.initializer !== undefined && !el.dotDotDotToken,
);
if (defaulted)
lowerer.unsupported("SC1031", defaulted, "binding defaults over Map/Set sources");
const snapshot = lowerCollectionSpread(
lowerer,
srcRef(),
pattern as unknown as ts.Expression,
);
if (snapshot && snapshot.type.kind === "array") {
const loc = locOf(pattern);
const snapshotT = snapshot.type;
const entries = lowerer.declareHiddenLocal("%dentries", snapshotT);
out.push({ kind: "varDecl", localId: entries.id, init: snapshot, loc });
lowerer.lowerBindingPattern(
pattern,
() => ({ kind: "varRef", localId: entries.id, type: snapshotT, loc }),
snapshotT,
isLet,
out,
);
return;
}
}
if (srcType.kind !== "array") {
lowerer.unsupported(
"SC1031",
@@ -242,6 +242,7 @@ import {
isOverloadedCallableMemberType,
isUnitOnlyTsType,
isWorkerHandleType,
isFetchHandleType,
jsOpenObjectType,
mapType,
ShapeRegistry,
@@ -3113,12 +3114,14 @@ export class Lowerer {
/** Declared class fields may store a DYN value only when its static type
* is `unknown` or `object` (also with `null` or `undefined`, or an array
* of those), names checked protocol identity, or is an owned runtime
* handle (a Worker or MessagePort); `any` fields stay refused. */
* handle (a Worker or MessagePort, or a fetch Headers/Request/Response);
* `any` fields stay refused. */
classFieldStoresDyn(type: ts.Type): boolean {
return (
this.opaqueValueType(type) ||
this.classImplementedProtocol(type) ||
isWorkerHandleType(type, this.typeCtx)
isWorkerHandleType(type, this.typeCtx) ||
isFetchHandleType(type, this.typeCtx)
);
}
@@ -2224,6 +2224,13 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [
fns: ["date.toISOString", "date.toISOStringValue"],
note: "UTC ISO formatting over constructed and stored Date values",
},
{
id: "stdlib.date.toUTCString",
kind: "stdlib",
name: "Date.prototype.toUTCString",
fns: ["date.toUTCString"],
note: "RFC 7231 IMF-fixdate formatting over stored Date values",
},
{
id: "stdlib.date.getFullYear",
kind: "stdlib",
@@ -1336,6 +1336,40 @@ export function isWorkerHandleType(
return handle;
}
/** The native fetch handles (Headers, Request, Response), optionally in
* arrays or with null/undefined: checked-dynamic handles wherever they are
* stored, like the worker handles above. */
export function isFetchHandleType(
type: ts.Type,
ctx: Pick<TypeMapperCtx, "checker" | "isStdlibFile">,
): boolean {
const members = type.isUnionType() ? ts.constituentTypes(type) : [type];
let handle = false;
for (const member of members) {
if (member.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) continue;
if (ctx.checker.isArrayType(member)) {
const element = ctx.checker.getTypeArguments(member as ts.TypeReference)[0];
if (element === undefined || !isFetchHandleType(element, ctx)) return false;
} else {
const symbol = member.getSymbol();
if (
!symbol ||
(symbol.name !== "Headers" && symbol.name !== "Request" && symbol.name !== "Response") ||
!ctx.checker
.declarationsOf(symbol)
.some(
(d) =>
(ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) &&
ctx.isStdlibFile(d.getSourceFile()),
)
)
return false;
}
handle = true;
}
return handle;
}
export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (mapTypeDepth >= MAP_TYPE_MAX_DEPTH) return null;
// These intrinsic domains cannot depend on generic bindings, declaration
@@ -119,6 +119,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"fetch.requestNew",
"fetch.headersNew",
"fetch.responseNew",
"fetch.responseJsonStatic",
"fetch.abortTimeout",
"fetch.abortAny",
"abort.timeout",
@@ -64,6 +64,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* are opaque checked-dynamic handles. */
"fetch.start": { argTypes: [STRING, DYN], result: { kind: "promise", inner: DYN } },
"fetch.responseNew": { argTypes: [DYN, DYN], result: DYN },
"fetch.responseJsonStatic": { argTypes: [DYN, DYN], result: DYN },
"fetch.input": { argTypes: [DYN, DYN], result: { kind: "promise", inner: DYN } },
"abort.controllerNew": { argTypes: [], result: DYN },
/** EventTarget / Event / CustomEvent construction (scr_event_target.c):
@@ -4197,6 +4198,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"date.valueOf": { argTypes: [DATE_T], result: F64 },
"date.toISOString": { argTypes: [F64], result: STRING },
"date.toISOStringValue": { argTypes: [DATE_T], result: STRING },
/** Date.prototype.toUTCString over a stored Date ("Invalid Date" for
* NaN; never throws). */
"date.toUTCString": { argTypes: [DATE_T], result: STRING },
"date.getFullYear": { argTypes: [DATE_T], result: F64 },
"date.getUTCFullYear": { argTypes: [DATE_T], result: F64 },
"date.getMonth": { argTypes: [DATE_T], result: F64 },
+27
View File
@@ -879,6 +879,33 @@ export function typeKey(t: IrType): string {
}
}
/** The type a CALL SITE (or function value) receives from a module
* function: an async body's returnType is the promise's INNER type, so
* calls and closures over it see Promise<T>; a generator body's is the
* TReturn channel, so call sites see the generator type. The one place
* the body/call-site split is spelled out, for the validator and lowering. */
export function callSiteReturnType(fn: IrFunction): IrType {
if (fn.async && fn.generator !== undefined) {
return {
kind: "generator",
async: true,
yieldT: fn.generator.yieldT,
retT: fn.returnType,
nextT: fn.generator.nextT,
};
}
if (fn.async) return { kind: "promise", inner: fn.returnType };
if (fn.generator !== undefined) {
return {
kind: "generator",
yieldT: fn.generator.yieldT,
retT: fn.returnType,
nextT: fn.generator.nextT,
};
}
return fn.returnType;
}
export function typeEquals(a: IrType, b: IrType): boolean {
// Dispatch once: a chain of negative narrowing checks repeatedly retags
// the remaining variants when this comparator runs in the native compiler.
+1 -28
View File
@@ -56,7 +56,7 @@ import {
maybePromiseUnionArmsOk,
VOID,
} from "./ir.js";
import { DYN_CLASS_PROPERTIES } from "./ir.js";
import { DYN_CLASS_PROPERTIES, callSiteReturnType } from "./ir.js";
import { unionWideningTags } from "./analysis.js";
import { alwaysReturns } from "./control-flow.js";
import { dynamicOperationOf, functionTier } from "./tiers.js";
@@ -85,33 +85,6 @@ export interface IrValidationError {
loc: SrcLoc;
}
/** The type a CALL SITE (or function value) receives from a module
* function: an async body's returnType is the promise's INNER type, so
* calls and closures over it see Promise<T>; a generator body's is the
* TReturn channel, so call sites see the generator type. The one place
* the body/call-site split is spelled out in the validator. */
function callSiteReturnType(fn: IrFunction): IrType {
if (fn.async && fn.generator !== undefined) {
return {
kind: "generator",
async: true,
yieldT: fn.generator.yieldT,
retT: fn.returnType,
nextT: fn.generator.nextT,
};
}
if (fn.async) return { kind: "promise", inner: fn.returnType };
if (fn.generator !== undefined) {
return {
kind: "generator",
yieldT: fn.generator.yieldT,
retT: fn.returnType,
nextT: fn.generator.nextT,
};
}
return fn.returnType;
}
interface VirtualCallInfo {
declared: boolean;
hasOverride: boolean;
+9 -3
View File
@@ -4216,6 +4216,13 @@
"status": "static",
"note": "UTC ISO formatting over constructed and stored Date values"
},
{
"id": "stdlib.date.toUTCString",
"kind": "stdlib",
"name": "Date.prototype.toUTCString",
"status": "static",
"note": "RFC 7231 IMF-fixdate formatting over stored Date values"
},
{
"id": "stdlib.date.valueOf",
"kind": "stdlib",
@@ -5293,9 +5300,8 @@
"id": "stdlib.response.static.json",
"kind": "stdlib",
"name": "Response.json",
"status": "unsupported",
"code": "SC2020",
"note": "Node 24.22.0 / Undici 7.29.1; Response static constructor-object operations have no compiler lowering"
"status": "static",
"note": "Node 24.22.0 / Undici 7.29.1; facets: webidl-conversion, error-shape; supported scope: data that crosses the checked-dynamic boundary, serialized with JSON.stringify (toJSON hooks included); ResponseInit as for the constructor; differential evidence: fixture:static-response-json"
},
{
"id": "stdlib.response.static.redirect",
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-devirtualized-this-return.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-fetch-handle-fields.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-never-getter.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-symbol-field-constructor-assigned.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/date-to-utc-string.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/destructure-map-set-iterables.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/fetch-headers-set-cookie-iteration.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/fetch-invalid-url-cause.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/fetch-request-set-cookie-wire.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-undefined-argument.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/json-stringify-class-tojson.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/json-stringify-unknown-undefined-root.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/object-spread-and-conditional.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/optional-call-native-method.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/union-narrowed-tostring.ts"
],
"diags": []
}
+160 -18
View File
@@ -312,6 +312,31 @@ static ScrDyn *sf_type_reason(const char *message, ScrError **error_out) {
return d;
}
/* Request's URL parse failure: with the URL parser's TypeError pending,
* replace it by undici's TypeError "Failed to parse URL from <input>"
* whose (non-enumerable) cause is the parser error. */
static void sf_throw_url_parse_failure(const ScrStr *input) {
ScrCaught *caught = scr_exc_take();
ScrDyn *cause = scr_caught_to_dyn(caught);
scr_caught_release(caught);
/* Sized without the NUL so it stays out of the unit's shared mergeable
* string section (which every program linking this unit carries). */
static const char prefix[25] = "Failed to parse URL from ";
size_t message_len = sizeof prefix + input->len;
char *message = malloc(message_len);
if (!message) sf_oom();
memcpy(message, prefix, sizeof prefix);
memcpy(message + sizeof prefix, input->data, input->len);
ScrStr *text = scr_str_new(message, message_len);
free(message);
ScrError *e = scr_error_new(SCR_ERR_TYPE, text);
scr_str_release(text);
scr_error_set_cause(e, cause);
e->cause_enumerable = false;
scr_dyn_release(cause);
scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg());
}
/* ── AbortSignal ─────────────────────────────────────────────────── */
static void sf_signal_track_reason(SfSignal *s) {
@@ -2386,6 +2411,50 @@ static ScrStr **sf_headers_sorted_names(SfHeaders *h, size_t *count_out) {
return names;
}
static bool sf_headers_is_set_cookie(const ScrStr *name) {
return name->len == 10 && memcmp(name->data, "set-cookie", 10) == 0;
}
/* The `index`th entry of Fetch's "sort and combine" list: names in
* byte order, each with its combined value, except that every set-cookie
* value is its own entry (in insertion order), as Node's Headers
* iterator yields them. False past the end. Outputs are +1. */
static bool sf_headers_sorted_entry(SfHeaders *h, size_t index,
ScrStr **name_out,
ScrStr **value_out) {
size_t count = 0;
ScrStr **names = sf_headers_sorted_names(h, &count);
bool found = false;
size_t at = 0;
for (size_t i = 0; i < count && !found; i++) {
if (!sf_headers_is_set_cookie(names[i])) {
if (at == index) {
*name_out = scr_str_retain(names[i]);
*value_out = sf_headers_get_value(h, names[i]);
found = true;
}
at++;
continue;
}
size_t n = (size_t)scr_arr_len(h->pairs);
for (size_t j = 0; j + 1 < n && !found; j += 2) {
ScrStr *key = scr_arr_get_ref(h->pairs, (double)j);
bool match = sf_headers_is_set_cookie(key);
scr_str_release(key);
if (!match) continue;
if (at == index) {
*name_out = scr_str_retain(names[i]);
*value_out = scr_arr_get_ref(h->pairs, (double)(j + 1));
found = true;
}
at++;
}
}
for (size_t j = 0; j < count; j++) scr_str_release(names[j]);
free(names);
return found;
}
static ScrDyn *sf_headers_invoke(void *ptr, ScrDyn *self,
const char *method,
ScrDyn *const *args, size_t argc,
@@ -2438,20 +2507,15 @@ static ScrDyn *sf_headers_invoke(void *ptr, ScrDyn *self,
* a future name skips it, inserting a later name visits it, and
* inserting before the current index can make the current name appear
* again. The old one-time snapshot was only safe while every native
* Headers handle was immutable. */
* Headers handle was immutable. Set-Cookie values are visited one by
* one (sf_headers_sorted_entry). */
for (size_t i = 0;; i++) {
size_t count = 0;
ScrStr **names = sf_headers_sorted_names(h, &count);
if (i >= count) {
for (size_t j = 0; j < count; j++) scr_str_release(names[j]);
free(names);
break;
}
ScrStr *value = sf_headers_get_value(h, names[i]);
ScrStr *name = NULL;
ScrStr *value = NULL;
if (!sf_headers_sorted_entry(h, i, &name, &value)) break;
ScrDyn *boxed_value = scr_dyn_new_str(value);
ScrDyn *boxed_name = scr_dyn_new_str(names[i]);
for (size_t j = 0; j < count; j++) scr_str_release(names[j]);
free(names);
ScrDyn *boxed_name = scr_dyn_new_str(name);
scr_str_release(name);
ScrDyn *call_args[3] = {boxed_value, boxed_name, self};
scr_dyn_this_push_dyn(this_arg);
ScrDyn *result =
@@ -4415,14 +4479,39 @@ static bool sf_start_hop(SfTransfer *t) {
}
if (connection_close) sf_push_header_text(headers, "connection", "close", 5);
else sf_push_header_text(headers, "connection", "keep-alive", 10);
/* Headers keeps each Set-Cookie value separately, but undici sends its
* header list's combined value: one line, the values joined by ", ",
* at the position of the first one. */
bool set_cookie_sent = false;
for (size_t i = 0; i + 1 < user_len; i += 2) {
ScrStr *name = scr_arr_get_ref(t->headers, (double)i);
ScrStr *value = scr_arr_get_ref(t->headers, (double)(i + 1));
if (sf_eq_ci(name, "host") || sf_eq_ci(name, "connection")) {
if (sf_eq_ci(name, "host") || sf_eq_ci(name, "connection") ||
(set_cookie_sent && sf_eq_ci(name, "set-cookie"))) {
scr_str_release(name);
scr_str_release(value);
continue;
}
if (sf_eq_ci(name, "set-cookie")) {
set_cookie_sent = true;
for (size_t j = i + 2; j + 1 < user_len; j += 2) {
ScrStr *later = scr_arr_get_ref(t->headers, (double)j);
bool match = sf_eq_ci(later, "set-cookie");
scr_str_release(later);
if (!match) continue;
ScrStr *next = scr_arr_get_ref(t->headers, (double)(j + 1));
size_t joined_len = value->len + 2 + next->len;
char *joined = malloc(joined_len ? joined_len : 1);
if (!joined) sf_oom();
memcpy(joined, value->data, value->len);
memcpy(joined + value->len, ", ", 2);
memcpy(joined + value->len + 2, next->data, next->len);
scr_str_release(next);
scr_str_release(value);
value = scr_str_new(joined, joined_len);
free(joined);
}
}
scr_arr_push_ref(headers, name);
scr_arr_push_ref(headers, value);
}
@@ -4578,14 +4667,33 @@ static ScrDyn *sf_response_init_get(
return NULL;
}
ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) {
/* The Response constructor, and Response.json(data, init) when json_data
* is non-NULL: undici converts ResponseInit, serializes the data (TypeError
* "Value is not JSON serializable" when JSON.stringify answers undefined),
* then initializes the response exactly as the constructor does, with
* "application/json" as the default content type. */
typedef ScrDyn *(*SfJsonSerialize)(const ScrDyn *value);
/* The JSON serializer reaches the constructor only through Response.json's
* entry point, so programs that never call it link no JSON walker. */
static ScrDyn *sf_response_json_serialize(const ScrDyn *value) {
ScrStr *gap = scr_str_new("", 0);
ScrDyn *serialized = scr_json_stringify_replacer(value, scr_dyn_undefined(), gap);
scr_str_release(gap);
return serialized;
}
static ScrDyn *sf_response_construct(ScrDyn *body, ScrDyn *init,
const ScrDyn *json_data,
SfJsonSerialize serialize) {
ScrArr *header_pairs = NULL;
ScrArr *converted_headers = NULL;
ScrBytes *body_bytes = NULL;
ScrStr *status_text = NULL;
SfStream *stream = NULL;
bool null_body =
!body || body->kind == SCR_DYN_UNDEF || body->kind == SCR_DYN_NULL;
bool json_body = json_data != NULL;
bool null_body = !json_body &&
(!body || body->kind == SCR_DYN_UNDEF || body->kind == SCR_DYN_NULL);
bool text_body = false;
int status = 200;
bool status_range_error = false;
@@ -4594,7 +4702,7 @@ ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) {
* string arm therefore runs before ResponseInit's dictionary conversion,
* while a ReadableStream is only brand-converted here — its locked/
* disturbed state belongs to the later body-extraction phase. */
if (!null_body &&
if (!null_body && !json_body &&
!(body->kind == SCR_DYN_HANDLE &&
body->v.handle.tag == SCR_DYNH_WEB_STREAM)) {
if (body->kind == SCR_DYN_BYTES) {
@@ -4668,7 +4776,25 @@ ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) {
/* Extract BodyInit before validating the response metadata. This makes a
* locked/disturbed stream win over the later status range, statusText HTTP
* reason-phrase, and HeadersInit validation exactly as in pinned Node. */
if (null_body) {
if (json_body) {
ScrDyn *serialized = serialize(json_data);
if (!serialized) goto fail;
if (serialized->kind != SCR_DYN_STR) {
static const char unserializable[30] = "Value is not JSON serializable";
scr_dyn_release(serialized);
scr_throw_error_msg(SCR_ERR_TYPE, unserializable, sizeof unserializable);
goto fail;
}
ScrStr *encoding = scr_str_new("utf8", 4);
body_bytes = scr_bytes_from_str(serialized->v.str, encoding);
scr_str_release(encoding);
scr_dyn_release(serialized);
stream = sf_stream_new_native();
sf_stream_enqueue_bytes(stream, body_bytes);
sf_stream_close(stream);
scr_bytes_release(body_bytes);
body_bytes = NULL;
} else if (null_body) {
stream = sf_stream_new_native();
sf_stream_close(stream);
} else if (body->kind == SCR_DYN_HANDLE &&
@@ -4719,6 +4845,11 @@ ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) {
sf_push_header_text(header_pairs, "content-type",
"text/plain;charset=UTF-8", 24);
}
if (json_body && !sf_pairs_have(header_pairs, "content-type")) {
static const char json_type[16] = "application/json";
scr_arr_push_ref(header_pairs, scr_str_new("content-type", 12));
scr_arr_push_ref(header_pairs, scr_str_new(json_type, sizeof json_type));
}
SfResponse *response = calloc(1, sizeof *response);
if (!response) sf_oom();
@@ -4744,6 +4875,15 @@ fail:
return NULL;
}
ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) {
return sf_response_construct(body, init, NULL, NULL);
}
ScrDyn *scr_fetch_response_json_static(ScrDyn *data, ScrDyn *init) {
return sf_response_construct(NULL, init, data ? data : scr_dyn_undefined(),
&sf_response_json_serialize);
}
/* Request uses the same body stream and Headers ownership as Response. */
typedef struct SfRequest {
size_t rc;
@@ -4809,6 +4949,7 @@ ScrDyn *scr_fetch_request_new(ScrDyn *input, ScrDyn *init) {
ScrStr *url_text = source ? scr_str_retain(source->url) : scr_dyn_string_coerce_js(input);
if (!url_text) return NULL;
ScrUrl *url = scr_url_new(url_text);
if (!url) sf_throw_url_parse_failure(url_text);
scr_str_release(url_text);
if (!url) return NULL;
if (url->userinfo->len) { scr_url_release(url); sf_type_error("Request cannot be constructed from a URL that includes credentials"); return NULL; }
@@ -5118,6 +5259,7 @@ ScrPromise *scr_fetch_static(ScrStr *url, ScrDyn *init) {
ScrUrl *u = scr_url_new(url);
if (!u) {
sf_throw_url_parse_failure(url);
scr_dyn_release(coerced_body);
scr_arr_release(headers);
scr_str_release(method);
+1
View File
@@ -166,6 +166,7 @@ ScrDyn *scr_fetch_request_new(ScrDyn *input, ScrDyn *init);
ScrDyn *scr_fetch_headers_new(ScrDyn *init);
ScrPromise *scr_fetch_static(ScrStr *url, ScrDyn *init); /* +1 promise<Response handle> */
ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init); /* borrowed args; +1 Response handle or NULL pending */
ScrDyn *scr_fetch_response_json_static(ScrDyn *data, ScrDyn *init); /* Response.json; borrowed args; +1 Response handle or NULL pending */
ScrPromise *scr_fetch_response_json(ScrDyn *response); /* +1 promise<dyn> */
ScrPromise *scr_fetch_response_text(ScrDyn *response); /* +1 promise<dyn> */
ScrPromise *scr_fetch_response_array_buffer(ScrDyn *response); /* +1 promise<dyn> */
+66
View File
@@ -10489,6 +10489,72 @@ ScrStr *scr_date_to_iso(double ms) {
return scr_str_new(buf, (size_t)len);
}
/* Node's Date.prototype.toUTCString over a stored (TimeClip'd) time
* value: "Www, DD Mmm YYYY HH:mm:ss GMT", a "-" and at least four digits
* for negative years, and "Invalid Date" for NaN. Never throws. +1.
* The text is assembled from function-local arrays rather than string
* literals: literals would land in this unit's shared mergeable string
* section and grow every executable, while these arrays are removed with
* the function by --gc-sections when a program never calls it. */
ScrStr *scr_date_to_utc_string(double ms) {
/* Sized without the terminating NUL, so none is a C string the
* compiler could move into a mergeable string section. */
static const char invalid[12] = "Invalid Date";
static const char weekdays[21] = "SunMonTueWedThuFriSat";
static const char months[36] = "JanFebMarAprMayJunJulAugSepOctNovDec";
if (!(fabs(ms) <= 8640000000000000.0)) return scr_str_new(invalid, sizeof invalid);
double t = trunc(ms);
double dayd = floor(t / 86400000.0);
long long msday = (long long)(t - dayd * 86400000.0);
long long days = (long long)dayd;
int weekday = (int)(((days + 4) % 7 + 7) % 7);
long long z = days + 719468;
long long era = (z >= 0 ? z : z - 146096) / 146097;
unsigned long long doe = (unsigned long long)(z - era * 146097);
unsigned long long yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
long long y = (long long)yoe + era * 400;
unsigned long long doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
unsigned long long mp = (5 * doy + 2) / 153;
unsigned long long d = doy - (153 * mp + 2) / 5 + 1;
unsigned long long m = mp < 10 ? mp + 3 : mp - 9;
if (m <= 2) y += 1;
int hh = (int)(msday / 3600000), mi = (int)(msday / 60000 % 60),
ss = (int)(msday / 1000 % 60);
char buf[40];
size_t n = 0;
memcpy(buf, weekdays + 3 * weekday, 3);
n = 3;
buf[n++] = ',';
buf[n++] = ' ';
buf[n++] = (char)('0' + d / 10);
buf[n++] = (char)('0' + d % 10);
buf[n++] = ' ';
memcpy(buf + n, months + 3 * (m - 1), 3);
n += 3;
buf[n++] = ' ';
if (y < 0) buf[n++] = '-';
unsigned long long year = (unsigned long long)(y < 0 ? -y : y);
char digits[20];
size_t count = 0;
do {
digits[count++] = (char)('0' + year % 10);
year /= 10;
} while (year > 0);
while (count < 4) digits[count++] = '0';
while (count > 0) buf[n++] = digits[--count];
int parts[3] = {hh, mi, ss};
for (int i = 0; i < 3; i++) {
buf[n++] = i == 0 ? ' ' : ':';
buf[n++] = (char)('0' + parts[i] / 10);
buf[n++] = (char)('0' + parts[i] % 10);
}
buf[n++] = ' ';
buf[n++] = 'G';
buf[n++] = 'M';
buf[n++] = 'T';
return scr_str_new(buf, n);
}
/* ── new Date(dateString).getTime() ──────────────────────────────────────
*
* The BOUNDED date-string parse (documented divergence — V8's parser
+3
View File
@@ -102,6 +102,9 @@ double scr_date_get_time(double ms);
* 0–9999). THROWS Node's "Invalid time value" RangeError on NaN or
* |ms| > 8.64e15 and returns NULL; +1 otherwise. */
ScrStr *scr_date_to_iso(double ms);
/* Node's Date.prototype.toUTCString ("Thu, 01 Jan 1970 00:00:00 GMT";
* "Invalid Date" for NaN). Never throws; +1. */
ScrStr *scr_date_to_utc_string(double ms);
/* new Date(dateString).getTime(): the BOUNDED parse (the X509 validity
* shape + ECMA's format with an explicit offset); NaN elsewhere. */
double scr_date_parse_get_time(ScrStr *s);
@@ -0,0 +1,27 @@
// A base method calling `this.set()` where only a subclass is ever
// constructed devirtualizes to the subclass override; a polymorphic `this`
// return resolves per declaring class, so the direct call's subclass
// result upcasts to the base method's type (was an internal compiler error).
class Cookies {
values: string[] = [];
set(value: string): this {
this.values.push(value);
return this;
}
delete(name: string): this {
return this.set(`${name}=; Max-Age=0`);
}
}
class MirroredCookies extends Cookies {
mirror: string[] = [];
override set(value: string): this {
super.set(value);
this.mirror.push(value.toUpperCase());
return this;
}
}
const cookies = new MirroredCookies();
cookies.set("a=1").delete("b").set("c=3");
console.log(cookies.values.join(" | "));
console.log(cookies.mirror.join(" | "));
console.log(cookies.delete("d") === cookies, cookies.values.length);
+36
View File
@@ -0,0 +1,36 @@
// Class fields typed as native fetch handles (Headers, Request, Response),
// directly, optional, nullable, in arrays, and inside record fields, hold
// the live handle: mutation through one reference is seen through another.
class Holder {
headers: Headers;
list: Headers[] = [];
maybe?: Response;
req: Request | null = null;
bag: { headers: Headers; status: number };
constructor(headers: Headers) {
this.headers = headers;
this.bag = { headers, status: 1 };
}
}
const holder = new Holder(new Headers({ a: "1" }));
console.log(holder.headers.get("a"), holder.list.length, holder.maybe === undefined, holder.req);
holder.list.push(new Headers({ b: "2" }));
holder.maybe = new Response("x", { status: 201 });
holder.req = new Request("http://localhost/p", { method: "POST" });
console.log(holder.list[0]!.get("b"), holder.maybe.status, holder.req.method, holder.req.url);
holder.headers.set("c", "3");
console.log(holder.bag.headers.get("c"), holder.bag.headers === holder.headers);
// Symbol-keyed fields hold the handles the same way.
const NATIVE = Symbol("native");
class Wrapper {
[NATIVE]: Response;
constructor(body: string) {
this[NATIVE] = new Response(body, { status: 202 });
}
get status(): number {
return this[NATIVE].status;
}
}
const wrapped = new Wrapper("ok");
console.log(wrapped.status, wrapped[NATIVE].headers.get("content-type"));
+36
View File
@@ -0,0 +1,36 @@
// A getter that never returns a value compiles: annotated `never` or
// unannotated (the removed-API getter idiom, which throws), and a getter
// without a return statement, whose reads answer undefined.
class Req {
ok = 1;
get page(): never {
throw new Error("page has been removed");
}
get ua(): never {
throw new TypeError("ua has been removed");
}
public get legacy() {
throw new RangeError("legacy has been removed");
}
get nothing() {
return;
}
}
const r = new Req();
try {
console.log(r.page);
} catch (e) {
console.log(e instanceof Error ? `${e.name}: ${e.message}` : e);
}
try {
const ua = r.ua;
console.log("unreachable", ua);
} catch (e) {
console.log(e instanceof Error ? `${e.name}: ${e.message}` : e);
}
try {
console.log(r.legacy);
} catch (e) {
console.log(e instanceof Error ? `${e.name}: ${e.message}` : e);
}
console.log(r.ok, r.nothing);
@@ -0,0 +1,43 @@
// Symbol-keyed (and literal-keyed) TypeScript fields without an
// initializer take their declared type when the constructor assigns them
// unconditionally; an optional one starts undefined. A field assigned
// later (here from a method the constructor calls) reads through the
// checked deferred-initialization slot.
const Internal = Symbol("internal");
const Registered = Symbol.for("pkg.registered");
const Pending = Symbol("pending");
class Box {
[Internal]: { n: number };
[Registered]: string[];
["literal"]: number;
[Pending]?: string[];
constructor(n: number) {
this[Internal] = { n };
this[Registered] = ["a"];
this["literal"] = n * 2;
}
get n(): number {
return this[Internal].n;
}
}
const b = new Box(5);
console.log(b.n, b[Registered].length, b.literal, b[Pending] === undefined);
b[Internal].n = 7;
b[Registered].push("b");
b[Pending] = ["p"];
console.log(b.n, b[Registered].join(","), b[Pending]?.join(","));
console.log(JSON.stringify(Object.entries({ literal: b.literal })));
class Late {
[Internal]!: { n: number };
constructor(flag: boolean) {
if (flag) this[Internal] = { n: 1 };
else this.init();
}
init(): void {
this[Internal] = { n: 2 };
}
}
console.log(new Late(true)[Internal].n, new Late(false)[Internal].n);
+26
View File
@@ -0,0 +1,26 @@
// Date.prototype.toUTCString: "Www, DD Mmm YYYY HH:mm:ss GMT" in UTC, a
// leading "-" and at least four digits for negative years, "Invalid Date"
// for an invalid time value.
const times = [
0,
-1,
1445412480000,
951782400000, // 2000-02-29
Date.UTC(1969, 11, 31, 23, 59, 59, 999),
Date.UTC(0, 0, 1) - 1900 * 365.2425 * 86400000,
Date.UTC(-1, 5, 15, 12, 30, 45),
Date.UTC(-271821, 3, 20),
8.64e15,
-8.64e15,
1234.9,
-1234.9,
NaN,
8.64e15 + 1,
];
for (const t of times) {
const d = new Date(t);
console.log(d.toUTCString());
}
for (let day = 0; day < 7; day++) console.log(new Date(Date.UTC(2024, 0, day + 1)).toUTCString());
for (let month = 0; month < 12; month++) console.log(new Date(Date.UTC(2023, month, 9, 8, 7, 6)).toUTCString());
console.log(new Date("2015-10-21T07:28:00Z").toUTCString(), new Date("garbage").toUTCString());
@@ -0,0 +1,18 @@
// Array destructuring of a Map or Set walks its iterator: Map entries as
// [key, value] pairs, Set values, holes and rest included.
const m = new Map([["a", "1"], ["b", "2"], ["c", "3"]]);
const [[name, value], ...rest] = m;
console.log(name, value, rest.length, JSON.stringify(rest));
const [first, , third] = m;
console.log(JSON.stringify(first), JSON.stringify(third));
const s = new Set([3, 1, 2]);
const [x, y, ...others] = s;
console.log(x, y, others);
let [p] = new Map<string, number>([["only", 1]]);
p = ["changed", 2];
console.log(p);
function firstKey(map: Map<string, number>): string {
const [[k]] = map;
return k;
}
console.log(firstKey(new Map([["z", 26]])));
@@ -0,0 +1,63 @@
// Headers iteration follows Fetch's "sort and combine": names in byte
// order with their combined values, except that every Set-Cookie value is
// visited on its own. get() still joins them; getSetCookie() lists them.
const h = new Headers();
h.append("set-cookie", "a=1; Path=/");
h.append("x-b", "1");
h.append("Set-Cookie", "b=2, c=3");
h.append("x-a", "1");
h.append("x-a", "2");
h.append("set-cookie", "d=4");
h.forEach((value, name) => console.log(`${name}: ${value}`));
console.log(JSON.stringify(h.get("set-cookie")));
console.log(JSON.stringify(h.getSetCookie()));
// A thisArg and the Headers object reach the callback.
const self = { tag: "this" };
h.forEach(function (this: typeof self, _value, name, headers) {
if (name === "x-b") console.log(this.tag, headers === h);
}, self);
// Iteration is live and indexes the expanded list: deleting the cookies
// while visiting the first one shifts the next name into its slot.
const live = new Headers();
live.append("set-cookie", "a=1");
live.append("set-cookie", "b=2");
live.append("x-z", "z");
const seen: string[] = [];
live.forEach((value, name) => {
seen.push(`${name}=${value}`);
if (value === "a=1") live.delete("set-cookie");
});
console.log(seen.join(" | "));
// Appending a cookie during the walk is visited when it sorts later.
const grow = new Headers();
grow.append("set-cookie", "a=1");
grow.append("x-y", "y");
const grown: string[] = [];
grow.forEach((value, name) => {
grown.push(`${name}=${value}`);
if (value === "a=1") grow.append("set-cookie", "b=2");
});
console.log(grown.join(" | "));
// Only set-cookie stays separate; cookie and other repeats combine.
const plain = new Headers([
["cookie", "a=1"],
["cookie", "b=2"],
["accept", "x"],
["accept", "y"],
]);
plain.forEach((value, name) => console.log(`${name}: ${value}`));
console.log(JSON.stringify(plain.getSetCookie()));
// A response's headers walk the same way.
const response = new Response("ok", {
headers: [
["set-cookie", "s=1"],
["set-cookie", "t=2"],
["content-type", "text/plain"],
],
});
response.headers.forEach((value, name) => console.log(`${name}: ${value}`));
+41
View File
@@ -0,0 +1,41 @@
// An unparsable fetch() or Request URL is undici's TypeError
// "Failed to parse URL from <input>", whose non-enumerable cause is the
// URL parser's TypeError.
function report(label: string, e: unknown): void {
const serialized = JSON.stringify(e);
if (!(e instanceof Error)) {
console.log(label, "not an Error");
return;
}
console.log(label, e.name, e.message, e instanceof TypeError);
console.log(" has cause:", "cause" in e, "own enumerable:", serialized);
console.log(
" cause:",
e.cause instanceof Error ? `${e.cause.name} ${e.cause.message} ${e.cause instanceof TypeError}` : String(e.cause),
);
}
async function main(): Promise<void> {
for (const input of ["http://127.0.0.1:1http://x/", "/relative/path", "", "http://[::1"]) {
try {
await fetch(input);
console.log("resolved?");
} catch (e) {
report(`fetch(${JSON.stringify(input)})`, e);
}
}
try {
new Request("not a url");
console.log("constructed?");
} catch (e) {
report("new Request", e);
}
// A valid URL with credentials keeps its own message.
try {
await fetch("http://user:pass@127.0.0.1:1/");
} catch (e) {
report("credentials", e);
}
}
void main();
@@ -0,0 +1,44 @@
// fetch() sends its header list's combined values: repeated Set-Cookie
// request headers go out as one line, the values joined by ", ", at the
// position of the first one, whatever form the headers were given in.
import { createServer } from "node:http";
function shown(raw: string[]): string {
const lines: string[] = [];
for (let i = 0; i < raw.length; i += 2) {
const name = raw[i]!;
const lower = name.toLowerCase();
if (lower === "host" || lower === "connection" || lower.startsWith("accept") || lower.startsWith("sec-") || lower === "user-agent") continue;
lines.push(`${name}: ${raw[i + 1]}`);
}
return lines.join(" | ");
}
const server = createServer((req, res) => {
res.end(shown(req.rawHeaders));
});
server.listen(0, "127.0.0.1", async () => {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const url = `http://127.0.0.1:${port}/`;
const headers = new Headers();
headers.append("set-cookie", "a=1");
headers.append("x-first", "1");
headers.append("set-cookie", "b=2");
console.log(await (await fetch(url, { headers })).text());
const pairs: [string, string][] = [
["x-before", "0"],
["set-cookie", "c=3"],
["set-cookie", "d=4; Path=/"],
];
console.log(await (await fetch(url, { headers: pairs })).text());
console.log(await (await fetch(url, { headers: { "set-cookie": "e=5", "x-record": "r" } })).text());
const single = new Headers({ "set-cookie": "only=1" });
console.log(await (await fetch(url, { headers: single })).text());
server.close();
});
@@ -0,0 +1,19 @@
// A generic call with a literal `undefined` argument binds T = undefined;
// the parameter holds undefined like a declared `v: undefined` parameter.
function kind<T>(v: T): string {
return typeof v;
}
console.log(kind(undefined), kind(null), kind(1), kind("s"));
function same<T>(a: T, b: T): boolean {
return a === b;
}
console.log(same(undefined, undefined));
function wrap<T>(v: T): { v: T } {
return { v };
}
const w = wrap(undefined);
console.log(JSON.stringify(w), w.v === undefined, "v" in w);
function describe<T>(label: string, v: T): string {
return `${label}=${String(v)}`;
}
console.log(describe("u", undefined));
@@ -0,0 +1,44 @@
// JSON.stringify of a class instance whose class (or a base) declares
// toJSON serializes what toJSON(key) returns: the root key is "", nested
// hooks run through the same walker, and a toJSON answering undefined
// makes the whole result undefined.
class Url {
href: string;
constructor(href: string) {
this.href = href;
}
toJSON(): string {
return this.href;
}
}
class NextUrl extends Url {
base = "ignored";
}
class Keyed {
toJSON(key: string): { key: string; at: Url[] } {
return { key, at: [new Url("n")] };
}
}
class Missing {
toJSON(): undefined {
return undefined;
}
}
console.log(JSON.stringify(new Url("http://x/")));
console.log(JSON.stringify(new NextUrl("http://y/")));
console.log(JSON.stringify(new Keyed()));
console.log(JSON.stringify(new Keyed(), null, 2));
function serialize(value: Missing): string {
const text = JSON.stringify(value);
return text === undefined ? "undefined result" : `text ${text}`;
}
console.log(serialize(new Missing()), JSON.stringify(new Missing()) === undefined);
let calls = 0;
class Counted {
toJSON(): number {
calls++;
return calls;
}
}
const c = new Counted();
console.log(JSON.stringify(c), JSON.stringify(c), calls);
@@ -0,0 +1,38 @@
// JSON.stringify of an unknown (or generic) root whose value is undefined,
// a function, or a symbol returns undefined, not text. Wherever the program
// can tell the two apart (undefined/null comparisons, typeof, ??, ?., truth
// tests, returns declaring undefined, and bindings used that way) the
// native result is the actual undefined. Nested values follow Node's
// member rules: dropped from objects, null in arrays.
const u: unknown = undefined;
const f: unknown = () => 1;
const s: unknown = Symbol("x");
const ok: unknown = { a: [1, undefined] };
for (const v of [u, f, s, ok]) {
const t = JSON.stringify(v);
console.log(t === undefined, typeof t, t ?? "fb", t?.length, t == null, t !== undefined);
}
for (const v of [u, ok]) {
const t = JSON.stringify(v);
if (t) console.log("truthy", t.length);
else console.log("falsy");
console.log(!t, t ? "y" : "n", t || "or", t && "and");
}
console.log(JSON.stringify(u) === undefined, typeof JSON.stringify(f), JSON.stringify(s) ?? "fb2", JSON.stringify(u)?.length);
console.log(JSON.stringify(u) || "or2", JSON.stringify(ok) || "or3", !JSON.stringify(u));
function raw<T>(v: T): string | undefined {
return JSON.stringify(v);
}
console.log(typeof raw(u), raw(ok));
function ser<T>(v: T): string {
const text = JSON.stringify(v);
if (text === undefined) return "undefined result";
return `string ${text} ${text.length}`;
}
console.log(ser(u), ser({ a: 1 }), ser(1), ser("s"), ser(ok));
// Unobserved uses keep the plain string path.
const plain = JSON.stringify(ok);
console.log(plain.length, plain, `${JSON.stringify(u)}`, "x" + JSON.stringify(u));
// A module-scope binding keeps the string slot; direct uses still observe.
const top = JSON.stringify(ok);
console.log(top.length, JSON.stringify(u) === undefined);
@@ -0,0 +1,37 @@
// `...(cond && { k: v })` spreads nothing when cond is a falsy primitive
// (false, 0, ""), so it is the optional-field idiom `...(cond ? { k: v } : {})`:
// cond evaluates once, v only when taken. (Fields print one by one: the
// declared-shape property order of spread-built records is a documented
// limitation.)
type C = { name: string; name2: string; httpOnly?: boolean; maxAge?: number; path?: string; sameSite?: string };
function parse(attrs: Record<string, string | undefined>, flag: boolean): C {
const httponly = attrs.httponly;
const maxage = attrs["max-age"];
const samesite = attrs.samesite;
let calls = 0;
const lower = (s: string): string => {
calls++;
return s.toLowerCase();
};
const c: C = {
name: "a",
...(flag && { httpOnly: true }),
...(httponly && { path: httponly }),
...(typeof maxage === "string" && { maxAge: Number(maxage) }),
name2: "/",
...(samesite && { sameSite: lower(samesite) }),
};
console.log("calls", calls);
return c;
}
for (const [attrs, flag] of [
[{}, false],
[{ httponly: "yes", "max-age": "10", samesite: "Lax" }, true],
[{ httponly: "", "max-age": "", samesite: "" }, true],
] as [Record<string, string | undefined>, boolean][]) {
const c = parse(attrs, flag);
console.log(c.name, c.name2, c.httpOnly, c.path, c.maxAge, c.sameSite, "httpOnly" in c, "path" in c);
}
const n: number = Number(process.argv.length > 99 ? "1" : "0");
const d: { v?: number; name: string } = { name: "n", ...(n && { v: n }) };
console.log(d.name, d.v, "v" in d);
@@ -0,0 +1,28 @@
// An optional call of a standard-library method that always exists on a
// never-nullish receiver is the plain call (`getSetCookie?.()` is always
// present on Node 24).
const h = new Headers();
h.append("set-cookie", "a=1");
h.append("set-cookie", "b=2");
const cookies = h.getSetCookie?.() ?? [];
console.log(JSON.stringify(cookies));
console.log(h.get?.("set-cookie"));
console.log(h.has?.("x-missing"));
h.set?.("x-set", "1");
h.forEach?.((value, name) => console.log(`${name}=${value}`));
// The receiver evaluates once; arguments evaluate after it.
let reads = 0;
function headers(): Headers {
reads++;
return h;
}
function arg(): string {
console.log("argument after receiver", reads);
return "x-set";
}
console.log(headers().get?.(arg()), reads);
// Strings and arrays: the same rule.
const s = "Hello";
console.log(s.toUpperCase?.(), [3, 1, 2].sort?.().join(","));
+27
View File
@@ -0,0 +1,27 @@
// .toString() on a flow-narrowed union whose storage union is wider: the
// read retags to the proven arms, then formats like String().
import type { OutgoingHttpHeaders } from "node:http";
function host(headers?: OutgoingHttpHeaders): string {
if (headers?.host && !Array.isArray(headers.host)) return headers.host.toString();
return "";
}
console.log(JSON.stringify([host({ host: "a:1" }), host({ host: 3 }), host({}), host()]));
function label(v: string | number | string[] | undefined): string {
if (v === undefined) return "none";
if (Array.isArray(v)) return `list(${v.length})`;
return v.toString();
}
console.log(label("x"), label(1.5), label(-0), label(["a", "b"]), label(undefined));
function flag(v: boolean | number | null): string {
return v === null ? "null" : v.toString();
}
console.log(flag(true), flag(false), flag(1e21), flag(null));
type Field = string | number | { nested: string };
function field(f: Field): string {
return typeof f === "object" ? f.nested : f.toString();
}
console.log(field("s"), field(42), field({ nested: "n" }));
+30
View File
@@ -0,0 +1,30 @@
// Response.json(data, init): ResponseInit converts, the data serializes with
// JSON.stringify (TypeError when it answers undefined), then the response
// initializes like the constructor, defaulting content-type to
// application/json.
function show(label: string, make: () => Response): void {
try {
const r = make();
console.log(label, r.status, JSON.stringify(r.statusText), r.headers.get("content-type"), r.headers.get("x-a"));
} catch (e) {
console.log(label, e instanceof Error ? `${e.name}: ${e.message}` : String(e));
}
}
show("plain", () => Response.json({ ok: true }));
show("init", () => Response.json([1, 2], { status: 201, statusText: "Made", headers: { "x-a": "1" } }));
show("content-type", () => Response.json("s", { headers: { "content-type": "text/x" } }));
show("headers object", () => Response.json(null, { headers: new Headers([["x-a", "h"]]) }));
show("undefined", () => Response.json(undefined));
const fn: unknown = () => 1;
show("function", () => Response.json(fn));
show("range", () => Response.json({}, { status: 99 }));
show("undefined before range", () => Response.json(undefined, { status: 99 }));
show("null body status", () => Response.json({}, { status: 204 }));
show("bad header", () => Response.json({}, { headers: { "bad name": "x" } }));
show("undefined before bad header", () => Response.json(undefined, { headers: { "bad name": "x" } }));
show("bad statusText", () => Response.json({}, { statusText: "a\nb" }));
const data: unknown = JSON.parse('{"a":[1,"x",null],"n":{"deep":true}}');
const r = Response.json({ data, d: new Date(0), skip: undefined });
console.log(r.bodyUsed, await r.text(), r.bodyUsed);
const parsed: unknown = await Response.json({ round: [1, 2] }).json();
console.log(JSON.stringify(parsed));
@@ -5,7 +5,7 @@ date-formatters.ts:4:1 - error SC2020: 'Date.prototype.toString' is part of the
| ^~~~~~~~~~~~~~~
5 | date.toLocaleString();
hint: getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering
hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering
date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part of the standard library types but has no scriptc lowering yet
@@ -14,4 +14,4 @@ date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part o
| ^~~~~~~~~~~~~~~~~~~~~
6 |
hint: getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering
hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering
@@ -66,7 +66,7 @@ stdlib-fence.ts:25:1 - error SC2020: 'Date.prototype.setUTCFullYear with argumen
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
26 | const sameDate = mutableDate === mutableDate; // scalar storage must not fake object identity
hint: getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering
hint: getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering
stdlib-fence.ts:26:18 - error SC1043: comparing non-number, non-string values are not supported yet
+1
View File
@@ -163,6 +163,7 @@ describe(`static fetch differential${sanitize ? " (sanitized)" : ""}`, () => {
"static-listener-noncallable",
"static-abort-throw",
"static-dispatch-throw",
"static-response-json",
] as const;
test.for(
staticCases.flatMap((name) => (["llvm"] as const).map((backend) => [name, backend] as const)),
+1
View File
@@ -552,6 +552,7 @@ describe("library profile fences", () => {
"stdlib.date.now",
"stdlib.date.parse",
"stdlib.date.toISOString",
"stdlib.date.toUTCString",
"stdlib.date.valueOf",
]);
expect(r.profile.fences[0]!.surfaces.every((s) => s.detector !== undefined)).toBe(true);