Box URL into checked-dynamic unions beside fetch's Request and Response (#1459)

This commit is contained in:
Chris Tate
2026-10-19 22:59:51 -05:00
committed by GitHub
7 changed files with 165 additions and 13 deletions
@@ -619,6 +619,20 @@ export function lowerFetchCall(lowerer: Lowerer, call: ts.CallExpression): IrExp
: lowerer.lowerExpr(argument);
stmts.push({ kind: "exprStmt", expr: discarded, loc: discarded.loc });
}
// A union input holding a URL arm (`string | URL`) converts through the
// checked-dynamic input path, which reads the URL's href at the call
// like the bare-URL arm below; narrowing it to a string would throw on
// the URL arm.
const unionWithUrl =
input.type.kind === "union" &&
(lowerer.unions.get(input.type.unionId)?.arms.some((arm) => arm.kind === "url") ?? false) &&
lowerer.dynConvertible(input.type);
const dynInput: IrExpr | null =
input.type.kind === "dyn"
? inputRef
: unionWithUrl
? { kind: "dynFrom", value: inputRef, type: DYN, loc: locOf(inputNode) }
: null;
const url: IrExpr =
input.type.kind === "url"
? {
@@ -628,13 +642,13 @@ export function lowerFetchCall(lowerer: Lowerer, call: ts.CallExpression): IrExp
type: STRING,
loc: locOf(inputNode),
}
: input.type.kind === "dyn"
? inputRef
: dynInput !== null
? dynInput
: lowerer.coerceInto(inputNode, inputRef, STRING);
const answer: IrExpr = {
kind: "libCall",
fn: input.type.kind === "dyn" ? "fetch.input" : "fetch.start",
args: [input.type.kind === "dyn" ? inputRef : url, initRef],
fn: dynInput !== null ? "fetch.input" : "fetch.start",
args: [url, initRef],
type: { kind: "promise", inner: DYN },
loc,
};
+18 -9
View File
@@ -1360,8 +1360,9 @@ export function mapAsyncGeneratorAnnotation(type: ts.Type, ctx: TypeMapperCtx):
if (
!symbol ||
!["AsyncIterator", "AsyncIterableIterator", "AsyncIterable"].includes(symbol.name) ||
!symbol.declarations
.some((d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(d.getSourceFile()))
!symbol.declarations.some(
(d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(d.getSourceFile()),
)
)
return null;
const args = checker.getTypeArguments(type as ts.TypeReference);
@@ -2513,12 +2514,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
if (
psym &&
WEBCRYPTO_DYN_TYPES.has(psym.name) &&
psym.declarations
.some(
(d) =>
(ts.isClassDeclaration(d) || ts.isInterfaceDeclaration(d)) &&
ctx.isStdlibFile(d.getSourceFile()),
)
psym.declarations.some(
(d) =>
(ts.isClassDeclaration(d) || ts.isInterfaceDeclaration(d)) &&
ctx.isStdlibFile(d.getSourceFile()),
)
) {
return DYN;
}
@@ -4184,7 +4184,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// semantics would DEGRADE under the collapse — class instances,
// Maps/Sets, functions (closure identity, `x === String`
// narrowing), promises, regexes, generators, handles — keep their
// existing union homes and fences below. A pending recursive
// existing union homes and fences below. URL is the
// exception: its handle boxes by reference and keeps every member,
// so it collapses too (dynSubsumableUnionArm). A pending recursive
// placeholder cannot collapse (back-references already hold the
// union id); the degenerate recursive spelling keeps its fence.
if (
@@ -6285,6 +6287,13 @@ function dynSubsumableUnionArm(arm: IrType, ctx: TypeMapperCtx): boolean {
case "undefinedT":
case "nullT":
return true;
// URL boxes BY REFERENCE (its checked-dynamic handle keeps identity,
// reads and writes its members through the native accessors, answers
// `instanceof URL`, and checks back out to the typed value), so it
// rides the collapse beside fetch's Request/Response handles:
// `string | URL | Request`, fetch's own input type under @types/node.
case "url":
return true;
case "record":
case "array":
return canConvertToDyn(
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6252-fetch-input-url-unions.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/node-types/fetch-input-unions.ts"
],
"diags": []
}
@@ -0,0 +1,48 @@
// `typeof globalThis.fetch` under @types/node takes `string | URL | Request`:
// unions holding URL handles beside fetch handles compile (URL boxes by
// reference into the checked-dynamic representation), and a custom fetch
// cast to that type through `unknown` keeps working, including an extra
// init member written through a record view (the dispatcher pattern).
import * as http from "node:http";
type FetchImpl = typeof globalThis.fetch;
interface DispatchingInit {
method?: string;
headers?: Record<string, string>;
dispatcher?: { name: string };
}
function customFetch(input: string | URL, init?: DispatchingInit): Promise<Response> {
console.log("custom fetch", typeof input === "string" ? "string" : "url", init?.dispatcher?.name ?? "none");
return fetch(input, { method: init?.method, headers: init?.headers });
}
const server = http.createServer((req, res) => res.end(`ok ${req.method} ${req.url}`));
server.listen(0, "127.0.0.1", async () => {
const address = server.address();
const port = address !== null && typeof address === "object" ? address.port : 0;
const impl: FetchImpl = customFetch as unknown as FetchImpl;
const init: RequestInit = { method: "POST" };
(init as Record<string, unknown>).dispatcher = { name: "pinned" };
const res = await impl(`http://127.0.0.1:${port}/a`, init);
console.log(res.status, await res.text());
const viaUrl = await impl(new URL(`http://127.0.0.1:${port}/b`));
console.log(viaUrl.status, await viaUrl.text());
const direct = await fetch(new URL(`http://127.0.0.1:${port}/c`));
console.log(direct.status, await direct.text());
server.close();
});
function describeInput(input: string | Request | URL): string {
if (typeof input === "string") return `string ${input}`;
if (input instanceof URL) return `url ${input.href} ${input.hostname}`;
return `request ${input.method} ${input.url}`;
}
console.log(describeInput("http://a.test/x"));
const u = new URL("http://b.test:8080/p?q=1");
console.log(describeInput(u));
console.log(describeInput(new Request("http://c.test/r", { method: "PUT" })));
const items: (string | URL | Request)[] = [u, "s"];
console.log(items.length, items[0] === u, String(items[0]));
let slot: string | URL | Request = u;
if (slot instanceof URL) { slot.pathname = "/changed"; }
console.log(u.href);
const params: string | Request | URL = new URL("http://d.test/?b=2");
console.log(typeof params === "string" ? params : params instanceof URL ? params.searchParams.get("b") : "req");
+48
View File
@@ -0,0 +1,48 @@
// `typeof globalThis.fetch` under @types/node takes `string | URL | Request`:
// unions holding URL handles beside fetch handles compile (URL boxes by
// reference into the checked-dynamic representation), and a custom fetch
// cast to that type through `unknown` keeps working, including an extra
// init member written through a record view (the dispatcher pattern).
import * as http from "node:http";
type FetchImpl = typeof globalThis.fetch;
interface DispatchingInit {
method?: string;
headers?: Record<string, string>;
dispatcher?: { name: string };
}
function customFetch(input: string | URL, init?: DispatchingInit): Promise<Response> {
console.log("custom fetch", typeof input === "string" ? "string" : "url", init?.dispatcher?.name ?? "none");
return fetch(input, { method: init?.method, headers: init?.headers });
}
const server = http.createServer((req, res) => res.end(`ok ${req.method} ${req.url}`));
server.listen(0, "127.0.0.1", async () => {
const address = server.address();
const port = address !== null && typeof address === "object" ? address.port : 0;
const impl: FetchImpl = customFetch as unknown as FetchImpl;
const init: RequestInit = { method: "POST" };
(init as Record<string, unknown>).dispatcher = { name: "pinned" };
const res = await impl(`http://127.0.0.1:${port}/a`, init);
console.log(res.status, await res.text());
const viaUrl = await impl(new URL(`http://127.0.0.1:${port}/b`));
console.log(viaUrl.status, await viaUrl.text());
const direct = await fetch(new URL(`http://127.0.0.1:${port}/c`));
console.log(direct.status, await direct.text());
server.close();
});
function describeInput(input: string | Request | URL): string {
if (typeof input === "string") return `string ${input}`;
if (input instanceof URL) return `url ${input.href} ${input.hostname}`;
return `request ${input.method} ${input.url}`;
}
console.log(describeInput("http://a.test/x"));
const u = new URL("http://b.test:8080/p?q=1");
console.log(describeInput(u));
console.log(describeInput(new Request("http://c.test/r", { method: "PUT" })));
const items: (string | URL | Request)[] = [u, "s"];
console.log(items.length, items[0] === u, String(items[0]));
let slot: string | URL | Request = u;
if (slot instanceof URL) { slot.pathname = "/changed"; }
console.log(u.href);
const params: string | Request | URL = new URL("http://d.test/?b=2");
console.log(typeof params === "string" ? params : params instanceof URL ? params.searchParams.get("b") : "req");
+21
View File
@@ -73,6 +73,27 @@ test("node-types: Headers, Request and Response members typed by undici-types ru
expect(comparableStderr(native.stderr)).toBe(node.stderr);
});
test("node-types: fetch's string | URL | Request input and a custom fetch cast to typeof fetch", async () => {
const outDir = outDirFor("fetch-input-unions");
const entry = join(nodeTypesDir, "fetch-input-unions.ts");
const result = await compile(entry, {
outPath: join(
outDir,
process.platform === "win32" ? "fetch-input-unions.exe" : "fetch-input-unions",
),
outDir,
sanitize,
});
expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true);
if (!result.ok) return;
const [native, node] = await Promise.all([
execFileAsync(result.binaryPath),
execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]),
]);
expect(native.stdout).toBe(node.stdout);
expect(comparableStderr(native.stderr)).toBe(node.stderr);
});
test("node-types: http header arrays and server.address() follow @types/node's types", async () => {
const outDir = outDirFor("http-headers-address");
const entry = join(nodeTypesDir, "http-headers-address.ts");