From cb26c95f618ad0b55708345b47b9afd64bba0ebc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 22:09:41 -0700 Subject: [PATCH 1/3] Read http.request options at run time, speak HTTP over Unix sockets, and fill the boot path's net and fetch gaps http.request, https.request and get now accept options the compiler cannot see as an object literal: a RequestOptions variable, a literal that spreads one, or a literal that sets socketPath. The runtime reads the record in the ClientRequest constructor's order with Node's errors; documented options it does not implement throw the runtime fence when they carry a value. https adds rejectUnauthorized, ca and servername. socketPath dials a Unix domain socket in the net unit; the agent keys its sockets by the path as Node's getName does, and the Host header defaults to localhost. server.listen(path[, cb]) binds a Unix socket and removes the socket file when the server closes; a numeric string is still a port. Errors name the path ("connect ENOENT /path", "listen EADDRINUSE: address already in use /path"). Windows reports ENOTSUP. A TypeScript object literal with no contextual type that spreads a checked-dynamic value maps to the checked dynamic, so `const options = { ...location, path, timeout }` compiles. A socket's 'lookup' event reaches its listeners with (err, address, family, host) for each address a host name resolves to, or once with the lookup error, before 'connect'. Only programs that listen for 'lookup' install the lookup records; elsewhere the hook sites are a NULL check. server.close((err) => ...) runs the callback with undefined at 'close', or with ERR_SERVER_NOT_RUNNING when the server was not listening; a server that never listened now emits 'close'. IncomingMessage 'data' listeners may take string chunks after setEncoding, and res.end() accepts a string-or-undefined chunk. fetch's BodyInit and HeadersInit unions, as RequestInit-derived option types spell them under @types/node, map to the checked dynamic, and a URLSearchParams body is sent form-encoded, as by Node. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/limitations.mdx | 4 + .../limitations/unix-domain-sockets.mdx | 2 + .../ambient/scriptc-node-fallback.d.ts | 29 +- packages/compiler/src/backend/llvm/emitter.ts | 3 + .../compiler/src/backend/llvm/lib-network.ts | 47 +++ .../compiler/src/backend/llvm/lib-shared.ts | 8 + .../src/frontend/lowering/lower-server.ts | 170 +++++++- packages/compiler/src/frontend/type-mapper.ts | 140 +++++++ packages/compiler/src/ir/builtin-effects.ts | 6 + .../compiler/src/ir/builtin-signatures.ts | 26 ++ packages/compiler/src/ir/runtime-features.ts | 9 + packages/compiler/src/ir/validate.ts | 16 +- .../corpus/6440-http-socket-path.ts.json | 6 + .../6441-http-request-options-record.ts.json | 6 + .../6442-https-request-options-record.ts.json | 6 + .../6443-net-lookup-http-close-text.ts.json | 6 + .../node-types/fetch-init-members.ts.json | 6 + .../node-types/http-socket-path.ts.json | 6 + packages/runtime/src/scr_fetch.c | 21 +- packages/runtime/src/scr_http.c | 394 +++++++++++++++++- packages/runtime/src/scr_http.h | 34 ++ packages/runtime/src/scr_net.c | 352 +++++++++++++++- packages/runtime/src/scr_net.h | 8 + packages/runtime/src/scr_tls.c | 68 +++ tests/corpus/6440-http-socket-path.ts | 109 +++++ .../6441-http-request-options-record.ts | 68 +++ .../6442-https-request-options-record.ts | 61 +++ .../corpus/6443-net-lookup-http-close-text.ts | 73 ++++ .../fixtures/node-types/fetch-init-members.ts | 68 +++ tests/fixtures/node-types/http-socket-path.ts | 68 +++ tests/harness/project-config.test.ts | 38 ++ 31 files changed, 1840 insertions(+), 18 deletions(-) create mode 100644 docs/content/limitations/unix-domain-sockets.mdx create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6440-http-socket-path.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6441-http-request-options-record.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6442-https-request-options-record.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6443-net-lookup-http-close-text.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-init-members.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/http-socket-path.ts.json create mode 100644 tests/corpus/6440-http-socket-path.ts create mode 100644 tests/corpus/6441-http-request-options-record.ts create mode 100644 tests/corpus/6442-https-request-options-record.ts create mode 100644 tests/corpus/6443-net-lookup-http-close-text.ts create mode 100644 tests/fixtures/node-types/fetch-init-members.ts create mode 100644 tests/fixtures/node-types/http-socket-path.ts diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index c64c5cebd..353fa42d1 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -224,6 +224,10 @@ title: "Limitations" ../limitations/udp-socket-options.mdx +### Unix domain sockets and request options + +../limitations/unix-domain-sockets.mdx + ### Process arguments and errors ../limitations/process-arguments-and-errors.mdx diff --git a/docs/content/limitations/unix-domain-sockets.mdx b/docs/content/limitations/unix-domain-sockets.mdx new file mode 100644 index 000000000..70a8a47e4 --- /dev/null +++ b/docs/content/limitations/unix-domain-sockets.mdx @@ -0,0 +1,2 @@ +`server.listen(path)` and the `socketPath` option of `http.request()` and `https.request()` use Unix domain sockets on Linux and macOS. On Windows, where Node maps these paths to named pipes, they fail with `ENOTSUP`. `server.address()` on a server listening on a Unix socket returns `{ address: , family: "IPv4", port: 0 }`, where Node returns the socket path as a string: compiled code holds `address()` as the `{ address, family, port }` record. `listen(path, backlog, callback)` is refused at compile time; pass `listen(path, callback)`. +When the options of `http.request()` come from a variable, or from an object literal that spreads one, they are read at run time with Node's validation errors. The `auth`, `createConnection`, `family`, `hints`, `insecureHTTPParser`, `joinDuplicateHeaders`, `localAddress`, `localPort`, `lookup` and `uniqueHeaders` options then throw a runtime error when they hold a value, and so do `setHost: false` and `setDefaultHeaders: false`. diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 1b0d1fd36..c0d8476a6 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -4681,6 +4681,11 @@ declare module "net" { on(event: "close", listener: (hadError: boolean) => void): void; on(event: "error", listener: (err: Error) => void): void; on(event: "session", listener: (session: Buffer) => void): void; + /* The host name's resolution, before the connect. */ + on( + event: "lookup", + listener: (err: Error | null, address: string, family: number, host: string) => void, + ): void; /* addListener IS on (Node aliases them) — the suite spells both. */ addListener(event: "data", listener: (chunk: Buffer) => void): void; /* After setEncoding the chunk is a string. */ @@ -4692,6 +4697,11 @@ declare module "net" { addListener(event: "close", listener: (hadError: boolean) => void): void; addListener(event: "error", listener: (err: Error) => void): void; addListener(event: "session", listener: (session: Buffer) => void): void; + /* The host name's resolution, before the connect. */ + addListener( + event: "lookup", + listener: (err: Error | null, address: string, family: number, host: string) => void, + ): void; once(event: "data", listener: (chunk: Buffer) => void): void; /* After setEncoding the chunk is a string. */ once(event: "data", listener: (chunk: string) => void): void; @@ -4702,6 +4712,11 @@ declare module "net" { once(event: "close", listener: (hadError: boolean) => void): void; once(event: "error", listener: (err: Error) => void): void; once(event: "session", listener: (session: Buffer) => void): void; + /* The host name's resolution, before the connect. */ + once( + event: "lookup", + listener: (err: Error | null, address: string, family: number, host: string) => void, + ): void; } export interface Server { /* Whether the server keeps the event loop alive (ref'd by default). */ @@ -4720,6 +4735,8 @@ declare module "net" { /* The positional bind address (the options form's host in argument * spelling). */ listen(port: number, host: string, callback?: () => void): Server; + /* A Unix-socket server; the socket file is removed when it closes. */ + listen(path: string, callback?: () => void): Server; /* The explicit-interface bind: host is an IP literal (absent = the * host-less dual-stack any); ipv6Only sets IPV6_V6ONLY and reusePort * requests kernel connection distribution where Node supports it. */ @@ -5043,10 +5060,11 @@ declare module "http" { * callback receives the response as an IncomingMessage (statusCode, * headers, body via on("data"/"end")). */ export interface RequestOptions { - hostname?: string; - host?: string; - port?: number; - path?: string; + hostname?: string | null; + host?: string | null; + /* A string port (a URL's .port) is accepted, as by Node. */ + port?: number | string | null; + path?: string | null; method?: string; timeout?: number; headers?: OutgoingHttpHeaders; @@ -5060,6 +5078,8 @@ declare module "http" { agent?: Agent | boolean | null; /* Aborting destroys the request with an AbortError. */ signal?: AbortSignal; + /* HTTP over the Unix socket at this path instead of host:port. */ + socketPath?: string; /* The options-record stance: Node ignores an options record's * unknown keys, so every key typechecks here — the option WALK in * the lowering decides per key (lower, fence by name, or drop @@ -5333,6 +5353,7 @@ declare module "https" { agent?: Agent | boolean | null; /* Aborting destroys the request with an AbortError. */ signal?: AbortSignal; + socketPath?: string; /* The options-record stance (see http.RequestOptions). */ [option: string]: unknown; } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e86b447d9..617d528fe 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -2154,6 +2154,7 @@ export class LlEmitter { // programs additionally stamp the httpReq/httpRes ops — the C main's // install lines, gated on the same predicates native/executable.ts links by. const usesNet = runtimeFeatures.net; + const usesNetLookup = runtimeFeatures.netLookup; const usesDgram = runtimeFeatures.dgram; const usesHttp2 = runtimeFeatures.http2; const usesHttp = runtimeFeatures.http; @@ -2207,6 +2208,7 @@ export class LlEmitter { this.declare(`declare void @scr_net_install()`); this.declare(`declare void @scr_net_dyn_install()`); } + if (usesNetLookup) this.declare(`declare void @scr_net_lookup_install()`); if (usesDgram) this.declare(`declare void @scr_dgram_install()`); if (usesHttp2) this.declare(`declare void @scr_http2_dyn_install()`); if (usesHttp) this.declare(`declare void @scr_http_dyn_install()`); @@ -2811,6 +2813,7 @@ export class LlEmitter { ...(snapshotsTlsCa ? [` call void @scr_tls_ca_install()`] : []), ...(usesFetch ? [` call void @scr_fetch_install()`] : []), ...(usesNet ? [` call void @scr_net_install()`, ` call void @scr_net_dyn_install()`] : []), + ...(usesNetLookup ? [` call void @scr_net_lookup_install()`] : []), ...(usesHttp ? [` call void @scr_http_dyn_install()`] : []), ...(usesDgram ? [` call void @scr_dgram_install()`] : []), ...(usesHttp2 ? [` call void @scr_http2_dyn_install()`] : []), diff --git a/packages/compiler/src/backend/llvm/lib-network.ts b/packages/compiler/src/backend/llvm/lib-network.ts index ea37fc2cf..d49bbde14 100644 --- a/packages/compiler/src/backend/llvm/lib-network.ts +++ b/packages/compiler/src/backend/llvm/lib-network.ts @@ -60,7 +60,54 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr) ...handler, ]); } + if ( + e.fn === "http.requestDyn" || + e.fn === "http.requestDynCb" || + e.fn === "https.requestDyn" || + e.fn === "https.requestDynCb" + ) { + // [options, autoEnd(, cb)]: the runtime walks the options record and + // attaches its signal through events.addAbortListener (the event-target + // unit, linked for these calls by runtime-features). + const args = e.args.map((arg) => host.emitExpr(arg)); + host.usesTimers = true; + const cb = args[2]; + const handler = + cb === undefined + ? [ptr(), ptr()] + : callbackAdapter( + host, + cb, + callbackType(cb).params.length === 0 + ? "scr_http_resp_thunk0" + : "scr_http_resp_thunk_res", + ["ptr", "ptr"], + ); + host.declare(`declare ptr @scr_events_add_abort_listener(ptr, ptr)`); + return finishRuntimeCall( + host, + e, + e.fn.startsWith("https.") ? "scr_https_request_dyn" : "scr_http_request_dyn", + [ + abiValue(host, args[0]!), + abiValue(host, args[1]!), + ...handler, + ptr("@scr_events_add_abort_listener"), + ], + ); + } const B = host.B; + if (e.fn === "net.listenPath" || e.fn === "net.listenPathCb") { + const args = e.args.map((a) => host.emitExpr(a)); + let cb = "null"; + if (e.fn === "net.listenPathCb") { + host.toHeap(args[2]!); + cb = args[2]!.name; + } + host.declare(`declare void @scr_net_listen_path(ptr, ptr, ptr)`); + B.line(`call void @scr_net_listen_path(ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${cb})`); + return { name: "", type: e.type }; + } if (e.fn === "net.connectOptsChk") { return emitAlwaysThrowLibCall(host, e, "scr_net_connect_opts_chk"); } diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index be34ad886..bb61bfba2 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1139,6 +1139,8 @@ export const LIB_FN_SYMS: Record = { "net.sockReadyState": "scr_net_sock_ready_state", "net.sockSetKeepAlive": "scr_net_sock_set_keepalive_ret", "net.sockOnCloseDyn": "scr_net_sock_on_close_dyn", + "net.sockOnLookup": "scr_net_sock_on_lookup", + "net.serverCloseDyn": "scr_net_server_close_dyn", "net.sockOnWriteFlushDyn": "scr_net_sock_on_write_flush_dyn", "net.sockOnFinishDyn": "scr_net_sock_on_finish_dyn", "net.isIP": "scr_net_is_ip", @@ -1614,6 +1616,8 @@ export const USES_TIMERS_LIB_FNS = new Set([ "sc.arrayBuffer", "net.listen", "net.listenCb", + "net.listenPath", + "net.listenPathCb", "net.listenOpts", "net.listenOptsCb", "net.listenOptsReusePort", @@ -1632,6 +1636,10 @@ export const USES_TIMERS_LIB_FNS = new Set([ "http.requestCb", "http.requestUrl", "http.requestUrlCb", + "http.requestDyn", + "http.requestDynCb", + "https.requestDyn", + "https.requestDynCb", "https.request", "https.requestCb", "https.requestUrl", diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 0e1438fc9..1a43485eb 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -53,7 +53,7 @@ import { sideEffectFreeOptionValue, TLS_SERVER_DOCUMENTED_OPTIONS, } from "./surfaces.js"; -import { conditionalSpreadOf } from "./expressions/object-literals.js"; +import { conditionalSpreadOf, lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { resultIsDiscarded } from "./call-position.js"; import { lowerCallbackArg as lowerCallbackArgShared } from "./callback-arg.js"; import { pairsSnapshotHelper } from "./pairs-snapshot.js"; @@ -1627,6 +1627,29 @@ function lowerNetServerMethodCall( : [receiver, port, host, v6only, reusePort, cbV], ); } + // listen(path[, cb]): a string first argument names a Unix socket + // (the runtime takes a numeric string as a port, Node's isPipeName). + if (lowerer.mapTypeOf(lowerer.typeOf(args[0]!))?.kind === "string") { + if (args.length > 2) { + lowerer.noLowering( + `listen(path) with ${args.length} arguments`, + call, + "the supported form is listen(path[, callback])", + ); + } + const path = lowerer.lowerExprExpecting(args[0]!, STRING); + if (args.length === 1) return listenResult("net.listenPath", [receiver, path]); + const { cb } = lowerCallbackArg( + lowerer, + args[1]!, + "listen callbacks", + 0, + () => false, + "use ()", + [], + ); + return listenResult("net.listenPathCb", [receiver, path, cb]); + } const port = lowerer.lowerExprExpecting(args[0]!, F64); // The optional middle host — listen(port, '127.0.0.1'[, cb]): a // string-typed second argument is the bind address (the net.connect @@ -1682,6 +1705,21 @@ function lowerNetServerMethodCall( if (args.length === 0) { return { kind: "libCall", fn: "net.serverClose", args: [receiver], type: VOID, loc }; } + if ( + lowerer.checker + .getCallSignatures(lowerer.typeOf(args[0]!)) + .some((sig) => sig.getParameters().length > 0) + ) { + // close((err) => ...): undefined once the server closes, or Node's + // ERR_SERVER_NOT_RUNNING when it was not listening. + return { + kind: "libCall", + fn: "net.serverCloseDyn", + args: [receiver, lowerSocketDynListener(lowerer, args[0]!, "close callbacks")], + type: VOID, + loc, + }; + } const { cb } = lowerCallbackArg( lowerer, args[0]!, @@ -2722,6 +2760,17 @@ function lowerNetSocketMethodCall( : "net.sockOnConnect"; return { kind: "libCall", fn, args: [receiver, cb, once], type: VOID, loc }; } + if (event === "lookup") { + // (err, address, family, host) after the host name resolves, before + // the connect: once per resolved address, or once with the error. + return { + kind: "libCall", + fn: "net.sockOnLookup", + args: [receiver, lowerSocketDynListener(lowerer, args[1]!, "lookup listeners"), once], + type: VOID, + loc, + }; + } if (event === "finish") { // Fires once when the FIN goes out — once either way (the event // happens at most once per socket). @@ -2781,7 +2830,7 @@ function lowerNetSocketMethodCall( lowerer.noLowering( `socket.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, args[0]!, - '"data", "end", "close", "error", "connect", "ready", "timeout", "readable", "secureConnect", and "session" are the supported socket events (as literals)', + '"data", "end", "close", "error", "connect", "lookup", "ready", "timeout", "readable", "secureConnect", and "session" are the supported socket events (as literals)', ); } lowerer.noLowering( @@ -5813,6 +5862,44 @@ function lowerHttp2ModuleCall( ); } +/** The checked-dynamic options value of a request whose options the + * runtime reads: a non-literal options value (a RequestOptions variable — + * checked-dynamic storage — or a record that converts to one), or a + * literal the static walk cannot lower because it spreads another options + * value or sets socketPath. Null keeps the static literal walk (and its + * fences), which every other literal takes unchanged. */ +function runtimeRequestOptions( + lowerer: Lowerer, + optsNode: ts.Expression, + secureish: boolean, +): IrExpr | null { + if (ts.isObjectLiteralExpression(optsNode)) { + const needsRuntime = optsNode.properties.nodes.some((prop) => { + if (ts.isSpreadAssignment(prop)) { + // the static walk's one spread: `...(c ? { rejectUnauthorized } : {})` on https + const cs = conditionalSpreadOf(prop.expression); + return !( + secureish && + cs !== null && + cs !== "unsupported" && + cs.props.length === 1 && + cs.props[0]!.name.text === "rejectUnauthorized" + ); + } + return ( + (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) && + (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) && + prop.name.text === "socketPath" + ); + }); + if (!needsRuntime) return null; + return lowerDynObjectLiteral(lowerer, optsNode); + } + const t = lowerer.mapTypeOf(lowerer.typeOf(optsNode)); + if (!t || !(t.kind === "dyn" || (t.kind === "record" && lowerer.dynConvertible(t)))) return null; + return lowerer.coerceToExpected(lowerer.lowerExpr(optsNode), DYN); +} + /** http.request(options[, cb]) / http.get(options[, cb]) — the client. * The options argument must be an OBJECT LITERAL; the lowered keys are * hostname/host, port, path, method, timeout, and headers (a nested @@ -5855,6 +5942,40 @@ function lowerHttpClientCall( ); } const optsNode = args[0]!; + const runtimeOptions = + binding === null ? runtimeRequestOptions(lowerer, optsNode, secureish) : null; + if (runtimeOptions !== null) { + // The options record is read at run time (the ClientRequest + // constructor's walk in the runtime): a RequestOptions variable, or a + // literal that spreads one or sets socketPath. + const autoEnd = boolLit(member === "get", loc); + const secureDyn = secure === true; + if (args.length === 1) { + return { + kind: "libCall", + fn: secureDyn ? "https.requestDyn" : "http.requestDyn", + args: [runtimeOptions, autoEnd], + type: HTTPCLIENTREQ_T, + loc, + }; + } + const { cb } = lowerCallbackArg( + lowerer, + args[1]!, + "response callbacks", + 1, + (p) => p.kind === "httpReq", + "use (res) or ()", + [HTTPREQ_T], + ); + return { + kind: "libCall", + fn: secureDyn ? "https.requestDynCb" : "http.requestDynCb", + args: [runtimeOptions, autoEnd, cb], + type: HTTPCLIENTREQ_T, + loc, + }; + } if (!ts.isObjectLiteralExpression(optsNode)) { const t = lowerer.mapTypeOf(lowerer.typeOf(optsNode)); if ((t?.kind === "string" || t?.kind === "url") && binding === null) { @@ -7135,6 +7256,27 @@ function lowerHttpReqMethodCall( const event = evT.isStringLiteralType() ? evT.value : null; const receiver = coerceToHandle(lowerer, access.expression, HTTPREQ_T); if (event === "data") { + const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(args[1]!)); + const firstParam = sigs.length === 1 ? sigs[0]!.getParameters()[0] : undefined; + const param = + firstParam !== undefined + ? lowerer.mapTypeOf(lowerer.checker.getTypeOfSymbol(firstParam)) + : null; + if (param !== null && param.kind === "string") { + // A chunk typed string (the setEncoding('utf8') shape), as on a + // socket: the listener takes the chunk through a checked + // boundary, so a string arrives after setEncoding and a Buffer + // without it fails the check instead of being decoded. + const boxed = lowerSocketDynListener(lowerer, args[1]!, "data listeners"); + const cb: IrExpr = { kind: "dynCheck", value: boxed, type: funcOf([DYN], VOID), loc }; + return { + kind: "libCall", + fn: "http.reqOnData", + args: [receiver, cb, once], + type: VOID, + loc, + }; + } const { cb } = lowerCallbackArg( lowerer, args[1]!, @@ -7697,6 +7839,30 @@ function lowerHttpResMethodCall( if (cbArg !== null) return endWithCb(fn, [receiver, data], cbArg); return { kind: "libCall", fn, args: [receiver, data], type: VOID, loc }; } + // end(payload ? text : undefined): an absent chunk ends without data, + // as end() does. The checked-dynamic end takes each arm at run time. + if (name === "end" && data.type.kind === "union" && lowerer.dynConvertible(data.type)) { + const arms = lowerer.unions.get(data.type.unionId)?.arms ?? []; + if ( + arms.every( + (arm) => + arm.kind === "string" || + (arm.kind === "bytes" && arm.elem === "u8") || + arm.kind === "undefinedT" || + arm.kind === "nullT", + ) + ) { + const dynData: IrExpr = { kind: "dynFrom", value: data, type: DYN, loc: locOf(dataNode) }; + if (cbArg !== null) return endWithCb("http.resEndDyn", [receiver, dynData], cbArg); + return { + kind: "libCall", + fn: "http.resEndDyn", + args: [receiver, dynData], + type: VOID, + loc, + }; + } + } lowerer.noLowering( `${name} of '${lowerer.fmt(data.type)}' data`, dataNode ?? call, diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 1d7ef7364..652a0d710 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1307,6 +1307,116 @@ function isWorkerHandleSymbol( /** The `{ message: any }` object type (or its union with undefined) that * worker_threads declares as receiveMessageOnPort's result. */ +/** The binary and stream members of fetch's BodyInit (undici-types): + * NodeJS.ArrayBufferView expands to the typed arrays and DataView. */ +const FETCH_BODY_MEMBERS: ReadonlySet = new Set([ + "ArrayBuffer", + "SharedArrayBuffer", + "AsyncIterable", + "Iterable", + "Blob", + "FormData", + "URLSearchParams", + "ReadableStream", + "DataView", + "Buffer", + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float16Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", +]); + +/** fetch's request body type, BodyInit, however TypeScript spells it + * (`RequestInit["body"]`, `NonNullable<...>`, an optional member): a + * union of strings, the stdlib binary and stream types and nullish + * values that includes both FormData and URLSearchParams. Native fetch + * reads such a body at run time, so it rides checked-dynamic storage. */ +function isFetchBodyUnion(type: ts.Type, ctx: Pick): boolean { + if (!type.isUnionType()) return false; + let formData = false; + let searchParams = false; + for (const member of ts.constituentTypes(type)) { + if (member.flags & (ts.TypeFlags.StringLike | ts.TypeFlags.Null | ts.TypeFlags.Undefined)) + continue; + const symbol = member.getSymbol(); + if ( + symbol === undefined || + !FETCH_BODY_MEMBERS.has(symbol.name) || + !symbol.declarations.some((d) => ctx.isStdlibFile(d.getSourceFile())) + ) + return false; + if (symbol.name === "FormData") formData = true; + if (symbol.name === "URLSearchParams") searchParams = true; + } + return formData && searchParams; +} + +/** A mapped object type (Omit, Pick, Partial) whose members all come from + * one stdlib interface that maps to the checked dynamic. */ +function isDynInterfaceSubset(type: ts.Type, ctx: TypeMapperCtx): boolean { + if (!(type.flags & ts.TypeFlags.Object) || type.isClassOrInterface()) return false; + const alias = type.getAliasSymbol(); + if (alias === undefined) return false; + const props = ctx.checker.getPropertiesOfType(type); + if (props.length === 0) return false; + let owner: ts.InterfaceDeclaration | undefined; + for (const prop of props) { + const decl = prop.declarations[0]; + const parent = decl?.parent; + if ( + parent === undefined || + !ts.isInterfaceDeclaration(parent) || + !ctx.isStdlibFile(parent.getSourceFile()) || + (owner !== undefined && owner !== parent) + ) + return false; + owner = parent; + } + return ( + owner !== undefined && mapType(ctx.checker.getTypeAtLocation(owner.name), ctx)?.kind === "dyn" + ); +} + +/** fetch's HeadersInit (undici-types): a union of the stdlib Headers + * class with header-pair arrays and/or a header record, plus nullish + * values. Native fetch reads it at run time like the init around it. */ +function isFetchHeadersUnion(type: ts.Type, ctx: Pick): boolean { + if (!type.isUnionType()) return false; + let headers = false; + let other = false; + for (const member of ts.constituentTypes(type)) { + if (member.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) continue; + const symbol = member.getSymbol(); + if ( + symbol?.name === "Headers" && + symbol.declarations.some((d) => ctx.isStdlibFile(d.getSourceFile())) + ) { + headers = true; + continue; + } + const alias = member.getAliasSymbol()?.name; + if ( + symbol?.name === "Array" || + symbol?.name === "ReadonlyArray" || + alias === "Record" || + alias === "HeaderRecord" + ) { + other = true; + continue; + } + return false; + } + return headers && other; +} + function isReceiveMessageResult( type: ts.Type, ctx: Pick, @@ -1474,6 +1584,25 @@ function classExprNeedsContext(decl: ts.ClassLikeDeclaration): boolean { return false; } +/** True for the type of a TypeScript object literal with no contextual + * type (`const options = { ...location, path }`) that spreads a value + * mapping to the checked dynamic (a stdlib options type such as + * http.RequestOptions). A literal with a contextual type is built in that + * type's representation instead, and JavaScript literals keep their own + * rules. */ +function spreadsCheckedDynamic(symbol: ts.Symbol | undefined, ctx: TypeMapperCtx): boolean { + if (symbol === undefined || (symbol.flags & ts.SymbolFlags.ObjectLiteral) === 0) return false; + const decl = symbol.declarations[0]; + if (decl === undefined || !ts.isObjectLiteralExpression(decl)) return false; + if (isJsSourceFile(decl.getSourceFile())) return false; + if (ctx.checker.getContextualType(decl)) return false; + return decl.properties.nodes.some( + (prop) => + ts.isSpreadAssignment(prop) && + mapType(ctx.checker.getTypeAtLocation(prop.expression), ctx)?.kind === "dyn", + ); +} + function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const { checker, unions, classNamer, resolveTypeParam } = ctx; // TypeScript's intrinsic globalThis symbol has no declaration. A stored @@ -1494,9 +1623,20 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const moduleId = ctx.moduleNamespaceId?.(type) ?? null; if (moduleId !== null) return { kind: "moduleNs", moduleId }; } + // A TypeScript object literal that spreads a checked-dynamic value + // (`{ ...requestOptions, path, timeout }`): its keys are the source's + // runtime keys, which no record shape holds, so the literal rides + // checked-dynamic storage like its source. + if (spreadsCheckedDynamic(intrinsic, ctx)) return DYN; // worker_threads.receiveMessageOnPort's `{ message: any } | undefined`: // the runtime's { message } object, a checked-dynamic value. if (isReceiveMessageResult(type, ctx)) return DYN; + if (isFetchBodyUnion(type, ctx) || isFetchHeadersUnion(type, ctx)) return DYN; + // Omit/Pick/Partial of a stdlib interface that rides checked-dynamic + // storage (`Omit`): every member comes + // from that interface, so the value is a subset of the same runtime-read + // options object and rides the same storage. + if (isDynInterfaceSubset(type, ctx)) return DYN; // @types/node spells child_process.ExecFileException as an intersection // type alias over two Error-derived metadata interfaces. The value is // still one ordinary Error; recognize the owned alias before generic diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 8b94646f2..51a5ed6a2 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -263,6 +263,12 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ // ("Invalid URL") or a non-http scheme (ERR_INVALID_PROTOCOL). "http.requestUrl", "http.requestUrlCb", + // The runtime options walk throws Node's validation errors and the + // fence for documented options it does not implement. + "http.requestDyn", + "http.requestDynCb", + "https.requestDyn", + "https.requestDynCb", // The h2 client's throwing entries: connect on a bad/non-http // authority (ERR_INVALID_URL / ERR_INVALID_PROTOCOL), request on a // closed/destroyed session (ERR_HTTP2_INVALID_SESSION), setEncoding on diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 1225b2293..778a509f5 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1241,6 +1241,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "net.createServer": { argTypes: [], result: NETSERVER_T }, "net.createServerCb": { argTypes: [null], result: NETSERVER_T }, "net.listen": { argTypes: [NETSERVER_T, F64], result: VOID }, + /** listen(path[, cb]): a Unix-socket server; the socket file is removed + * when the server closes. Failures are the async 'error'. */ + "net.listenPath": { argTypes: [NETSERVER_T, STRING], result: VOID }, + "net.listenPathCb": { + argTypes: [NETSERVER_T, STRING, { kind: "func", params: [], ret: VOID }], + result: VOID, + }, "net.listenCb": { argTypes: [NETSERVER_T, F64, { kind: "func", params: [], ret: VOID }], result: VOID, @@ -1283,6 +1290,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * answers null (the serverPort stance). */ "net.serverAddress": { argTypes: [NETSERVER_T], result: VOID }, "net.serverClose": { argTypes: [NETSERVER_T], result: VOID }, + /** server.close((err) => ...): the callback (a dyn function) gets + * undefined once the server closes, or Node's ERR_SERVER_NOT_RUNNING + * when it was not listening. */ + "net.serverCloseDyn": { argTypes: [NETSERVER_T, DYN], result: VOID }, "net.serverCloseCb": { argTypes: [NETSERVER_T, { kind: "func", params: [], ret: VOID }], result: VOID, @@ -1865,6 +1876,17 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * non-http scheme. */ "http.requestUrl": { argTypes: [STRING, STRING, BOOL], result: HTTPCLIENTREQ_T }, "http.requestUrlCb": { argTypes: [STRING, STRING, BOOL, null], result: HTTPCLIENTREQ_T }, + /** request/get with a runtime options record: a RequestOptions + * variable, or a literal that spreads one or sets socketPath. Args + * (options, autoEnd[, cb]). The runtime reads the record in the + * ClientRequest constructor's order (host/port or socketPath, path, + * method, headers, timeout, agent, signal, maxHeaderSize) with Node's + * errors, and documented options it does not implement throw the + * runtime fence. The "https." pair adds the TLS options. MAY THROW. */ + "http.requestDyn": { argTypes: [DYN, BOOL], result: HTTPCLIENTREQ_T }, + "http.requestDynCb": { argTypes: [DYN, BOOL, null], result: HTTPCLIENTREQ_T }, + "https.requestDyn": { argTypes: [DYN, BOOL], result: HTTPCLIENTREQ_T }, + "https.requestDynCb": { argTypes: [DYN, BOOL, null], result: HTTPCLIENTREQ_T }, /** The paused-mode demux surface (portless's first-byte TLS peek: * once('readable') + read(1) + unshift + emit('connection')). * sockOnReadable registers a zero-param listener (a consumer: arrived @@ -1910,6 +1932,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** Listeners with a parameter, as dyn functions: close(hadError), * write's cb(err) and end's cb. */ "net.sockOnCloseDyn": { argTypes: [NETSOCKET_T, DYN, BOOL], result: VOID }, + /** socket.on/once('lookup', listener): the listener (a dyn function) + * gets (err, address, family, host) once per resolved address, or once + * with the lookup error, before the connect. */ + "net.sockOnLookup": { argTypes: [NETSOCKET_T, DYN, BOOL], result: VOID }, "net.sockOnWriteFlush": { argTypes: [NETSOCKET_T, { kind: "func", params: [], ret: VOID }], result: VOID, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index cc3d08148..d8e388054 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -48,6 +48,9 @@ export interface RuntimeFeatures { qs: boolean; parseArgs: boolean; fsWatch: boolean; + /** The program listens for a socket's 'lookup' event: the C main + * installs the net unit's lookup records. */ + netLookup: boolean; nodeTest: boolean; /** The program calls mock.timers members: its timer calls go through * scr_test.c's mock-aware entry points. */ @@ -183,6 +186,10 @@ const LEGACY_TEXT_DECODER_LIB_FNS: ReadonlySet = new Set([ /** Signal-taking readline, stream and timers/promises entry points, which register abort listeners. */ const ABORT_SIGNAL_LIB_FNS: ReadonlySet = new Set([ "http.clientSignal", + "http.requestDyn", + "http.requestDynCb", + "https.requestDyn", + "https.requestDynCb", "rl.createSignal", "rl.questionAsyncSignal", "sp.finishedOptsSignal", @@ -438,6 +445,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run qs: false, parseArgs: false, fsWatch: false, + netLookup: false, nodeTest: false, mockTimers: false, dgram: false, @@ -540,6 +548,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run features.regex = true; } if (fn.startsWith("fs.watch") || fn.startsWith("watcher.")) features.fsWatch = true; + if (fn === "net.sockOnLookup") features.netLookup = true; // scr_test.c renders thrown strings in the failing-tests section with // the inspect unit's string formatting. if (fn.startsWith("test.")) { diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 4a961dfca..5d372664f 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4426,7 +4426,9 @@ function validateFunction( e.fn === "http.clientOnResponse" || e.fn === "http.requestAgentCb" || e.fn === "https.requestAgentCb" || - e.fn === "https.requestUrlCb" + e.fn === "https.requestUrlCb" || + e.fn === "http.requestDynCb" || + e.fn === "https.requestDynCb" ) { // The response listener: void, no params or exactly (res: httpReq). const cbT = @@ -4437,11 +4439,13 @@ function validateFunction( ? 9 : e.fn === "http.requestUrlCb" || e.fn === "https.requestUrlCb" ? 3 - : e.fn === "http.requestAgentCb" - ? 8 - : e.fn === "https.requestAgentCb" - ? 10 - : 1 + : e.fn === "http.requestDynCb" || e.fn === "https.requestDynCb" + ? 2 + : e.fn === "http.requestAgentCb" + ? 8 + : e.fn === "https.requestAgentCb" + ? 10 + : 1 ]?.type; let ok = cbT?.kind === "func" && cbT.ret.kind === "void" && cbT.params.length <= 1; if (ok && cbT?.kind === "func" && cbT.params.length === 1) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6440-http-socket-path.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6440-http-socket-path.ts.json new file mode 100644 index 000000000..63142a38a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6440-http-socket-path.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6440-http-socket-path.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6441-http-request-options-record.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6441-http-request-options-record.ts.json new file mode 100644 index 000000000..d596d3442 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6441-http-request-options-record.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6441-http-request-options-record.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6442-https-request-options-record.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6442-https-request-options-record.ts.json new file mode 100644 index 000000000..ccc648aee --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6442-https-request-options-record.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6442-https-request-options-record.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6443-net-lookup-http-close-text.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6443-net-lookup-http-close-text.ts.json new file mode 100644 index 000000000..cad9675b2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6443-net-lookup-http-close-text.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6443-net-lookup-http-close-text.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-init-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-init-members.ts.json new file mode 100644 index 000000000..3d8a7bef5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fetch-init-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/fetch-init-members.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/http-socket-path.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/http-socket-path.ts.json new file mode 100644 index 000000000..9147244bf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/http-socket-path.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/http-socket-path.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index 17beb9122..735183b89 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -5179,7 +5179,21 @@ ScrPromise *scr_fetch_static(ScrStr *url, ScrDyn *init) { * arrays, and plain objects (with the ordinary JS object coercion * protocol), while null and undefined continue to mean no body. */ ScrDyn *coerced_body = NULL; - if (body_present && body->kind != SCR_DYN_STR && + /* A URLSearchParams body is its serialization, sent as + * application/x-www-form-urlencoded;charset=UTF-8 (extract a body). */ + bool form_body = false; + if (body_present && body->kind == SCR_DYN_HANDLE && + body->v.handle.tag == SCR_DYNH_SEARCH_PARAMS) { + coerced_body = scr_dyn_handle_ops_of(body)->invoke(body->v.handle.ptr, (ScrDyn *)body, + "toString", NULL, 0, "URLSearchParams"); + if (!coerced_body || coerced_body->kind != SCR_DYN_STR) { + scr_dyn_release(coerced_body); + return sf_reject_now(promise, "fetch failed"); + } + body = coerced_body; + form_body = true; + } + if (body_present && !form_body && body->kind != SCR_DYN_STR && body->kind != SCR_DYN_BYTES && !body_stream) { ScrStr *text = scr_dyn_string_coerce_js(body); if (!text) { @@ -5322,7 +5336,10 @@ ScrPromise *scr_fetch_static(ScrStr *url, ScrDyn *init) { /* Fetch Metadata is controlled by the fetch implementation. Undici * replaces a caller-provided sec-fetch-mode with "cors". */ sf_strip_header(&headers, "sec-fetch-mode"); - if (body && body->kind == SCR_DYN_STR && + if (form_body && !sf_pairs_have(headers, "content-type")) { + sf_push_header_text(headers, "content-type", + "application/x-www-form-urlencoded;charset=UTF-8", 47); + } else if (body && body->kind == SCR_DYN_STR && !sf_pairs_have(headers, "content-type")) { sf_push_header_text(headers, "content-type", "text/plain;charset=UTF-8", 24); diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index 83a1b3583..9a2541d24 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -5582,6 +5582,18 @@ static void scr_http_agent_destroy(ScrHttpAgent *a) { } } +/* The socketPath option of the next request built through + * scr_http_request_agent_ex (borrowed; the caller keeps it alive across + * the call), like https's servername: that request dials the Unix socket + * instead of host:port, and the agent keys its sockets by the path, as + * Node's getName does. */ +static SCR_TL ScrStr *scr_http_next_socket_path = NULL; +/* The Unix-socket dialer, set by the runtime options path that sets the + * path; a pointer (not static) so ordinary requests do not link the + * Unix-socket half of the net unit. */ +extern SCR_TL ScrNetSocket *(*scr_http_socket_dialer)(ScrStr *path, bool deferred); +SCR_TL ScrNetSocket *(*scr_http_socket_dialer)(ScrStr *path, bool deferred) = NULL; + ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, @@ -5590,6 +5602,8 @@ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double po ScrHttpRespFn fn, int default_port, void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, ScrStr *pool_key /*borrowed, nullable*/) { + ScrStr *socket_path = scr_http_next_socket_path; + scr_http_next_socket_path = NULL; /* the null/undefined arms: the scheme's global agent, like Node */ if (agent == NULL || agent->kind == SCR_DYN_UNDEF || agent->kind == SCR_DYN_NULL) agent = scr_http_global_agent(default_port == 443); @@ -5619,8 +5633,10 @@ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double po scr_arr_push_ref(copy, scr_str_new("close", 5)); pairs = copy; } + ScrNetSocket *presock = socket_path != NULL ? scr_http_socket_dialer(socket_path, false) : NULL; ScrHttpClientReq *c = scr_http_request_impl(host, p, path, method, timeout_ms, pairs, - auto_end, cb, fn, default_port, wrap, wrap_ctx, NULL); + auto_end, cb, fn, default_port, wrap, wrap_ctx, + presock); if (copy) scr_arr_release(copy); return c; } @@ -5636,11 +5652,14 @@ ScrHttpClientReq *scr_http_request_agent_ex(ScrStr *host /*borrowed*/, double po char portbuf[16]; int portn = snprintf(portbuf, sizeof portbuf, "%d", (int)p); ScrStr *name = scr_http_agent_name(host->data, host->len, portbuf, (size_t)portn, - NULL, 0, 0, NULL, 0); + NULL, 0, 0, socket_path ? socket_path->data : NULL, + socket_path ? socket_path->len : 0); /* a free pooled socket for the name serves the request right away */ ScrHttpAgentFree *reuse = ag->keep_alive ? scr_http_agent_free_take(ag, name, pool_key) : NULL; bool queue = reuse == NULL && scr_http_agent_active(ag, name) >= ag->max_sockets; - ScrNetSocket *presock = queue || reuse ? scr_net_connect_deferred(p, host) : NULL; + ScrNetSocket *presock = socket_path != NULL ? scr_http_socket_dialer(socket_path, queue || reuse) + : queue || reuse ? scr_net_connect_deferred(p, host) + : NULL; ScrHttpClientReq *c = scr_http_request_impl(host, p, path, method, timeout_ms, header_pairs, auto_end, cb, fn, default_port, wrap, wrap_ctx, presock); @@ -6968,3 +6987,372 @@ ScrHttpClientReq *scr_http_request_url(ScrStr *url /*borrowed*/, ScrStr *method scr_str_release(path); return c; } + +/* ── http.request(options) with a runtime options record ───────────────── + * + * The options value of http.request/get when the compiler cannot see an + * object literal: a RequestOptions variable, or a literal that spreads one + * or sets socketPath. Node's ClientRequest constructor reads it at run + * time; so does this walk, in Node's validation order and with its error + * shapes. Documented options outside the implemented set throw the + * runtime fence when they carry a value, rather than being ignored; + * undocumented keys are ignored, as Node ignores them. */ + +/* The documented options this walk does not implement. A truthy value + * fences; setHost and setDefaultHeaders fence only when false. */ +static const char *const SCR_HTTP_REQ_FENCED_OPTIONS[] = { + "auth", "createConnection", "family", "hints", "httpValidation", "insecureHTTPParser", + "joinDuplicateHeaders", "localAddress", "localPort", "lookup", "uniqueHeaders", NULL, +}; + +static const char SCR_HTTP_REQ_SUPPORTED[] = + "hostname/host, port, path, method, headers, timeout, agent, signal, socketPath, " + "defaultPort, protocol, and maxHeaderSize are the supported options"; + +void scr_http_req_opts_fence(const char *api, const char *key, const char *hint) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, api); + scr_jb_puts(&b, " option '"); + scr_jb_puts(&b, key); + scr_jb_puts(&b, "' has no static lowering — "); + scr_jb_puts(&b, hint); + scr_throw_error(SCR_ERR_ERROR, scr_jb_finish(&b)); +} + +void scr_http_req_opts_free(ScrHttpReqOpts *o) { + scr_str_release(o->host); + scr_str_release(o->path); + scr_str_release(o->method); + scr_str_release(o->socket_path); + if (o->headers) scr_arr_release(o->headers); + scr_dyn_release(o->agent); + scr_dyn_release(o->signal); + scr_dyn_release(o->view); + memset(o, 0, sizeof *o); +} + +/* A member of the options record, NULL when absent or undefined. */ +static const ScrDyn *scr_http_req_opt(const ScrDyn *opts, const char *key) { + const ScrDyn *v = scr_dyn_obj_get(opts, key, strlen(key)); + return v == NULL || v->kind == SCR_DYN_UNDEF ? NULL : v; +} + +static bool scr_http_req_truthy(const ScrDyn *v) { return v != NULL && scr_dyn_truthy(v); } + +/* validatePort's accepted values: an integer in [0, 65535] or a string + * holding one. NaN when the value is not a valid port. */ +static double scr_http_req_port_value(const ScrDyn *v) { + if (v->kind == SCR_DYN_NUM) { + double n = v->v.num; + return n == n && trunc(n) == n && n >= 0 && n <= 65535 ? n : NAN; + } + if (v->kind == SCR_DYN_STR) { + bool blank = true; + for (size_t i = 0; i < v->v.str->len && blank; i++) + blank = isspace((unsigned char)v->v.str->data[i]) != 0; + if (blank) return NAN; + ScrStr *s = scr_str_retain(v->v.str); + double n = scr_string_to_number(s); + scr_str_release(s); + return n == n && trunc(n) == n && n >= 0 && n <= 65535 ? n : NAN; + } + return NAN; +} + +/* Reads `opts` (borrowed) into `o` in the ClientRequest constructor's + * order. False with the exception pending (`o` holds nothing). */ +bool scr_http_req_opts_read(const ScrDyn *opts, bool secure, const char *api, ScrHttpReqOpts *o) { + memset(o, 0, sizeof *o); + o->port = -1; + o->default_port = secure ? 443 : 80; + if (opts != NULL && opts->kind == SCR_DYN_TYPED_REF) { + o->view = scr_dyn_typed_ref_materialize(opts); + opts = o->view; + } + if (opts == NULL || opts->kind == SCR_DYN_UNDEF || opts->kind == SCR_DYN_NULL) { + opts = NULL; /* Node spreads it: every option takes its default */ + } else if (opts->kind != SCR_DYN_OBJ) { + scr_http_req_opts_fence(api, "options", "pass an options object or a URL string"); + scr_http_req_opts_free(o); + return false; + } +#define OPT(key) (opts != NULL ? scr_http_req_opt(opts, key) : NULL) + /* agent: false, null/undefined (the global agent), or an Agent */ + const ScrDyn *agent = OPT("agent"); + if (agent != NULL && agent->kind != SCR_DYN_NULL && + !(agent->kind == SCR_DYN_BOOL && !agent->v.b) && + !(agent->kind == SCR_DYN_HANDLE && agent->v.handle.tag == SCR_DYNH_HTTP_AGENT)) { + scr_dyn_prop_type_fail("options.agent", "one of Agent-like Object, undefined, or false", agent); + scr_http_req_opts_free(o); + return false; + } + if (agent != NULL) o->agent = scr_dyn_retain((ScrDyn *)agent); + bool agent_secure = secure; + if (agent != NULL && agent->kind == SCR_DYN_HANDLE) + agent_secure = ((ScrHttpAgent *)agent->v.handle.ptr)->secure; + /* path: String(options.path) when truthy, without unescaped characters */ + const ScrDyn *path = OPT("path"); + if (scr_http_req_truthy(path)) { + o->path = scr_dyn_string_coerce_js(path); + if (o->path == NULL) { + scr_http_req_opts_free(o); + return false; + } + bool bad = false; + for (size_t i = 0; i < o->path->len && !bad; i++) { + unsigned char ch = (unsigned char)o->path->data[i]; + if (ch < 0x21) bad = true; + else if (ch >= 0x80) { + /* U+0080..U+00FF (lead byte C2/C3) pass; anything above is unescaped */ + if (ch != 0xc2 && ch != 0xc3) bad = true; + else i++; + } + } + if (bad) { + static const char msg[] = "Request path contains unescaped characters"; + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_UNESCAPED_CHARACTERS"); + scr_http_req_opts_free(o); + return false; + } + } else { + o->path = scr_str_new("/", 1); + } + /* protocol: options.protocol || the agent's, which must match */ + const ScrDyn *protocol = OPT("protocol"); + const char *expected = agent_secure ? "https:" : "http:"; + if (scr_http_req_truthy(protocol)) { + ScrStr *p = scr_dyn_string_coerce_js(protocol); + if (p == NULL) { + scr_http_req_opts_free(o); + return false; + } + if (p->len != strlen(expected) || memcmp(p->data, expected, p->len) != 0) { + char msg[192]; + int n = snprintf(msg, sizeof msg, "Protocol \"%.*s\" not supported. Expected \"%s\"", + (int)(p->len < 64 ? p->len : 64), p->data, expected); + scr_str_release(p); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)n, "ERR_INVALID_PROTOCOL"); + scr_http_req_opts_free(o); + return false; + } + scr_str_release(p); + } + /* defaultPort, then port (validated when the dial is TCP) */ + const ScrDyn *default_port = OPT("defaultPort"); + if (scr_http_req_truthy(default_port) && default_port->kind == SCR_DYN_NUM) + o->default_port = (int)default_port->v.num; + else if (agent != NULL && agent->kind == SCR_DYN_HANDLE) + o->default_port = (int)((ScrHttpAgent *)agent->v.handle.ptr)->default_port; + const ScrDyn *port = OPT("port"); + /* host: validateHost(hostname) || validateHost(host) || 'localhost' */ + static const char *const host_keys[] = { "hostname", "host" }; + for (size_t i = 0; i < 2; i++) { + const ScrDyn *h = OPT(host_keys[i]); + if (h == NULL || h->kind == SCR_DYN_NULL) continue; + if (h->kind != SCR_DYN_STR) { + char name[24]; + snprintf(name, sizeof name, "options.%s", host_keys[i]); + scr_dyn_prop_type_fail(name, "of type string or one of undefined or null", h); + scr_http_req_opts_free(o); + return false; + } + if (o->host == NULL && h->v.str->len > 0) o->host = scr_str_retain(h->v.str); + } + if (o->host == NULL) o->host = scr_str_new("localhost", 9); + const ScrDyn *set_host = OPT("setHost"); + if (set_host != NULL && set_host->kind == SCR_DYN_BOOL && !set_host->v.b) { + scr_http_req_opts_fence(api, "setHost", "the Host header is always sent — leave setHost at its true default"); + scr_http_req_opts_free(o); + return false; + } + const ScrDyn *set_default = OPT("setDefaultHeaders"); + if (set_default != NULL && set_default->kind == SCR_DYN_BOOL && !set_default->v.b) { + scr_http_req_opts_fence(api, "setDefaultHeaders", "the default headers are always sent — leave setDefaultHeaders at its true default"); + scr_http_req_opts_free(o); + return false; + } + const ScrDyn *socket_path = OPT("socketPath"); + /* timeout: getTimerDuration(options.timeout, 'timeout') */ + const ScrDyn *timeout = OPT("timeout"); + if (timeout != NULL) { + if (timeout->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("timeout", "of type number", timeout); + scr_http_req_opts_free(o); + return false; + } + double t = timeout->v.num; + if (!(t >= 0 && isfinite(t))) { + char recv[48], msg[192]; + scr_num_received(t, recv); + int n = snprintf(msg, sizeof msg, + "The value of \"timeout\" is out of range. It must be a non-negative finite number. Received %s", + recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)n, "ERR_OUT_OF_RANGE"); + scr_http_req_opts_free(o); + return false; + } + o->timeout = t; + } + /* method: a string token, upper-cased; GET when absent or empty */ + const ScrDyn *method = OPT("method"); + if (method != NULL && method->kind != SCR_DYN_NULL && method->kind != SCR_DYN_STR) { + scr_dyn_prop_type_fail("options.method", "of type string", method); + scr_http_req_opts_free(o); + return false; + } + if (method != NULL && method->kind == SCR_DYN_STR && method->v.str->len > 0) { + o->method = scr_str_new(method->v.str->data, method->v.str->len); + for (size_t i = 0; i < o->method->len; i++) { + if (!scr_http_token_char((unsigned char)o->method->data[i])) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "Method must be a valid HTTP token [\""); + scr_jb_write(&b, o->method->data, o->method->len); + scr_jb_puts(&b, "\"]"); + ScrStr *msg = scr_jb_finish(&b); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg->data, msg->len, "ERR_INVALID_HTTP_TOKEN"); + scr_str_release(msg); + scr_http_req_opts_free(o); + return false; + } + } + for (size_t i = 0; i < o->method->len; i++) { + unsigned char ch = (unsigned char)o->method->data[i]; + if (ch >= 'a' && ch <= 'z') o->method->data[i] = (char)(ch - 'a' + 'A'); + } + } else { + o->method = scr_str_new("GET", 3); + } + const ScrDyn *max_header = OPT("maxHeaderSize"); + if (max_header != NULL) { + if (max_header->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("maxHeaderSize", "of type number", max_header); + scr_http_req_opts_free(o); + return false; + } + o->max_header_size = max_header->v.num; + o->has_max_header_size = true; + } + for (size_t i = 0; SCR_HTTP_REQ_FENCED_OPTIONS[i] != NULL; i++) { + if (scr_http_req_truthy(OPT(SCR_HTTP_REQ_FENCED_OPTIONS[i]))) { + scr_http_req_opts_fence(api, SCR_HTTP_REQ_FENCED_OPTIONS[i], SCR_HTTP_REQ_SUPPORTED); + scr_http_req_opts_free(o); + return false; + } + } + /* headers: an object (String(number) values, arrays as repeated lines) + * or the flat [name, value, ...] array */ + const ScrDyn *headers = OPT("headers"); + ScrDyn *headers_view = NULL; + if (headers != NULL && headers->kind == SCR_DYN_TYPED_REF) { + headers_view = scr_dyn_typed_ref_materialize(headers); + headers = headers_view; + } + if (headers != NULL && headers->kind == SCR_DYN_OBJ) { + for (size_t i = 0; i < headers->v.obj.len; i++) { + const ScrDynEntry *e = &headers->v.obj.entries[i]; + ScrStr *name = scr_str_new(e->key, e->key_len); + ScrStr *label = scr_str_new("", 0); + scr_http_validate_header_name(name, label); + scr_str_release(label); + const ScrDyn *v = e->value; + if (!scr_exc_pending()) { + if (v->kind == SCR_DYN_ARR) { + for (size_t k = 0; k < v->v.arr.len && !scr_exc_pending(); k++) + scr_http_validate_header_value(name, v->v.arr.items[k]); + } else { + scr_http_validate_header_value(name, v); + } + } + scr_str_release(name); + if (scr_exc_pending()) break; + } + if (!scr_exc_pending()) o->headers = scr_http_dynh_pairs(headers); + } else if (headers != NULL && headers->kind == SCR_DYN_ARR) { + o->headers = scr_http_dynh_flat_pairs(headers); + } else if (headers != NULL && headers->kind != SCR_DYN_NULL) { + scr_http_req_opts_fence(api, "headers", "headers is an object of string or number values here"); + } + scr_dyn_release(headers_view); + if (scr_exc_pending()) { + scr_http_req_opts_free(o); + return false; + } + if (o->headers == NULL) o->headers = scr_arr_new(SCR_ELEM_STR, 0); + const ScrDyn *signal = OPT("signal"); + if (signal != NULL) o->signal = scr_dyn_retain((ScrDyn *)signal); + /* the connection: a Unix socket (net validates the path) or host:port */ + if (scr_http_req_truthy(socket_path)) { + if (socket_path->kind != SCR_DYN_STR) { + scr_dyn_prop_type_fail("options.path", "of type string", socket_path); + scr_http_req_opts_free(o); + return false; + } + o->socket_path = scr_str_retain(socket_path->v.str); + if (scr_http_req_truthy(port)) { + double n = scr_http_req_port_value(port); + if (n == n) o->port = n; + } + } else if (scr_http_req_truthy(port)) { + double n = scr_http_req_port_value(port); + if (n != n) { + char detail[96], msg[192]; + const char *d = scr_dyn_specific_type(port, detail, sizeof detail); + int len = snprintf(msg, sizeof msg, "Port should be >= 0 and < 65536. Received %s.", d); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_SOCKET_BAD_PORT"); + scr_http_req_opts_free(o); + return false; + } + o->port = n; + } +#undef OPT + return true; +} + +/* Builds the request `o` describes (its references stay with `o`). The + * signal is checked before construction and attached after it, like the + * literal lowering's sequence. +1, or NULL with the exception pending. */ +ScrHttpClientReq *scr_http_req_opts_send(ScrHttpReqOpts *o, bool auto_end, + ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn, + ScrDyn *(*add_abort)(ScrDyn *signal, ScrDyn *listener), + void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, + ScrStr *pool_key /*borrowed, nullable*/) { + if (o->signal != NULL) { + scr_http_client_signal_check(o->signal); + if (scr_exc_pending()) { + if (cb) scr_closure_release(cb); + return NULL; + } + } + /* without a port option the agent's default port applies (< 0), and + * without an agent the scheme's */ + double port = o->port >= 0 ? o->port : o->agent != NULL && o->agent->kind == SCR_DYN_HANDLE ? -1 + : o->default_port; + scr_http_next_socket_path = o->socket_path; + scr_http_socket_dialer = &scr_net_connect_path; + ScrHttpClientReq *c = scr_http_request_agent_ex( + o->host, port, o->path, o->method, o->timeout, o->headers, auto_end, o->agent, cb, fn, + o->default_port, wrap, wrap_ctx, pool_key); + if (c == NULL) return NULL; + if (o->has_max_header_size) { + scr_http_client_max_header_size(c, o->max_header_size); + if (scr_exc_pending()) return c; + } + if (o->signal != NULL) scr_http_client_add_abort_signal(c, o->signal, add_abort); + return c; +} + +/* http.request/http.get(options[, cb]) with a runtime options record. */ +ScrHttpClientReq *scr_http_request_dyn(const ScrDyn *opts /*borrowed*/, bool auto_end, + ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn, + ScrDyn *(*add_abort)(ScrDyn *signal, ScrDyn *listener)) { + ScrHttpReqOpts o; + if (!scr_http_req_opts_read(opts, false, "http.request", &o)) { + if (cb) scr_closure_release(cb); + return NULL; + } + ScrHttpClientReq *c = scr_http_req_opts_send(&o, auto_end, cb, fn, add_abort, NULL, NULL, NULL); + scr_http_req_opts_free(&o); + return c; +} diff --git a/packages/runtime/src/scr_http.h b/packages/runtime/src/scr_http.h index a4dcc44ce..654362c06 100644 --- a/packages/runtime/src/scr_http.h +++ b/packages/runtime/src/scr_http.h @@ -371,6 +371,40 @@ ScrHttpClientReq *scr_http_request_agent(ScrStr *host /*borrowed*/, double port, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, const ScrDyn *agent /*borrowed*/, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn); /* +1 */ +/* http.request/get with a runtime options record (a RequestOptions + * variable, or a literal spreading one or setting socketPath). The walk + * reads the record in the ClientRequest constructor's order; https adds + * its TLS options (scr_tls.c) and sends through the same path. */ +typedef struct ScrHttpReqOpts { + ScrStr *host; /* +1: hostname || host || "localhost" */ + double port; /* < 0: no port option */ + int default_port; /* defaultPort, the agent's, or the scheme's */ + ScrStr *path; /* +1 */ + ScrStr *method; /* +1, upper-cased */ + double timeout; /* 0 = none */ + ScrArr *headers; /* +1 name/value pairs */ + ScrDyn *agent; /* +1, NULL = the global agent */ + ScrDyn *signal; /* +1, NULL = none */ + ScrStr *socket_path; /* +1, NULL = dial host:port */ + double max_header_size; + bool has_max_header_size; + ScrDyn *view; /* +1 materialized typed record, if any */ +} ScrHttpReqOpts; +bool scr_http_req_opts_read(const ScrDyn *opts /*borrowed*/, bool secure, const char *api, + ScrHttpReqOpts *o); /* false = exception pending */ +void scr_http_req_opts_free(ScrHttpReqOpts *o); +void scr_http_req_opts_fence(const char *api, const char *key, const char *hint); /* throws */ +ScrHttpClientReq *scr_http_req_opts_send(ScrHttpReqOpts *o, bool auto_end, + ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn, + ScrDyn *(*add_abort)(ScrDyn *signal, ScrDyn *listener), + void (*wrap)(ScrNetSocket *, void *), void *wrap_ctx, + ScrStr *pool_key /*borrowed, nullable*/); /* +1 */ +ScrHttpClientReq *scr_http_request_dyn(const ScrDyn *opts /*borrowed*/, bool auto_end, + ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn, + ScrDyn *(*add_abort)(ScrDyn *signal, ScrDyn *listener)); /* +1 */ +ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool auto_end, + ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn, + ScrDyn *(*add_abort)(ScrDyn *signal, ScrDyn *listener)); /* +1 */ #ifdef SCR_RC_AUDIT long scr_http_live_count(void); #endif diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index f13906454..a92f738bd 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -21,6 +21,7 @@ #include #include #include +#include /* AF_UNIX: listen(path) and http's socketPath */ #if defined(__FreeBSD__) || defined(__DragonFly__) || defined(_AIX) || defined(__sun) #include #endif @@ -442,6 +443,7 @@ struct ScrNetServer { * '::' when bound_v6, else '0.0.0.0' — Node's own * address().address answers) */ ScrStr *pending_err; /* deferred listen failure */ + ScrStr *unix_path; /* listen(path): the bound socket file, removed at close */ size_t nconns; ScrNetLs conn_ls, err_ls, close_ls, listening_cbs, timeout_ls; /* server.maxConnections (unset = undefined) and its 'drop' listeners */ @@ -532,6 +534,14 @@ struct ScrNetSocket { /* the in-flight dial chain's LAST failure — surfaced (moved into * pending_err) only when the list exhausts; a later success drops it */ ScrStr *dial_err; + /* a dial that is not the TCP sockaddr path (the Unix-socket connect): + * scr_net_sock_dial_peer hands the dial to it; unix_path is its target */ + void (*dial_hook)(ScrNetSocket *s); + ScrStr *unix_path; + /* 'lookup' listeners and the resolutions waiting to reach them (the + * lookup unit's records; see scr_net_lookup_ops) */ + ScrNetLs lookup_ls; + struct ScrNetLookupEv *lookup_evs; ScrNetLs data_ls, end_ls, close_ls, err_ls, conn_ls, timeout_ls, readable_ls; /* Flow control (pause()/resume()): user_paused holds reads OFF even * with consumers (kernel/TCP backpressure is the buffer); flowing @@ -608,6 +618,21 @@ typedef struct ScrNetTunnel { size_t in_len; } ScrNetTunnel; +/* The 'lookup' event's half of the runtime, installed by the first + * 'lookup' registration: programs without a 'lookup' listener never link + * it, and the hook sites below are a NULL check. */ +typedef struct ScrNetLookupOps { + /* a host name resolved (ip/family) or failed (err_msg, ip NULL) */ + void (*record)(ScrNetSocket *s, const char *host, size_t host_len, const char *ip, int family, + const char *err_msg); + void (*flush)(ScrNetSocket *s); /* deliver the recorded resolutions */ + void (*free)(ScrNetSocket *s); +} ScrNetLookupOps; +/* Not static: the compiler must not resolve the calls through it to the + * one implementation, which would link that half into every program. */ +extern SCR_TL const ScrNetLookupOps *scr_net_lookup_ops; +SCR_TL const ScrNetLookupOps *scr_net_lookup_ops = NULL; + static void scr_net_tunnel_free(ScrNetTunnel *t) { if (t == NULL) return; free(t->out); @@ -648,6 +673,7 @@ void scr_net_server_release(ScrNetServer *s) { scr_dyn_release(s->drop_max_connection); scr_str_release(s->bound_host); scr_str_release(s->pending_err); + scr_str_release(s->unix_path); if (s->fd >= 0) scr_net_close_fd_raw(s->fd); #ifdef SCR_RC_AUDIT scr_net_live--; @@ -685,6 +711,9 @@ void scr_net_sock_release(ScrNetSocket *s) { free(s->dial_ips); scr_str_release(s->dial_err); scr_str_release(s->dial_host); + scr_str_release(s->unix_path); + scr_net_ls_drop(&s->lookup_ls); + if (s->lookup_evs != NULL) scr_net_lookup_ops->free(s); scr_net_tunnel_free(s->tunnel); free(s->wbuf); free(s->rbuf); @@ -1385,6 +1414,10 @@ static void scr_net_sock_connect_done(ScrNetSocket *s) { * one): the transport handshake starts, or 'connect' fires and buffered * writes flush. */ static void scr_net_sock_connected(ScrNetSocket *s) { + if (s->lookup_evs != NULL) { + scr_net_lookup_ops->flush(s); + if (scr_exc_pending()) return; + } scr_net_sock_update_write(s); /* keeps the filter iff bytes are buffered */ scr_net_sock_update_read(s); scr_net_sock_touch(s); @@ -1892,6 +1925,11 @@ void scr_net_server_close_direct(ScrNetServer *s, ScrClosure *cb /*moves, nullab scr_net_close_fd_raw(s->fd); /* stops accepting NOW (registrations dropped first) */ s->fd = -1; s->listening = false; +#ifndef _WIN32 + /* a Unix-socket server removes its socket file as it closes, like + * libuv's pipe close */ + if (s->unix_path != NULL) unlink(s->unix_path->data); +#endif } /* A close BEFORE the deferred 'listening' delivered cancels it — Node's * emitListeningNT checks the handle close() nulled, so the listen @@ -1905,6 +1943,9 @@ void scr_net_server_close_direct(ScrNetServer *s, ScrClosure *cb /*moves, nullab s->close_seq = ++scr_net_close_seq; } s->closing = true; /* the sweep fires 'close' once nconns drains */ + /* a server that never listened is not registered yet: Node still emits + * its 'close' (and runs close callbacks) */ + scr_net_server_register(s); /* Node's http server.close() first closes its idle keep-alive * connections; busy ones end after their response (scr_http.c). Only * HTTP connections carry an idle probe, so plain net servers keep @@ -2207,7 +2248,10 @@ ScrStr *scr_net_blocking_lookup(ScrStr *host /*borrowed*/) { * autoselect), and any other name is resolved when the dial starts. */ static void scr_net_sock_set_peer(ScrNetSocket *s, ScrStr *host /*borrowed, nullable*/) { const char *h = host && host->len > 0 ? host->data : "localhost"; - if (strcmp(h, "localhost") == 0) h = "127.0.0.1"; + if (strcmp(h, "localhost") == 0) { + h = "127.0.0.1"; + if (scr_net_lookup_ops != NULL) scr_net_lookup_ops->record(s, "localhost", 9, h, 4, NULL); + } snprintf(s->peer_ip, sizeof s->peer_ip, "%s", h); struct in_addr a4; struct in6_addr a6; @@ -2253,6 +2297,9 @@ static void scr_net_sock_dial_host(ScrNetSocket *s) { out = inet_ntop(AF_INET6, &((struct sockaddr_in6 *)ai->ai_addr)->sin6_addr, ip, sizeof ip); } if (out == NULL) continue; + if (scr_net_lookup_ops != NULL) + scr_net_lookup_ops->record(s, host->data, host->len, ip, ai->ai_family == AF_INET6 ? 6 : 4, + NULL); if (first == AF_UNSPEC) first = ai->ai_family; size_t f = ai->ai_family == first ? 0 : 1; if (fam[f] == NULL) { @@ -2283,6 +2330,7 @@ static void scr_net_sock_dial_host(ScrNetSocket *s) { char msg[320]; snprintf(msg, sizeof msg, "getaddrinfo %s %.*s", code, (int)(host->len < 255 ? host->len : 255), host->data); + if (scr_net_lookup_ops != NULL) scr_net_lookup_ops->record(s, host->data, host->len, NULL, 0, msg); free(ips); scr_str_release(host); s->pending_err = scr_str_new(msg, strlen(msg)); @@ -2304,6 +2352,10 @@ static void scr_net_sock_dial_host(ScrNetSocket *s) { * queue — the socket exists, buffers writes, and dials when a slot * frees). */ static void scr_net_sock_dial_peer(ScrNetSocket *s) { + if (s->dial_hook != NULL) { + s->dial_hook(s); + return; + } if (s->dial_host != NULL) { scr_net_sock_dial_host(s); return; @@ -2552,6 +2604,167 @@ void scr_net_sock_dial_start(ScrNetSocket *s) { scr_net_sock_dial_peer(s); } +/* ── Unix domain sockets ─────────────────────────────────────────────── + * + * listen(path) and the http client's socketPath option. Node (libuv) + * reports these failures with the path where a TCP error names + * host:port: "connect ENOENT /run/x.sock", "listen EADDRINUSE: address + * already in use /run/x.sock". Windows has no Unix-socket support here + * (Node maps paths to named pipes there), so both forms fail with ENOTSUP. */ + +static const char *scr_net_unix_errname(int err) { + switch (err) { + case ENOENT: return "ENOENT"; + case ENOTDIR: return "ENOTDIR"; + case ENAMETOOLONG: return "ENAMETOOLONG"; + case EAGAIN: return "EAGAIN"; + case EPROTOTYPE: return "EPROTOTYPE"; + default: return scr_net_errname(err); + } +} + +static const char *scr_net_unix_reason(int err) { + switch (err) { + case EADDRINUSE: return "address already in use"; + case EACCES: return "permission denied"; + case ENOENT: return "no such file or directory"; + case ENOTDIR: return "not a directory"; + case ENAMETOOLONG: return "name too long"; + case EINVAL: return "invalid argument"; + default: return "operation not supported"; + } +} + +/* Fills a sockaddr_un for `path`; false (with errno set) when it does not + * fit, which libuv reports as ENAMETOOLONG too. */ +#ifndef _WIN32 +static bool scr_net_unix_addr(const ScrStr *path, struct sockaddr_un *sa) { + memset(sa, 0, sizeof *sa); + sa->sun_family = AF_UNIX; + if (path->len >= sizeof sa->sun_path || memchr(path->data, '\0', path->len) != NULL) { + errno = ENAMETOOLONG; + return false; + } + memcpy(sa->sun_path, path->data, path->len); + return true; +} +#endif + +/* The dial_hook of a Unix-socket client: a nonblocking connect that + * completes through the same write-readiness path as TCP. A local + * connect usually settles at once; its failure is the deferred 'error'. */ +static void scr_net_sock_dial_unix(ScrNetSocket *s) { + int err = ENOTSUP; +#ifndef _WIN32 + struct sockaddr_un sa; + if (scr_net_unix_addr(s->unix_path, &sa)) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + fputs("scriptc: socket() failed\n", stderr); + abort(); + } + scr_net_nonblock(fd); + s->fd = fd; + if (connect(fd, (struct sockaddr *)&sa, sizeof sa) == 0 || errno == EINPROGRESS) { + s->connecting = true; + scr_net_sock_update_write(s); + return; + } + } + err = errno; +#endif + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "connect "); + scr_jb_puts(&b, scr_net_unix_errname(err)); + scr_jb_putc(&b, ' '); + scr_jb_write(&b, s->unix_path->data, s->unix_path->len); + s->pending_err = scr_jb_finish(&b); + s->had_error = true; + s->connecting = false; + scr_net_sock_close_fd(s); +} + +/* A client socket for the Unix socket at `path` (borrowed). `deferred` + * creates it the way scr_net_connect_deferred does: registered and + * buffering, dialed by scr_net_sock_dial_start. +1. */ +ScrNetSocket *scr_net_connect_path(ScrStr *path, bool deferred) { + ScrNetSocket *s = scr_net_sock_new(); + s->unix_path = scr_str_retain(path); + s->dial_hook = &scr_net_sock_dial_unix; + if (!scr_net_poller_init()) { + fputs("scriptc: event poller init failed\n", stderr); + abort(); + } + if (deferred) { + s->connecting = true; + s->dial_deferred = true; + } else { + scr_net_sock_dial_unix(s); + } + scr_net_sock_register(s); + return s; +} + +/* server.listen(path[, cb]): binds a Unix socket now; a failure is the + * deferred 'error', success the deferred 'listening', as for a port. */ +void scr_net_listen_path(ScrNetServer *s, ScrStr *path /*borrowed*/, + ScrClosure *cb /*moves, nullable*/) { + /* Node's isPipeName: a string that reads as a non-negative number is a port */ + double port = scr_string_to_number(path); + if (port >= 0) { + scr_net_listen_any(s, port, false, cb); + return; + } + if (cb) scr_net_ls_add(&s->listening_cbs, cb, NULL, true); + if (s->listening || s->close_emitted) return; + if (!scr_net_poller_init()) { + fputs("scriptc: event poller init failed\n", stderr); + abort(); + } + int err = ENOTSUP; +#ifndef _WIN32 + struct sockaddr_un sa; + if (scr_net_unix_addr(path, &sa)) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + fputs("scriptc: socket() failed\n", stderr); + abort(); + } + scr_net_nonblock(fd); + if (bind(fd, (struct sockaddr *)&sa, sizeof sa) == 0 && listen(fd, 511) == 0) { + s->fd = fd; + s->listening = true; + s->emit_listening = true; + scr_str_release(s->unix_path); + s->unix_path = scr_str_retain(path); + /* address() carries the path in its address field (Node answers + * the path string itself) */ + scr_str_release(s->bound_host); + s->bound_host = scr_str_retain(path); + scr_net_watch_read(fd, s, true); + scr_net_server_register(s); + return; + } + err = errno; + close(fd); + } else { + err = errno; + } +#endif + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "listen "); + scr_jb_puts(&b, scr_net_unix_errname(err)); + scr_jb_puts(&b, ": "); + scr_jb_puts(&b, scr_net_unix_reason(err)); + scr_jb_putc(&b, ' '); + scr_jb_write(&b, path->data, path->len); + if (!s->pending_err) s->pending_err = scr_jb_finish(&b); + else scr_str_release(scr_jb_finish(&b)); + scr_net_server_register(s); /* the sweep delivers the failure */ +} + /* ── the caller-lookup dial (net.connect with a lookup option) ───────── * * portless's createLoopbackConnection: connect({ host, port, @@ -4094,6 +4307,136 @@ void scr_net_sock_on_close_dyn(ScrNetSocket *s, const ScrDyn *listener /*borrowe (void *)&scr_net_dyn_fire_arg, once); } +/* ── 'lookup' ─────────────────────────────────────────────────────────── */ + +typedef struct ScrNetLookupEv { + struct ScrNetLookupEv *next; + ScrStr *host; + ScrStr *ip; /* NULL with err_msg */ + int family; + ScrStr *err_msg; /* "getaddrinfo ENOTFOUND host" */ +} ScrNetLookupEv; + +static void scr_net_lookup_record(ScrNetSocket *s, const char *host, size_t host_len, + const char *ip, int family, const char *err_msg) { + ScrNetLookupEv *ev = calloc(1, sizeof *ev); + if (!ev) scr_net_oom(); + ev->host = scr_str_new(host, host_len); + ev->ip = ip != NULL ? scr_str_new(ip, strlen(ip)) : NULL; + ev->family = family; + ev->err_msg = err_msg != NULL ? scr_str_new(err_msg, strlen(err_msg)) : NULL; + ScrNetLookupEv **link = &s->lookup_evs; + while (*link) link = &(*link)->next; + *link = ev; +} + +static void scr_net_lookup_ev_free(ScrNetLookupEv *ev) { + scr_str_release(ev->host); + scr_str_release(ev->ip); + scr_str_release(ev->err_msg); + free(ev); +} + +static void scr_net_lookup_free(ScrNetSocket *s) { + while (s->lookup_evs) { + ScrNetLookupEv *ev = s->lookup_evs; + s->lookup_evs = ev->next; + scr_net_lookup_ev_free(ev); + } +} + +/* Node emits ('lookup', err, address, family, host) from the resolver's + * answer, before it dials: here the resolutions recorded at the dial reach + * the listeners just before 'connect' (or the lookup's 'error'). */ +static void scr_net_lookup_flush(ScrNetSocket *s) { + while (s->lookup_evs && !scr_exc_pending()) { + ScrNetLookupEv *ev = s->lookup_evs; + s->lookup_evs = ev->next; + if (s->lookup_ls.n > 0) { + ScrDyn *args[4]; + if (ev->err_msg != NULL) { + ScrError *e = scr_error_new(SCR_ERR_ERROR, ev->err_msg); + const char *code = strchr(ev->err_msg->data, ' '); + if (code != NULL) { + char buf[32]; + size_t n = strcspn(code + 1, " "); + if (n < sizeof buf) { + memcpy(buf, code + 1, n); + buf[n] = '\0'; + scr_error_set_code(e, buf); + } + } + args[0] = scr_dyn_from_error(e); + scr_error_release(e); + args[1] = scr_dyn_retain(scr_dyn_undefined()); + args[2] = scr_dyn_retain(scr_dyn_undefined()); + } else { + args[0] = scr_dyn_new_null(); + args[1] = scr_dyn_new_str(ev->ip); + args[2] = scr_dyn_new_num((double)ev->family); + } + args[3] = scr_dyn_new_str(ev->host); + ScrNetL *snap; + size_t n = scr_net_ls_snapshot(&s->lookup_ls, &snap); + scr_dyn_this_push(s, SCR_DYNH_NET_SOCKET); + for (size_t i = 0; i < n; i++) { + if (!scr_exc_pending()) { + ScrDyn *fn = scr_dyn_listener_fn(snap[i].cb); + scr_dyn_release(scr_dyn_call(fn, args, 4, "listener")); + scr_dyn_release(fn); + } + scr_closure_release(snap[i].cb); + } + scr_dyn_this_pop(); + free(snap); + for (size_t i = 0; i < 4; i++) scr_dyn_release(args[i]); + } + scr_net_lookup_ev_free(ev); + } +} + +static const ScrNetLookupOps scr_net_lookup_ops_impl = { + &scr_net_lookup_record, + &scr_net_lookup_flush, + &scr_net_lookup_free, +}; + +/* The C main of a program with a 'lookup' listener installs the lookup + * records before any socket dials. */ +void scr_net_lookup_install(void) { scr_net_lookup_ops = &scr_net_lookup_ops_impl; } + +/* socket.on/once('lookup', listener) with a dyn function listener. The + * socket resolved when net.connect ran, before the listener was added; it + * delivers its recorded answers at the connect, like Node's asynchronous + * lookup. */ +void scr_net_sock_on_lookup(ScrNetSocket *s, const ScrDyn *listener /*borrowed*/, bool once) { + if (s->close_emitted) return; + scr_net_ls_add(&s->lookup_ls, scr_dyn_listener_closure_fn(listener, (void *)&scr_net_dyn_fire_arg), + (void *)&scr_net_dyn_fire_arg, once); +} + +/* server.close((err) => ...): the callback runs at 'close' with undefined, + * or with Node's ERR_SERVER_NOT_RUNNING when the server was not listening. */ +static void scr_net_close_not_running_fire(ScrClosure *cb) { + ScrDyn *fn = scr_dyn_listener_fn(cb); + static const char msg[] = "Server is not running."; + ScrStr *text = scr_str_new(msg, sizeof msg - 1); + ScrError *e = scr_error_new(SCR_ERR_ERROR, text); + scr_str_release(text); + scr_error_set_code(e, "ERR_SERVER_NOT_RUNNING"); + ScrDyn *err = scr_dyn_from_error(e); + scr_error_release(e); + scr_dyn_release(scr_dyn_call(fn, &err, 1, "listener")); + scr_dyn_release(err); + scr_dyn_release(fn); +} + +void scr_net_server_close_dyn(ScrNetServer *s, const ScrDyn *listener /*borrowed*/) { + ScrClosure *cb = scr_dyn_listener_closure_fn( + listener, s->listening ? (void *)&scr_dyn_listener_fire0 : (void *)&scr_net_close_not_running_fire); + scr_net_server_close(s, cb); +} + void scr_net_sock_on_write_flush_dyn(ScrNetSocket *s, const ScrDyn *listener /*borrowed*/) { if (s->close_emitted) return; scr_net_ls_add(&s->wcb_ls, scr_dyn_listener_closure_fn(listener, (void *)&scr_net_dyn_fire_arg), @@ -4752,6 +5095,13 @@ static void scr_net_sweep(void) { return; } } + if (sock->lookup_evs != NULL && sock->pending_err) { + scr_net_lookup_ops->flush(sock); + if (scr_exc_pending()) { + scr_net_sock_release(sock); + return; + } + } if (sock->pending_err) { ScrStr *msg = sock->pending_err; sock->pending_err = NULL; diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index f76613a0e..385704654 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -164,6 +164,9 @@ void scr_net_server_timeout_option_set(ScrNetServer *s, double field, const stru ScrStr *scr_net_server_addr_ip(ScrNetServer *s); /* +1 — address().address */ ScrStr *scr_net_server_addr_family(ScrNetServer *s); /* +1 — address().family */ void scr_net_server_close(ScrNetServer *s, ScrClosure *cb /*moves, nullable*/); +void scr_net_server_close_dyn(ScrNetServer *s, const ScrDyn *listener /*borrowed*/); +void scr_net_sock_on_lookup(ScrNetSocket *s, const ScrDyn *listener /*borrowed*/, bool once); +void scr_net_lookup_install(void); void scr_net_server_close_all_connections(ScrNetServer *s); void scr_net_server_close_idle_connections(ScrNetServer *s); ScrDyn *scr_net_server_max_connections_get(ScrNetServer *s); /* +1 */ @@ -237,6 +240,11 @@ bool scr_net_sock_readable(ScrNetSocket *s); /* The deferred dial (the http agent's maxSockets queue): the socket * registers "connecting" and buffers writes; dial_start runs the dial. */ ScrNetSocket *scr_net_connect_deferred(double port, ScrStr *host /*borrowed, nullable*/); /* +1 */ +/* A client socket for the Unix socket at `path`, dialed now or (deferred) + * by scr_net_sock_dial_start. A failure is the deferred 'error'. */ +ScrNetSocket *scr_net_connect_path(ScrStr *path /*borrowed*/, bool deferred); /* +1 */ +/* server.listen(path[, cb]): a Unix-socket server, its file removed at close. */ +void scr_net_listen_path(ScrNetServer *s, ScrStr *path /*borrowed*/, ScrClosure *cb /*moves, nullable*/); void scr_net_sock_dial_start(ScrNetSocket *s); void scr_net_sock_set_timeout(ScrNetSocket *s, double ms); void scr_net_sock_on_timeout(ScrNetSocket *s, ScrClosure *cb /*moves*/, bool once); diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 464b19736..804e51d69 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -1512,6 +1512,74 @@ static ScrBytes *scr_tls_pem_dyn(const ScrDyn *v, const char *what, bool concat) return NULL; } +/* https.request/get with a runtime options record: the http walk, then + * the TLS options the literal https path lowers (rejectUnauthorized, ca, + * servername). Other tls.connect options fence when they carry a value. */ +static const char *const SCR_HTTPS_REQ_FENCED_OPTIONS[] = { + "ALPNProtocols", "cert", "checkServerIdentity", "ciphers", "crl", "dhparam", "ecdhCurve", + "honorCipherOrder", "key", "maxVersion", "minVersion", "passphrase", "pfx", "secureContext", + "secureOptions", "secureProtocol", "session", "sessionIdContext", "sigalgs", NULL, +}; + +ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool auto_end, + ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn, + ScrDyn *(*add_abort)(ScrDyn *signal, ScrDyn *listener)) { + ScrHttpReqOpts o; + if (!scr_http_req_opts_read(opts, true, "https.request", &o)) { + if (cb) scr_closure_release(cb); + return NULL; + } + const ScrDyn *rec = o.view != NULL ? o.view : opts; + bool reject = true; + ScrBytes *ca = NULL; + ScrStr *servername = NULL; + bool ok = true; + if (rec != NULL && rec->kind == SCR_DYN_OBJ) { + for (size_t i = 0; ok && SCR_HTTPS_REQ_FENCED_OPTIONS[i] != NULL; i++) { + const char *key = SCR_HTTPS_REQ_FENCED_OPTIONS[i]; + const ScrDyn *v = scr_dyn_obj_get(rec, key, strlen(key)); + if (v != NULL && scr_tls_dyn_truthy(v)) { + scr_http_req_opts_fence("https.request", key, + "rejectUnauthorized, ca, and servername are the supported TLS options"); + ok = false; + } + } + const ScrDyn *r = ok ? scr_dyn_obj_get(rec, "rejectUnauthorized", 18) : NULL; + if (r != NULL && r->kind == SCR_DYN_BOOL && !r->v.b) reject = false; + const ScrDyn *c = ok ? scr_dyn_obj_get(rec, "ca", 2) : NULL; + if (c != NULL && c->kind != SCR_DYN_UNDEF && c->kind != SCR_DYN_NULL) { + ca = scr_tls_pem_dyn(c, "an https.request 'ca' option", true); + if (ca == NULL) ok = false; + } + const ScrDyn *sn = ok ? scr_dyn_obj_get(rec, "servername", 10) : NULL; + if (sn != NULL && sn->kind != SCR_DYN_UNDEF && sn->kind != SCR_DYN_NULL) { + if (sn->kind != SCR_DYN_STR) { + scr_dyn_prop_type_fail("options.servername", "of type string", sn); + ok = false; + } else { + servername = scr_str_retain(sn->v.str); + } + } + } + if (!ok) { + if (ca) scr_bytes_release(ca); + scr_http_req_opts_free(&o); + if (cb) scr_closure_release(cb); + return NULL; + } + const char *ca_data = ca ? (const char *)ca->data : NULL; + size_t ca_len = ca ? ca->len : 0; + ScrTlsCli *cli = scr_tls_cli_for(o.host, reject, ca_data, ca_len, servername); + ScrStr *key = scr_tls_pool_key(o.host, reject, ca_data, ca_len, servername); + scr_str_release(servername); + ScrHttpClientReq *req = scr_http_req_opts_send(&o, auto_end, cb, fn, add_abort, + &scr_tls_cli_wrap, cli, key); + scr_str_release(key); + if (ca) scr_bytes_release(ca); + scr_http_req_opts_free(&o); + return req; +} + /* The literal path's runtime-valued cert/key extraction (the compiler's * tls.pemDyn libCall): `what` arrives precomposed. undefined throws — the * literal walk required the member's presence. */ diff --git a/tests/corpus/6440-http-socket-path.ts b/tests/corpus/6440-http-socket-path.ts new file mode 100644 index 000000000..f782cf2a4 --- /dev/null +++ b/tests/corpus/6440-http-socket-path.ts @@ -0,0 +1,109 @@ +// HTTP over a Unix domain socket: http.createServer().listen(path) and +// http.request / http.get with a socketPath option, from an object literal, +// from a RequestOptions variable, and from a literal that spreads one. The +// Host header defaults to localhost. A missing socket fails the request +// with ENOENT, a second listen on a bound path fails with EADDRINUSE, and +// closing the server removes the socket file. +import * as http from "node:http"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const sock = join(tmpdir(), `scriptc-http-${process.pid}.sock`); +const shown = (text: string): string => text.split(sock).join(""); + +const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => { + res.setHeader("content-type", "text/plain"); + res.end(`${req.method} ${req.url} host=${req.headers.host} x=${req.headers["x-n"]} body=${body}`); + }); +}); + +function collect(res: http.IncomingMessage, label: string, done: () => void): void { + let text = ""; + res.on("data", (chunk: Buffer) => { + text += chunk.toString(); + }); + res.on("end", () => { + console.log(label, res.statusCode, res.headers["content-type"], text); + done(); + }); +} + +function literal(): Promise { + return new Promise((resolve) => { + const req = http.request( + { socketPath: sock, path: "/literal", method: "POST", headers: { "x-n": "1" } }, + (res) => collect(res, "literal", resolve), + ); + req.end("one"); + }); +} + +const base: http.RequestOptions = { socketPath: sock }; + +function fromVariable(): Promise { + return new Promise((resolve) => { + const headers: Record = { "x-n": 2, "content-type": "text/plain" }; + const options: http.RequestOptions = { ...base, path: "/variable", method: "PUT", headers, timeout: 5000 }; + const req = http.request(options, (res) => collect(res, "variable", resolve)); + req.write("two"); + req.end(); + }); +} + +function spread(): Promise { + return new Promise((resolve) => { + const req = http.request({ ...base, path: "/spread", method: "POST", headers: { "x-n": "3" } }, (res) => + collect(res, "spread", resolve), + ); + req.end("three"); + }); +} + +function get(): Promise { + return new Promise((resolve) => { + const options: http.RequestOptions = { socketPath: sock, path: "/get?q=4" }; + http.get(options, (res) => collect(res, "get", resolve)); + }); +} + +function missing(): Promise { + return new Promise((resolve) => { + const options: http.RequestOptions = { socketPath: `${sock}.missing`, path: "/" }; + const req = http.request(options, () => console.log("unexpected response")); + req.on("error", (err: NodeJS.ErrnoException) => { + console.log("missing", err.code, shown(err.message)); + resolve(); + }); + req.end(); + }); +} + +function busy(): Promise { + return new Promise((resolve) => { + const other = http.createServer(); + other.on("error", (err: NodeJS.ErrnoException) => { + console.log("busy", err.code, shown(err.message)); + resolve(); + }); + other.listen(sock); + }); +} + +server.listen(sock, async () => { + console.log("listening", existsSync(sock)); + await literal(); + await fromVariable(); + await spread(); + await get(); + await missing(); + await busy(); + server.close(() => { + console.log("closed", existsSync(sock)); + }); +}); diff --git a/tests/corpus/6441-http-request-options-record.ts b/tests/corpus/6441-http-request-options-record.ts new file mode 100644 index 000000000..f2eba1a02 --- /dev/null +++ b/tests/corpus/6441-http-request-options-record.ts @@ -0,0 +1,68 @@ +// http.request options read at run time: a RequestOptions value chosen by +// a runtime condition (a TCP endpoint with a string port, or a socket +// path), spread into an options object with a timeout and an optional +// signal; agent: false; an aborted signal; and Node's validation errors +// for the method, port, host, timeout, path, protocol and agent options. +import * as http from "node:http"; + +const server = http.createServer((req, res) => { + const host = (req.headers.host ?? "").replace(/:\d+$/, ":"); + res.end(`${req.method} ${req.url} host=${host} connection=${req.headers.connection} n=${req.headers["x-n"]}`); +}); + +function send(label: string, options: http.RequestOptions): Promise { + return new Promise((resolve) => { + const req = http.request(options, (res) => { + let text = ""; + res.on("data", (chunk: Buffer) => { + text += chunk.toString(); + }); + res.on("end", () => { + console.log(label, res.statusCode, text); + resolve(); + }); + }); + req.on("error", (err: NodeJS.ErrnoException) => { + console.log(label, "error", err.name, err.code, err.message); + resolve(); + }); + req.end(); + }); +} + +function invalid(label: string, options: http.RequestOptions): void { + try { + const req = http.request(options); + req.on("error", () => {}); + req.destroy(); + console.log(label, "accepted"); + } catch (err) { + const e = err as NodeJS.ErrnoException; + console.log(label, e.name, e.code, e.message); + } +} + +server.listen(0, "127.0.0.1", async () => { + const port = (server.address() as { port: number }).port; + const endpoint = process.env.NO_SUCH_ENDPOINT_VAR ?? `http://127.0.0.1:${port}`; + const url = new URL(endpoint); + const location: http.RequestOptions = process.env.NO_SUCH_SOCKET_VAR + ? { socketPath: process.env.NO_SUCH_SOCKET_VAR } + : { port: url.port, host: url.hostname }; + const headers: Record = { "x-n": 1 }; + const controller: AbortController | undefined = undefined; + const options = { ...location, path: "/rpc", method: "post", headers, timeout: 2000, signal: controller }; + await send("spread", options); + await send("agent false", { ...location, path: "/close", agent: false }); + const aborted = new AbortController(); + aborted.abort(); + await send("aborted", { ...location, path: "/never", signal: aborted.signal }); + invalid("method", { ...location, method: "g t" }); + invalid("port", { host: "127.0.0.1", port: "http" }); + invalid("host", { hostname: 5 } as unknown as http.RequestOptions); + invalid("timeout", { ...location, timeout: -1 }); + invalid("path", { ...location, path: "/a b" }); + invalid("protocol", { ...location, protocol: "https:" }); + invalid("agent", { ...location, agent: 5 } as unknown as http.RequestOptions); + server.close(); +}); diff --git a/tests/corpus/6442-https-request-options-record.ts b/tests/corpus/6442-https-request-options-record.ts new file mode 100644 index 000000000..4148e015b --- /dev/null +++ b/tests/corpus/6442-https-request-options-record.ts @@ -0,0 +1,61 @@ +// https.request with options read at run time: a RequestOptions value +// spread with the CA, over TCP and over a Unix socket, an unverifiable +// certificate with and without rejectUnauthorized, and the protocol check. +import * as https from "node:https"; +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ca = readFileSync("tests/fixtures/server/certs/ca.pem"); +const sock = join(tmpdir(), `scriptc-https-${process.pid}.sock`); + +const server = https.createServer( + { + cert: readFileSync("tests/fixtures/server/certs/localhost.pem"), + key: readFileSync("tests/fixtures/server/certs/localhost-key.pem"), + }, + (req, res) => res.end(`${req.method} ${req.url} host=${(req.headers.host ?? "").replace(/:\d+$/, ":")}`), +); +const unix = https.createServer( + { + cert: readFileSync("tests/fixtures/server/certs/localhost.pem"), + key: readFileSync("tests/fixtures/server/certs/localhost-key.pem"), + }, + (req, res) => res.end(`unix ${req.method} ${req.url} host=${req.headers.host}`), +); + +function send(label: string, options: https.RequestOptions): Promise { + return new Promise((resolve) => { + const req = https.request(options, (res) => { + let body = ""; + res.on("data", (c: Buffer) => (body += c.toString())); + res.on("end", () => { + console.log(label, res.statusCode, body); + resolve(); + }); + }); + req.on("error", (err: NodeJS.ErrnoException) => { + console.log(label, "error", err.code); + resolve(); + }); + req.end(); + }); +} + +server.listen(0, () => { + const port = (server.address() as { port: number }).port; + unix.listen(sock, async () => { + const tcp: https.RequestOptions = { host: "localhost", port }; + await send("tcp", { ...tcp, path: "/a", ca, agent: false }); + await send("unix", { socketPath: sock, path: "/b", ca, agent: false }); + await send("unverified", { ...tcp, path: "/c", agent: false }); + await send("unchecked", { ...tcp, path: "/d", rejectUnauthorized: false, agent: false }); + try { + https.request({ ...tcp, protocol: "http:" }); + } catch (err) { + console.log("protocol", (err as NodeJS.ErrnoException).code, (err as Error).message); + } + server.close(); + unix.close(); + }); +}); diff --git a/tests/corpus/6443-net-lookup-http-close-text.ts b/tests/corpus/6443-net-lookup-http-close-text.ts new file mode 100644 index 000000000..1d50d3580 --- /dev/null +++ b/tests/corpus/6443-net-lookup-http-close-text.ts @@ -0,0 +1,73 @@ +// A socket's 'lookup' event for a host name (none for an IP address), +// string 'data' listeners after setEncoding on both sides of an HTTP +// exchange, res.end() of a string or undefined, and the close callback of +// a server that never listened. +import * as http from "node:http"; +import * as net from "node:net"; + +function lookups(): Promise { + return new Promise((resolve) => { + const tcp = net.createServer((s) => s.end("hi")); + tcp.listen(0, "127.0.0.1", () => { + const port = (tcp.address() as net.AddressInfo).port; + const named = net.connect({ host: "localhost", port }); + let reported = false; + named.on("lookup", (err: Error | null, address: string, family: number, host: string) => { + // macOS also answers ::1; report the IPv4 answer the dial uses. + if (family === 4 && !reported) { + reported = true; + console.log("lookup", err, address, family, host); + } + }); + named.once("connect", () => console.log("connect after lookup", reported)); + named.on("data", () => {}); + named.on("close", () => { + const direct = net.connect({ host: "127.0.0.1", port }); + direct.once("lookup", () => console.log("unexpected lookup")); + direct.once("connect", () => console.log("connect without lookup")); + direct.on("data", () => {}); + direct.on("close", () => tcp.close(() => resolve())); + }); + }); + }); +} + +const server = http.createServer((req, res) => { + req.setEncoding("utf8"); + const chunks: string[] = []; + req.on("data", (chunk: string) => chunks.push(chunk)); + req.on("end", () => { + const body = chunks.join(""); + res.writeHead(200, { "content-type": "text/plain" }); + const payload = body ? body.toUpperCase() : undefined; + res.end(payload); + }); +}); + +function post(port: number, body: string): Promise { + return new Promise((resolve) => { + const req = http.request({ host: "127.0.0.1", port, method: "POST", path: "/" }, (res) => { + res.setEncoding("utf8"); + let text = ""; + res.on("data", (part: string) => { + text += part; + }); + res.on("end", () => { + console.log("response", JSON.stringify(body), "->", JSON.stringify(text)); + resolve(); + }); + }); + req.end(body); + }); +} + +lookups().then(() => { + server.listen(0, "127.0.0.1", async () => { + const port = (server.address() as net.AddressInfo).port; + await post(port, "hello"); + await post(port, ""); + server.close(() => console.log("closed")); + const idle = http.createServer(); + idle.close(() => console.log("never listened closed")); + }); +}); diff --git a/tests/fixtures/node-types/fetch-init-members.ts b/tests/fixtures/node-types/fetch-init-members.ts new file mode 100644 index 000000000..371941e5a --- /dev/null +++ b/tests/fixtures/node-types/fetch-init-members.ts @@ -0,0 +1,68 @@ +// Options derived from RequestInit under @types/node: an interface that +// extends Omit holds fetch's BodyInit and HeadersInit +// members, which are destructured, narrowed and passed back into fetch, +// with the remaining members carried as an Omit<...> of RequestInit. +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; + +type PassthroughRequestInit = Omit; +type FetchBody = NonNullable; + +interface PostOptions extends PassthroughRequestInit { + timeoutMs?: number; +} + +function describe(body: FetchBody | null | undefined): string { + return typeof body === "string" ? `text(${body.length})` : body == null ? "none" : "binary or form"; +} + +async function send(params: { + port: number; + method: string; + body: FetchBody | null | undefined; + headers: RequestInit["headers"]; + init: Omit; + timeoutMs: number; +}): Promise { + const { port, method, body, headers, init, timeoutMs } = params; + const fetchInit: RequestInit = { + ...init, + method, + headers, + body: body ?? undefined, + signal: AbortSignal.timeout(timeoutMs), + }; + return fetch(`http://127.0.0.1:${port}/`, fetchInit); +} + +async function post(port: number, options: PostOptions = {}): Promise { + const { timeoutMs, body, method, headers, ...rest } = options; + const res = await send({ + port, + method: method ?? "POST", + body, + headers, + init: rest, + timeoutMs: timeoutMs ?? 5000, + }); + return `${describe(body)} -> ${res.status} ${await res.text()}`; +} + +const server = http.createServer((req, res) => { + let data = ""; + req.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + req.on("end", () => { + res.end(`${req.method} type=${req.headers["content-type"]} x=${req.headers["x-n"]} body=${data}`); + }); +}); + +server.listen(0, "127.0.0.1", async () => { + const port = (server.address() as AddressInfo).port; + console.log(await post(port, { body: "plain", headers: { "x-n": "1" } })); + console.log(await post(port, { body: new URLSearchParams("a=1&b=2"), headers: [["x-n", "2"]] })); + console.log(await post(port, { body: Buffer.from("bytes"), method: "PUT", timeoutMs: 2000 })); + console.log(await post(port, { method: "DELETE", headers: new Headers({ "x-n": "4" }) })); + server.close(); +}); diff --git a/tests/fixtures/node-types/http-socket-path.ts b/tests/fixtures/node-types/http-socket-path.ts new file mode 100644 index 000000000..1b7ff96b5 --- /dev/null +++ b/tests/fixtures/node-types/http-socket-path.ts @@ -0,0 +1,68 @@ +// An HTTP client that reaches its API over a Unix socket unless an +// endpoint URL is configured, typed by @types/node: the location is an +// http.RequestOptions chosen at run time, spread into each request's +// options with a path, method, headers, timeout and an optional signal. +// server.close callbacks take Node's ERR_SERVER_NOT_RUNNING error. +import * as http from "node:http"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const socketPath: string = join(tmpdir(), `scriptc-api-${process.pid}.sock`); +const endpoint = process.env.NO_SUCH_API_ENDPOINT_VAR ? new URL(process.env.NO_SUCH_API_ENDPOINT_VAR) : undefined; +const location: http.RequestOptions = endpoint + ? { port: endpoint.port, host: endpoint.hostname } + : { socketPath }; + +function call(path: string, body: string, timeout: number, ac?: AbortController): Promise { + return new Promise((resolve, reject) => { + const headers: Record = { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }; + const options = { + ...location, + path, + method: "POST", + headers, + timeout, + signal: ac?.signal, + }; + const request = http.request(options, (res) => { + let data = ""; + res.on("data", (part: Buffer) => { + data += part.toString(); + }); + res.on("error", reject); + res.on("end", () => resolve(`${res.statusCode} ${data}`)); + }); + request.on("error", reject); + request.on("timeout", () => request.destroy(new Error("timed out"))); + request.end(body); + }); +} + +const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (part: Buffer) => { + body += part.toString(); + }); + req.on("end", () => { + res.end(`${req.method} ${req.url} host=${req.headers.host} body=${body}`); + }); +}); + +server.listen(socketPath, async () => { + console.log(await call("/v1/start", JSON.stringify({ id: 1 }), 5000)); + console.log(await call("/v1/status", JSON.stringify({ id: 2 }), 5000, new AbortController())); + try { + const ac = new AbortController(); + ac.abort(); + await call("/v1/never", "{}", 5000, ac); + } catch (err) { + console.log("aborted", (err as Error).name); + } + server.close((err) => console.log("closed", err === undefined, existsSync(socketPath))); + server.close((err) => console.log("closed again", err?.name, (err as NodeJS.ErrnoException)?.code, err?.message)); + http.createServer().close((err) => console.log("never listened", (err as NodeJS.ErrnoException)?.code)); +}); diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 1479cea1a..ea8cbf79b 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -422,6 +422,44 @@ test("node-types: util.promisify over typed callback-last functions settles like expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: http.request over a Unix socket with RequestOptions read at run time", async () => { + const outDir = outDirFor("http-socket-path"); + const entry = join(nodeTypesDir, "http-socket-path.ts"); + const result = await compile(entry, { + outPath: join(outDir, "http-socket-path"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const native = await execFileAsync(result.binaryPath); + const node = await 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: RequestInit-derived options carry BodyInit and HeadersInit into fetch", async () => { + const outDir = outDirFor("fetch-init-members"); + const entry = join(nodeTypesDir, "fetch-init-members.ts"); + const result = await compile(entry, { + outPath: join(outDir, "fetch-init-members"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const native = await execFileAsync(result.binaryPath); + const node = await 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: stream subclasses extend the @types/node stream classes", async () => { const outDir = outDirFor("stream-subclass-hooks"); const entry = join(nodeTypesDir, "stream-subclass-hooks.ts"); From b9aaaa9d8c7af2a8fcaf69f96cc6d974aa39f1b8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 04:30:50 -0700 Subject: [PATCH 2/3] Answer server.address() with the socket path for Unix-socket servers Node answers a Unix-socket server's address() with its path. Programs that call listen() with a string argument now get a Unix-aware address(): typed `string | AddressInfo | null` (the @types/node declaration), it answers the path; typed as the AddressInfo record alone, which cannot hold the path, it throws for such a server, as does the composed address().port read. Every other program keeps the record-only lowering, so their output is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../limitations/unix-domain-sockets.mdx | 2 +- .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/frontend/lowering/lower-server.ts | 126 ++++++++++++++++-- .../compiler/src/frontend/lowering/lowerer.ts | 4 + packages/compiler/src/ir/builtin-effects.ts | 2 + .../compiler/src/ir/builtin-signatures.ts | 5 + packages/runtime/src/scr_net.c | 20 ++- packages/runtime/src/scr_net.h | 2 + tests/fixtures/node-types/http-socket-path.ts | 12 ++ 9 files changed, 161 insertions(+), 14 deletions(-) diff --git a/docs/content/limitations/unix-domain-sockets.mdx b/docs/content/limitations/unix-domain-sockets.mdx index 70a8a47e4..e98bc72e3 100644 --- a/docs/content/limitations/unix-domain-sockets.mdx +++ b/docs/content/limitations/unix-domain-sockets.mdx @@ -1,2 +1,2 @@ -`server.listen(path)` and the `socketPath` option of `http.request()` and `https.request()` use Unix domain sockets on Linux and macOS. On Windows, where Node maps these paths to named pipes, they fail with `ENOTSUP`. `server.address()` on a server listening on a Unix socket returns `{ address: , family: "IPv4", port: 0 }`, where Node returns the socket path as a string: compiled code holds `address()` as the `{ address, family, port }` record. `listen(path, backlog, callback)` is refused at compile time; pass `listen(path, callback)`. +`server.listen(path)` and the `socketPath` option of `http.request()` and `https.request()` use Unix domain sockets on Linux and macOS. On Windows, where Node maps these paths to named pipes, they fail with `ENOTSUP`. `server.address()` on a server listening on a Unix socket returns the socket path, as in Node, when the result is typed `string | AddressInfo | null` (the @types/node declaration). Where it is typed as the `{ address, family, port }` record alone, which cannot hold the path, the call throws an error for such a server. `listen(path, backlog, callback)` is refused at compile time; pass `listen(path, callback)`. When the options of `http.request()` come from a variable, or from an object literal that spreads one, they are read at run time with Node's validation errors. The `auth`, `createConnection`, `family`, `hints`, `insecureHTTPParser`, `joinDuplicateHeaders`, `localAddress`, `localPort`, `lookup` and `uniqueHeaders` options then throw a runtime error when they hold a value, and so do `setHost: false` and `setDefaultHeaders: false`. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index b4b0db146..fd62aa613 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1153,6 +1153,8 @@ export const LIB_FN_SYMS: Record = { "net.sockOnCloseDyn": "scr_net_sock_on_close_dyn", "net.sockOnLookup": "scr_net_sock_on_lookup", "net.serverCloseDyn": "scr_net_server_close_dyn", + "net.serverUnixPath": "scr_net_server_unix_path", + "net.serverAddressCheck": "scr_net_server_address_check", "net.sockOnWriteFlushDyn": "scr_net_sock_on_write_flush_dyn", "net.sockOnFinishDyn": "scr_net_sock_on_finish_dyn", "net.isIP": "scr_net_is_ip", diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 9e86c65dd..b4e64c9d0 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -2093,6 +2093,32 @@ function lowerNetServerMethodCall( const result = lowerer.mapTypeOf(lowerer.typeOf(call)); if (result && isAddressInfoRecord(lowerer, result)) { const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T); + if (unixListenReachable(lowerer)) { + // A Unix-socket server's address() is its path, which this record + // cannot hold: the check throws for one before the record reads. + const local = lowerer.declareHiddenLocal("%addressServer", NETSERVER_T); + const server = (): IrExpr => varRef(local.id, NETSERVER_T, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: local.id, init: receiver, loc }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "net.serverAddressCheck", + args: [server()], + type: VOID, + loc, + }, + loc, + }, + ], + result: { kind: "libCall", fn: "net.serverAddress", args: [server()], type: result, loc }, + type: result, + loc, + }; + } return { kind: "libCall", fn: "net.serverAddress", args: [receiver], type: result, loc }; } // @types/node's `string | AddressInfo | null`: the record while the @@ -2112,20 +2138,50 @@ function lowerNetServerMethodCall( const receiver = coerceToHandle(lowerer, access.expression, NETSERVER_T); const local = lowerer.declareHiddenLocal("%addressServer", NETSERVER_T); const server = varRef(local.id, NETSERVER_T, loc); + const recordArm: IrExpr = { + kind: "unionWrap", + unionId, + tag: lowerer.armTag(unionId, record), + value: { kind: "libCall", fn: "net.serverAddress", args: [server], type: record, loc }, + type: result, + loc, + }; + const stringTag = lowerer.armTag(unionId, STRING); + let listeningArm: IrExpr = recordArm; + const stmts: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: receiver, loc }]; + if (stringTag >= 0 && unixListenReachable(lowerer)) { + // Node answers a Unix-socket server's address() with its path. + const pathLocal = lowerer.declareHiddenLocal("%addressPath", STRING); + const path = (): IrExpr => varRef(pathLocal.id, STRING, loc); + stmts.push({ + kind: "varDecl", + localId: pathLocal.id, + init: { kind: "libCall", fn: "net.serverUnixPath", args: [server], type: STRING, loc }, + loc, + }); + listeningArm = { + kind: "ternary", + cond: { + kind: "strEq", + negated: true, + left: path(), + right: strLit("", loc), + type: BOOL, + loc, + }, + then: { kind: "unionWrap", unionId, tag: stringTag, value: path(), type: result, loc }, + else_: recordArm, + type: result, + loc, + }; + } return { kind: "seqExpr", - stmts: [{ kind: "varDecl", localId: local.id, init: receiver, loc }], + stmts, result: { kind: "ternary", cond: { kind: "libCall", fn: "net.serverListening", args: [server], type: BOOL, loc }, - then: { - kind: "unionWrap", - unionId, - tag: lowerer.armTag(unionId, record), - value: { kind: "libCall", fn: "net.serverAddress", args: [server], type: record, loc }, - type: result, - loc, - }, + then: listeningArm, else_: { kind: "unionWrap", unionId, @@ -2181,6 +2237,34 @@ function socketEncodingReachable(lowerer: Lowerer): boolean { return reachable; } +/** Whether some server in the program may listen on a Unix socket: a + * `.listen(x, ...)` call on a server (or an untyped value) whose first + * argument is string-typed. Only then does address() check for the socket + * path, so other programs keep the record-only lowering. */ +function unixListenReachable(lowerer: Lowerer): boolean { + if (lowerer.unixListenReachable !== null) return lowerer.unixListenReachable; + let reachable = false; + for (const file of lowerer.program.getSourceFiles()) { + if (file.isDeclarationFile || !file.text.includes("listen")) continue; + ts.walkPreorder(file, (node) => { + if (reachable) return "skip"; + if (!ts.isCallExpression(node) || node.arguments.nodes.length === 0) return undefined; + const callee = node.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "listen") return undefined; + const kind = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(callee.expression))?.kind; + if (kind !== undefined && kind !== "netServer" && kind !== "dyn") return undefined; + const arg = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(node.arguments.nodes[0]!)); + // Only a string-typed argument lowers to listen(path) (an untyped + // one checks into a port). + if (arg?.kind === "string") reachable = true; + return undefined; + }); + if (reachable) break; + } + lowerer.unixListenReachable = reachable; + return reachable; +} + /** An inline 'data' listener whose chunk parameter is typed Buffer, for a * socket that may have an encoding set: the chunk binds as a checked * dynamic value, so the body sees what Node passes (a string after @@ -4020,6 +4104,30 @@ export function lowerServerProperty( if (lowerer.mapTypeOf(lowerer.typeOf(inner.expression))?.kind !== "netServer") return null; if (!lowerer.isStdlibMember(inner)) return null; const receiver = coerceToHandle(lowerer, inner.expression, NETSERVER_T); + if (unixListenReachable(lowerer)) { + const local = lowerer.declareHiddenLocal("%addressServer", NETSERVER_T); + const server = (): IrExpr => varRef(local.id, NETSERVER_T, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: local.id, init: receiver, loc }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "net.serverAddressCheck", + args: [server()], + type: VOID, + loc, + }, + loc, + }, + ], + result: { kind: "libCall", fn: "net.serverPort", args: [server()], type: F64, loc }, + type: F64, + loc, + }; + } return { kind: "libCall", fn: "net.serverPort", args: [receiver], type: F64, loc }; } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 1361b346f..198a9f55b 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1950,6 +1950,10 @@ export class Lowerer { * setEncoding call whose receiver is a socket or an untyped value. * Computed once on first use (null until then). */ socketEncodingReachable: boolean | null = null; + /** Whether some server in this program may listen on a Unix socket: a + * listen call whose first argument is a string. Computed once on first + * use (null until then); address() answers the path only when true. */ + unixListenReachable: boolean | null = null; /** IMPLICIT-ANY instances lowered EAGERLY at first demand (their return * types are inferred from the body — the call site needs them settled), * collected here for run()'s function list (the liftedFns discipline). */ diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 754968113..af5b24129 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -269,6 +269,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "http.requestDynCb", "https.requestDyn", "https.requestDynCb", + // address() of a Unix-socket server read as the AddressInfo record. + "net.serverAddressCheck", // The h2 client's throwing entries: connect on a bad/non-http // authority (ERR_INVALID_URL / ERR_INVALID_PROTOCOL), request on a // closed/destroyed session (ERR_HTTP2_INVALID_SESSION), setEncoding on diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 4bfdf031a..4b56da342 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1311,6 +1311,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * undefined once the server closes, or Node's ERR_SERVER_NOT_RUNNING * when it was not listening. */ "net.serverCloseDyn": { argTypes: [NETSERVER_T, DYN], result: VOID }, + /** server.address() in programs that may listen on a Unix socket: the + * path a listening server answers ("" for a TCP server), and the check + * a record-typed address() makes first (throws for a Unix server). */ + "net.serverUnixPath": { argTypes: [NETSERVER_T], result: STRING }, + "net.serverAddressCheck": { argTypes: [NETSERVER_T], result: VOID }, "net.serverCloseCb": { argTypes: [NETSERVER_T, { kind: "func", params: [], ret: VOID }], result: VOID, diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index a92f738bd..978f42e3d 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -2706,6 +2706,22 @@ ScrNetSocket *scr_net_connect_path(ScrStr *path, bool deferred) { return s; } +/* server.address() in programs that listen on a path (the compiler links + * these only there): the Unix-socket path a listening server answers in + * place of the AddressInfo record (+1, "" for a TCP server), and the check + * a record-typed address() read makes first. */ +ScrStr *scr_net_server_unix_path(ScrNetServer *s) { + return s->listening && s->unix_path != NULL ? scr_str_retain(s->unix_path) : scr_str_new("", 0); +} + +void scr_net_server_address_check(ScrNetServer *s) { + if (!s->listening || s->unix_path == NULL) return; + static const char msg[] = + "server.address() of a server listening on a Unix socket is the socket path string; " + "type the result as string | AddressInfo | null (Node's declaration) to read it"; + scr_throw_error_msg(SCR_ERR_ERROR, msg, sizeof msg - 1); +} + /* server.listen(path[, cb]): binds a Unix socket now; a failure is the * deferred 'error', success the deferred 'listening', as for a port. */ void scr_net_listen_path(ScrNetServer *s, ScrStr *path /*borrowed*/, @@ -2738,10 +2754,6 @@ void scr_net_listen_path(ScrNetServer *s, ScrStr *path /*borrowed*/, s->emit_listening = true; scr_str_release(s->unix_path); s->unix_path = scr_str_retain(path); - /* address() carries the path in its address field (Node answers - * the path string itself) */ - scr_str_release(s->bound_host); - s->bound_host = scr_str_retain(path); scr_net_watch_read(fd, s, true); scr_net_server_register(s); return; diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index 385704654..d78ae48f0 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -167,6 +167,8 @@ void scr_net_server_close(ScrNetServer *s, ScrClosure *cb /*moves, nullable*/); void scr_net_server_close_dyn(ScrNetServer *s, const ScrDyn *listener /*borrowed*/); void scr_net_sock_on_lookup(ScrNetSocket *s, const ScrDyn *listener /*borrowed*/, bool once); void scr_net_lookup_install(void); +ScrStr *scr_net_server_unix_path(ScrNetServer *s); /* +1, "" unless listening on a path */ +void scr_net_server_address_check(ScrNetServer *s); /* throws for a Unix-socket server */ void scr_net_server_close_all_connections(ScrNetServer *s); void scr_net_server_close_idle_connections(ScrNetServer *s); ScrDyn *scr_net_server_max_connections_get(ScrNetServer *s); /* +1 */ diff --git a/tests/fixtures/node-types/http-socket-path.ts b/tests/fixtures/node-types/http-socket-path.ts index 1b7ff96b5..9282ed052 100644 --- a/tests/fixtures/node-types/http-socket-path.ts +++ b/tests/fixtures/node-types/http-socket-path.ts @@ -2,9 +2,11 @@ // endpoint URL is configured, typed by @types/node: the location is an // http.RequestOptions chosen at run time, spread into each request's // options with a path, method, headers, timeout and an optional signal. +// server.address() answers the socket path (a TCP server's the record), and // server.close callbacks take Node's ERR_SERVER_NOT_RUNNING error. import * as http from "node:http"; import { existsSync } from "node:fs"; +import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -52,7 +54,17 @@ const server = http.createServer((req, res) => { }); }); +function describeAddress(address: string | AddressInfo | null): string { + if (address === null) return "null"; + return typeof address === "string" ? address.split(socketPath).join("") : `${address.family} port>0=${address.port > 0}`; +} + server.listen(socketPath, async () => { + console.log("address", describeAddress(server.address())); + const tcp = http.createServer(); + await new Promise((resolve) => tcp.listen(0, "127.0.0.1", () => resolve())); + console.log("tcp address", describeAddress(tcp.address())); + tcp.close(); console.log(await call("/v1/start", JSON.stringify({ id: 1 }), 5000)); console.log(await call("/v1/status", JSON.stringify({ id: 2 }), 5000, new AbortController())); try { From 7d35fee6883edee81db080acbd6310086c1a890f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 10:21:03 -0500 Subject: [PATCH 3/3] Validate flat request header arrays and drop lookup listeners when a socket closes - http.request, https.request and get validate each name and value of a flat [name, value, ...] headers array, as Node's _storeHeader does: a name that is not an HTTP token throws ERR_INVALID_HTTP_TOKEN, a value with an invalid character throws ERR_INVALID_CHAR, and an odd length throws ERR_INVALID_ARG_VALUE with the inspected array. The [[name, value], ...] entry form throws the options fence. - A closing socket drops its 'lookup' listeners with its other listeners, so a listener that holds the socket no longer keeps it alive. - The options-record diagnostics snapshot no longer expects the socketPath refusal. --- packages/runtime/src/scr_http.c | 113 +++++++++++++++++- packages/runtime/src/scr_net.c | 2 + .../6441-http-request-options-record.ts | 15 ++- .../corpus/6443-net-lookup-http-close-text.ts | 4 +- .../__snapshots__/options-record.ts.txt | 9 -- 5 files changed, 130 insertions(+), 13 deletions(-) diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index 9a2541d24..d9876c30b 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -806,6 +806,8 @@ static bool scr_http_token_char(unsigned char ch) { (ch >= 'a' && ch <= 'z') || (ch != 0 && strchr("!#$%&'*+-.^_`|~", ch) != NULL); } +static void scr_http_throw_invalid_header_name(ScrStr *name /*borrowed*/, ScrStr *label /*borrowed*/); + /* node:http's public validators use the same token and field-vchar rules * as OutgoingMessage. Strings are UTF-8 here, so the Latin-1 obs-text * range accepted by Node's JS regex is C2/C3 plus one continuation byte. */ @@ -814,7 +816,12 @@ void scr_http_validate_header_name(ScrStr *name /*borrowed*/, ScrStr *label /*bo for (size_t i = 0; i < name->len && valid; i++) valid = scr_http_token_char((unsigned char)name->data[i]); if (valid) return; + scr_http_throw_invalid_header_name(name, label); +} +/* Node's ERR_INVALID_HTTP_TOKEN for a header name; `name` is the name's + * string form. */ +static void scr_http_throw_invalid_header_name(ScrStr *name /*borrowed*/, ScrStr *label /*borrowed*/) { ScrJsonBuf b; scr_jb_init(&b); ScrStr *shown = label->len ? label : NULL; @@ -7009,6 +7016,96 @@ static const char SCR_HTTP_REQ_SUPPORTED[] = "hostname/host, port, path, method, headers, timeout, agent, signal, socketPath, " "defaultPort, protocol, and maxHeaderSize are the supported options"; +/* validateHeaderName and validateHeaderValue over one pair of the flat + * headers array: a name that is not a string throws + * ERR_INVALID_HTTP_TOKEN with its string form, and an array value + * validates each element. */ +static void scr_http_req_validate_header_pair(const ScrDyn *name, const ScrDyn *value) { + ScrStr *label = scr_str_new("", 0); + if (name->kind == SCR_DYN_STR) { + scr_http_validate_header_name(name->v.str, label); + } else { + ScrStr *text = scr_dyn_string_coerce_js(name); + if (text != NULL) { + scr_http_throw_invalid_header_name(text, label); + scr_str_release(text); + } + } + scr_str_release(label); + if (scr_exc_pending()) return; + if (value->kind == SCR_DYN_ARR) { + for (size_t k = 0; k < value->v.arr.len && !scr_exc_pending(); k++) + scr_http_validate_header_value(name->v.str, value->v.arr.items[k]); + } else { + scr_http_validate_header_value(name->v.str, value); + } +} + +/* util.inspect's one-line rendering of a short array ("[ 'x-a', 5 ]"): up + * to six primitive entries whose rendering stays within inspect's + * 80-column break length (a start of n + 11, plus the separators and each + * entry). NULL when inspect would group or break the entries, or an entry + * is not a primitive or renders outside ASCII (inspect counts UTF-16 + * units). */ +static ScrStr *scr_http_inspect_short_array(const ScrDyn *list) { + size_t n = list->v.arr.len; + if (n == 0 || n > 6) return NULL; + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "[ "); + size_t total = 2 * n + 11; + bool fits = true; + for (size_t i = 0; i < n && fits; i++) { + const ScrDyn *v = list->v.arr.items[i]; + ScrStr *part = NULL; + switch (v->kind) { + case SCR_DYN_STR: part = scr_str_inspect_quoted(v->v.str, false); break; + case SCR_DYN_NUM: + part = v->v.num == 0 && signbit(v->v.num) ? scr_str_new("-0", 2) : scr_f64_to_scrstr(v->v.num); + break; + case SCR_DYN_BOOL: part = v->v.b ? scr_str_new("true", 4) : scr_str_new("false", 5); break; + case SCR_DYN_NULL: part = scr_str_new("null", 4); break; + case SCR_DYN_UNDEF: part = scr_str_new("undefined", 9); break; + default: break; + } + if (part == NULL) { + fits = false; + break; + } + for (size_t k = 0; k < part->len && fits; k++) fits = (unsigned char)part->data[k] < 0x80; + total += part->len; + if (total > 80) fits = false; + if (i > 0) scr_jb_puts(&b, ", "); + scr_jb_write(&b, part->data, part->len); + scr_str_release(part); + } + scr_jb_puts(&b, " ]"); + ScrStr *out = scr_jb_finish(&b); + if (fits) return out; + scr_str_release(out); + return NULL; +} + +/* Node's ERR_INVALID_ARG_VALUE for a flat headers array of odd length. + * Arrays whose inspected form scr_http_inspect_short_array cannot + * reproduce fence instead. */ +static void scr_http_req_odd_headers_fail(const char *api, const ScrDyn *list) { + ScrStr *shown = scr_http_inspect_short_array(list); + if (shown == NULL) { + scr_http_req_opts_fence(api, "headers", + "the odd-length headers array error prints up to six short primitive entries here"); + return; + } + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "The argument 'headers' is invalid. Received "); + scr_jb_write(&b, shown->data, shown->len); + scr_str_release(shown); + ScrStr *msg = scr_jb_finish(&b); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg->data, msg->len, "ERR_INVALID_ARG_VALUE"); + scr_str_release(msg); +} + void scr_http_req_opts_fence(const char *api, const char *key, const char *hint) { ScrJsonBuf b; scr_jb_init(&b); @@ -7270,7 +7367,21 @@ bool scr_http_req_opts_read(const ScrDyn *opts, bool secure, const char *api, Sc } if (!scr_exc_pending()) o->headers = scr_http_dynh_pairs(headers); } else if (headers != NULL && headers->kind == SCR_DYN_ARR) { - o->headers = scr_http_dynh_flat_pairs(headers); + /* Node's _storeHeader checks that the flat [name, value, ...] form has + * an even length, then validates each name and value. The + * [[name, value], ...] entry form is not modeled. */ + size_t n = headers->v.arr.len; + if (n > 0 && headers->v.arr.items[0]->kind == SCR_DYN_ARR) { + scr_http_req_opts_fence( + api, "headers", + "headers is an object of string or number values or a flat [name, value, ...] array here"); + } else if (n % 2 != 0) { + scr_http_req_odd_headers_fail(api, headers); + } else { + for (size_t i = 0; i < n && !scr_exc_pending(); i += 2) + scr_http_req_validate_header_pair(headers->v.arr.items[i], headers->v.arr.items[i + 1]); + } + if (!scr_exc_pending()) o->headers = scr_http_dynh_flat_pairs(headers); } else if (headers != NULL && headers->kind != SCR_DYN_NULL) { scr_http_req_opts_fence(api, "headers", "headers is an object of string or number values here"); } diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 978f42e3d..9902033f2 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -5172,6 +5172,7 @@ static void scr_net_sweep(void) { scr_net_ls_drop(&sock->finish_ls); scr_net_ls_drop(&sock->wcb_ls); scr_net_ls_drop(&sock->ready_ls); + scr_net_ls_drop(&sock->lookup_ls); if (sock->pipe_dst) { scr_net_sock_release(sock->pipe_dst); sock->pipe_dst = NULL; @@ -5373,6 +5374,7 @@ static void scr_net_cleanup_atexit(void) { scr_net_ls_drop(&s->conn_ls); scr_net_ls_drop(&s->timeout_ls); scr_net_ls_drop(&s->readable_ls); + scr_net_ls_drop(&s->lookup_ls); /* Tear down the protocol layer's parser NOW: an in-flight http * exchange (a handler that threw before res.end()) holds req/res, * and the res holds this socket back — a cycle the lean untraced diff --git a/tests/corpus/6441-http-request-options-record.ts b/tests/corpus/6441-http-request-options-record.ts index f2eba1a02..89a9abc29 100644 --- a/tests/corpus/6441-http-request-options-record.ts +++ b/tests/corpus/6441-http-request-options-record.ts @@ -1,8 +1,10 @@ // http.request options read at run time: a RequestOptions value chosen by // a runtime condition (a TCP endpoint with a string port, or a socket // path), spread into an options object with a timeout and an optional -// signal; agent: false; an aborted signal; and Node's validation errors -// for the method, port, host, timeout, path, protocol and agent options. +// signal; agent: false; an aborted signal; a flat [name, value, ...] +// headers array; and Node's validation errors for the method, port, host, +// timeout, path, protocol and agent options and for flat header names and +// values. import * as http from "node:http"; const server = http.createServer((req, res) => { @@ -52,11 +54,15 @@ server.listen(0, "127.0.0.1", async () => { const headers: Record = { "x-n": 1 }; const controller: AbortController | undefined = undefined; const options = { ...location, path: "/rpc", method: "post", headers, timeout: 2000, signal: controller }; + // The flat [name, value, ...] headers form; @types/node types headers as an object. + const flat = (path: string, list: unknown[]): http.RequestOptions => + ({ ...location, path, headers: list }) as unknown as http.RequestOptions; await send("spread", options); await send("agent false", { ...location, path: "/close", agent: false }); const aborted = new AbortController(); aborted.abort(); await send("aborted", { ...location, path: "/never", signal: aborted.signal }); + await send("flat headers", flat("/flat", ["host", "flat.test", "x-n", 2])); invalid("method", { ...location, method: "g t" }); invalid("port", { host: "127.0.0.1", port: "http" }); invalid("host", { hostname: 5 } as unknown as http.RequestOptions); @@ -64,5 +70,10 @@ server.listen(0, "127.0.0.1", async () => { invalid("path", { ...location, path: "/a b" }); invalid("protocol", { ...location, protocol: "https:" }); invalid("agent", { ...location, agent: 5 } as unknown as http.RequestOptions); + invalid("flat name", flat("/", ["x n", "1"])); + invalid("flat number name", flat("/", [5, "1"])); + invalid("flat crlf value", flat("/", ["x-a", "ok\r\nx-b: injected"])); + invalid("flat crlf array value", flat("/", ["x-a", ["ok", "b\r\nx-c: injected"]])); + invalid("flat odd length", flat("/", ["x-a"])); server.close(); }); diff --git a/tests/corpus/6443-net-lookup-http-close-text.ts b/tests/corpus/6443-net-lookup-http-close-text.ts index 1d50d3580..ba445b117 100644 --- a/tests/corpus/6443-net-lookup-http-close-text.ts +++ b/tests/corpus/6443-net-lookup-http-close-text.ts @@ -23,7 +23,9 @@ function lookups(): Promise { named.on("data", () => {}); named.on("close", () => { const direct = net.connect({ host: "127.0.0.1", port }); - direct.once("lookup", () => console.log("unexpected lookup")); + // A 'lookup' listener that never runs and holds its socket: closing + // the socket drops it. + direct.once("lookup", () => console.log("unexpected lookup", direct.remotePort)); direct.once("connect", () => console.log("connect without lookup")); direct.on("data", () => {}); direct.on("close", () => tcp.close(() => resolve())); diff --git a/tests/harness/__snapshots__/options-record.ts.txt b/tests/harness/__snapshots__/options-record.ts.txt index ab89613c0..26ca22db3 100644 --- a/tests/harness/__snapshots__/options-record.ts.txt +++ b/tests/harness/__snapshots__/options-record.ts.txt @@ -25,15 +25,6 @@ options-record.ts:20:22 - error SC2020: 'get with agent: false and a non-literal hint: agent: false lowers by injecting Connection: close into a LITERAL headers object — add connection: "close" to the record instead, or write the headers inline -options-record.ts:23:22 - error SC2020: 'get option 'socketPath'' is part of the standard library types but has no scriptc lowering yet - - 22 | // Documented client options with no lowering fence by name. - 23 | http.get({ port: 80, socketPath: "/tmp/sock.s" }, () => {}); - | ^~~~~~~~~~~~~~~~~~~~~~~~~ - 24 | http.get({ port: 80, localAddress: "127.0.0.1" }, () => {}); - - hint: hostname/host, port, path, method, timeout, headers, agent, signal, and maxHeaderSize are the supported options - options-record.ts:24:22 - error SC2020: 'get option 'localAddress'' is part of the standard library types but has no scriptc lowering yet 23 | http.get({ port: 80, socketPath: "/tmp/sock.s" }, () => {});