diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 3bf50e71a..823b7f028 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -230,6 +230,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..e98bc72e3 --- /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 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/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 86a65802e..cb059fc1d 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -4694,6 +4694,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. */ @@ -4705,6 +4710,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; @@ -4715,6 +4725,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). */ @@ -4733,6 +4748,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. */ @@ -5056,10 +5073,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; @@ -5073,6 +5091,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 @@ -5348,6 +5368,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/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 7d60205e5..86b275763 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -177,6 +177,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { // 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; @@ -226,6 +227,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { if (usesFsWatch) emitter.declare(`declare void @scr_watch_install()`); if (emitter.ffiHasForeignCallback) emitter.declare(`declare void @scr_ffi_install()`); if (usesStream) emitter.declare(`declare void @scr_stream_install()`); + if (usesNetLookup) emitter.declare(`declare void @scr_net_lookup_install()`); if (usesNet) { emitter.declare(`declare void @scr_net_install()`); emitter.declare(`declare void @scr_net_dyn_install()`); @@ -837,6 +839,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { ...(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 38c6bdbe0..503ed0e6d 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1157,6 +1157,10 @@ 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.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", @@ -1632,6 +1636,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", @@ -1650,6 +1656,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 7cdb9ef5d..b4e64c9d0 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]!, @@ -2055,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 @@ -2074,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, @@ -2143,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 @@ -2736,6 +2858,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). @@ -2795,7 +2928,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( @@ -3971,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 }; } @@ -5827,6 +5984,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 @@ -5869,6 +6064,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) { @@ -7149,6 +7378,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]!, @@ -7711,6 +7961,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/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 3d2e91985..321cfba28 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/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 22f5c2c07..61c7e2d55 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1375,6 +1375,116 @@ function isLookupOptionsUnion(type: ts.Type, ctx: Pick = 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, @@ -1542,6 +1652,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 @@ -1562,9 +1691,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; // The lookup-function option union (`LookupOneOptions | LookupAllOptions // | number | undefined`): with a LookupAllOptions arm it is an option bag // dns.lookup reads at run time, so it rides checked-dynamic storage. diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 57fb7a304..0cbafcb3f 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -263,6 +263,14 @@ 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", + // 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 644b92baf..8468785b9 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1280,6 +1280,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, @@ -1322,6 +1329,15 @@ 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 }, + /** 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, @@ -1911,6 +1927,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 @@ -1956,6 +1983,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 c1a97525c..caa39e66c 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -131,6 +131,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", @@ -541,6 +545,9 @@ const RUNTIME_FEATURE_RULES = { fetch: rule({ prefixes: ["fetch."] }), fileHandle: rule({ types: ["fileHandle"] }), fsWatch: rule({ prefixes: ["fs.watch", "watcher."], types: ["fsWatcher"] }), + /** A socket 'lookup' listener: the C main installs the net unit's + * lookup records (scr_net_lookup_install). */ + netLookup: rule({ fns: ["net.sockOnLookup"] }), http: rule({ prefixes: ["http.", "tls.", "https.", "http2."], types: ["httpReq", "httpRes", "httpClientReq", "secureCtx"], @@ -667,6 +674,7 @@ function noRuntimeFeatures(): RuntimeFeatures { fetch: false, fileHandle: false, fsWatch: false, + netLookup: false, http: false, http2: false, inspect: false, diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 716774b03..84827e787 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4427,7 +4427,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 = @@ -4438,11 +4440,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..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; @@ -5582,6 +5589,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 +5609,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 +5640,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 +5659,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 +6994,476 @@ 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"; + +/* 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); + 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) { + /* 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"); + } + 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..9902033f2 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,179 @@ 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.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*/, + 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); + 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 +4319,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 +5107,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; @@ -4810,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; @@ -5011,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/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index f76613a0e..d78ae48f0 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -164,6 +164,11 @@ 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); +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 */ @@ -237,6 +242,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..89a9abc29 --- /dev/null +++ b/tests/corpus/6441-http-request-options-record.ts @@ -0,0 +1,79 @@ +// 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; 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) => { + 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 }; + // 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); + 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); + 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/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..ba445b117 --- /dev/null +++ b/tests/corpus/6443-net-lookup-http-close-text.ts @@ -0,0 +1,75 @@ +// 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 }); + // 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())); + }); + }); + }); +} + +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..9282ed052 --- /dev/null +++ b/tests/fixtures/node-types/http-socket-path.ts @@ -0,0 +1,80 @@ +// 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.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"; + +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}`); + }); +}); + +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 { + 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/__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" }, () => {}); diff --git a/tests/harness/corpus-costs/6440-http-socket-path.ts.json b/tests/harness/corpus-costs/6440-http-socket-path.ts.json new file mode 100644 index 000000000..7096393ca --- /dev/null +++ b/tests/harness/corpus-costs/6440-http-socket-path.ts.json @@ -0,0 +1 @@ +{ "plain": 3157, "san": 11133 } diff --git a/tests/harness/corpus-costs/6441-http-request-options-record.ts.json b/tests/harness/corpus-costs/6441-http-request-options-record.ts.json new file mode 100644 index 000000000..004d0559a --- /dev/null +++ b/tests/harness/corpus-costs/6441-http-request-options-record.ts.json @@ -0,0 +1 @@ +{ "plain": 3186, "san": 59437 } diff --git a/tests/harness/corpus-costs/6442-https-request-options-record.ts.json b/tests/harness/corpus-costs/6442-https-request-options-record.ts.json new file mode 100644 index 000000000..eaa5f8364 --- /dev/null +++ b/tests/harness/corpus-costs/6442-https-request-options-record.ts.json @@ -0,0 +1 @@ +{ "plain": 3069, "san": 6484 } diff --git a/tests/harness/corpus-costs/6443-net-lookup-http-close-text.ts.json b/tests/harness/corpus-costs/6443-net-lookup-http-close-text.ts.json new file mode 100644 index 000000000..0fafd0f0b --- /dev/null +++ b/tests/harness/corpus-costs/6443-net-lookup-http-close-text.ts.json @@ -0,0 +1 @@ +{ "plain": 2847, "san": 10348 } diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index b5d47fae1..742eb5b4f 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -462,6 +462,44 @@ test("node-types: lookup-option unions and narrowed header-record entries under 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");