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");