@mswjs/interceptors
Advanced tools
| import { a as getDeepPropertyDescriptor } from "./net-Ca8p1rIe.js"; | ||
| //#region src/utils/has-configurable-global.ts | ||
| /** | ||
| * Returns a boolean indicating whether the given global property | ||
| * is defined and is configurable. | ||
| */ | ||
| function hasConfigurableGlobal(propertyName) { | ||
| const match = getDeepPropertyDescriptor(globalThis, propertyName); | ||
| if (typeof match === "undefined") return false; | ||
| const { descriptor } = match; | ||
| if (typeof descriptor.get === "function" && typeof descriptor.get() === "undefined") return false; | ||
| if (typeof descriptor.get === "undefined" && descriptor.value == null) return false; | ||
| if (typeof descriptor.set === "undefined" && !descriptor.configurable) { | ||
| console.error(`[MSW] Failed to apply interceptor: the global \`${propertyName}\` property is non-configurable. This is likely an issue with your environment. If you are using a framework, please open an issue about this in their repository.`); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| //#endregion | ||
| export { hasConfigurableGlobal as t }; | ||
| //# sourceMappingURL=has-configurable-global-BU1sT1u4.js.map |
| {"version":3,"file":"has-configurable-global-BU1sT1u4.js","names":[],"sources":["../../src/utils/has-configurable-global.ts"],"sourcesContent":["import { getDeepPropertyDescriptor } from './patches-registry'\n\n/**\n * Returns a boolean indicating whether the given global property\n * is defined and is configurable.\n */\nexport function hasConfigurableGlobal(propertyName: string): boolean {\n const match = getDeepPropertyDescriptor(globalThis, propertyName)\n\n // The property is not set at all.\n if (typeof match === 'undefined') {\n return false\n }\n\n const { descriptor } = match\n\n // The property is set to a getter that returns undefined.\n if (\n typeof descriptor.get === 'function' &&\n typeof descriptor.get() === 'undefined'\n ) {\n return false\n }\n\n // The property is set to a value equal to undefined.\n if (typeof descriptor.get === 'undefined' && descriptor.value == null) {\n return false\n }\n\n if (typeof descriptor.set === 'undefined' && !descriptor.configurable) {\n console.error(\n `[MSW] Failed to apply interceptor: the global \\`${propertyName}\\` property is non-configurable. This is likely an issue with your environment. If you are using a framework, please open an issue about this in their repository.`\n )\n return false\n }\n\n return true\n}\n"],"mappings":";;;;;;AAMA,SAAgB,sBAAsB,cAA+B;CACnE,MAAM,QAAQ,0BAA0B,YAAY,YAAY;CAGhE,IAAI,OAAO,UAAU,aACnB,OAAO;CAGT,MAAM,EAAE,eAAe;CAGvB,IACE,OAAO,WAAW,QAAQ,cAC1B,OAAO,WAAW,IAAI,MAAM,aAE5B,OAAO;CAIT,IAAI,OAAO,WAAW,QAAQ,eAAe,WAAW,SAAS,MAC/D,OAAO;CAGT,IAAI,OAAO,WAAW,QAAQ,eAAe,CAAC,WAAW,cAAc;EACrE,QAAQ,MACN,mDAAmD,aAAa,mKAClE;EACA,OAAO;CACT;CAEA,OAAO;AACT"} |
| import { a as createLogger, i as Interceptor, r as toBuffer } from "./buffer-utils-BvPY1Tc-.js"; | ||
| import { TypedEvent } from "rettime"; | ||
| import { invariant } from "outvariant"; | ||
| import http from "node:http"; | ||
| import net from "node:net"; | ||
| import tls from "node:tls"; | ||
| //#region src/utils/patches-registry.ts | ||
| var PatchesRegistry = class { | ||
| #replacements = /* @__PURE__ */ new Map(); | ||
| applyPatch(owner, key, getNextValue) { | ||
| const ownerReplacements = this.#replacements.get(owner); | ||
| invariant(!ownerReplacements?.has(key), `Failed to replace a global value at "${String(key)}": already replaced.`); | ||
| const match = getDeepPropertyDescriptor(owner, key); | ||
| if (typeof match === "undefined") { | ||
| console.warn(`Failed to replace a global value at "${String(key)}": not a global value.`); | ||
| return () => {}; | ||
| } | ||
| if (match.descriptor.configurable) Object.defineProperty(owner, key, { | ||
| value: getNextValue(owner[key]), | ||
| enumerable: true, | ||
| configurable: true | ||
| }); | ||
| else if (match.descriptor.writable) owner[key] = getNextValue(owner[key]); | ||
| else throw new Error(`Failed to patch a non-configurable non-writable property "${key.toString()}"`); | ||
| const restorePatch = () => { | ||
| const currentReplacements = this.#replacements.get(owner); | ||
| if (!currentReplacements?.has(key)) return; | ||
| if (match.owner === owner) | ||
| /** | ||
| * @note Restoring non-configurable properties works as long as "writable: true" | ||
| * and none of the other descriptor properties except for "value" have changed. | ||
| */ | ||
| Object.defineProperty(match.owner, key, match.descriptor); | ||
| else | ||
| /** | ||
| * @todo Delete the proxy property set by the registry. | ||
| * If the match's owner isn't the original owner, the property is likely nested in the prototype. | ||
| * The registry does not meddle with those, they are left intact. | ||
| */ | ||
| Reflect.deleteProperty(owner, key); | ||
| currentReplacements.delete(key); | ||
| if (currentReplacements.size === 0) this.#replacements.delete(owner); | ||
| }; | ||
| if (ownerReplacements) ownerReplacements.set(key, restorePatch); | ||
| else this.#replacements.set(owner, /* @__PURE__ */ new Map([[key, restorePatch]])); | ||
| return restorePatch; | ||
| } | ||
| restoreAllPatches() { | ||
| const errors = []; | ||
| for (const [, ownerReplacements] of this.#replacements) for (const [, restorePatch] of ownerReplacements) try { | ||
| restorePatch(); | ||
| } catch (error) { | ||
| if (error instanceof Error) errors.push(error); | ||
| else throw error; | ||
| } | ||
| if (errors.length > 0) throw new AggregateError(errors, "FOO!"); | ||
| } | ||
| }; | ||
| const patchesRegistry = new PatchesRegistry(); | ||
| /** | ||
| * Returns a property descriptor for the given property on the owner. | ||
| * Walks down the prototype chain if the property does not exist on the owner. | ||
| * Handy for getting a global property descriptor where `globalThis` is | ||
| * replaced with a controlled class (e.g. ServiceWorkerGlobalScope). | ||
| */ | ||
| function getDeepPropertyDescriptor(owner, key) { | ||
| let currentOwner = owner; | ||
| let descriptor; | ||
| while (currentOwner) { | ||
| descriptor = Object.getOwnPropertyDescriptor(currentOwner, key); | ||
| if (descriptor) return { | ||
| owner: currentOwner, | ||
| descriptor | ||
| }; | ||
| currentOwner = Object.getPrototypeOf(currentOwner); | ||
| } | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/normalize-net-connect-args.ts | ||
| /** | ||
| * Normalizes the arguments passed to `net.connect()`. | ||
| */ | ||
| function normalizeNetConnectArgs(args) { | ||
| if (args.length === 0) return [{ path: "" }, null]; | ||
| const callback = typeof args[1] === "function" ? args[1] : args[2] || null; | ||
| if (typeof args[0] === "string") return [{ path: args[0] }, callback]; | ||
| if (typeof args[0] === "number") return [{ | ||
| port: args[0], | ||
| path: "", | ||
| /** | ||
| * @note The host is optional in the "(port[, host][, callback])" | ||
| * signatures and defaults to "localhost" in Node.js. | ||
| */ | ||
| host: typeof args[1] === "string" ? args[1] : void 0 | ||
| }, callback]; | ||
| if (typeof args[0] === "object") { | ||
| /** | ||
| * @note URL arguments receive no special treatment on purpose. | ||
| * Node.js does not support them and reads a given URL as a plain | ||
| * options object (e.g. "url.port" is a string, "url.host" includes | ||
| * the port). The "port" branch below reproduces that reading. | ||
| */ | ||
| if ("port" in args[0]) return [{ | ||
| path: "", | ||
| port: Reflect.get(args[0], "port"), | ||
| host: Reflect.get(args[0], "host"), | ||
| auth: Reflect.get(args[0], "auth"), | ||
| family: Reflect.get(args[0], "family"), | ||
| hints: Reflect.get(args[0], "hints"), | ||
| session: Reflect.get(args[0], "session"), | ||
| localAddress: Reflect.get(args[0], "localAddress"), | ||
| localPort: Reflect.get(args[0], "localPort"), | ||
| timeout: Reflect.get(args[0], "timeout"), | ||
| lookup: Reflect.get(args[0], "lookup"), | ||
| allowHalfOpen: Reflect.get(args[0], "allowHalfOpen"), | ||
| noDelay: Reflect.get(args[0], "noDelay"), | ||
| keepAlive: Reflect.get(args[0], "keepAlive"), | ||
| keepAliveInitialDelay: Reflect.get(args[0], "keepAliveInitialDelay"), | ||
| autoSelectFamily: Reflect.get(args[0], "autoSelectFamily"), | ||
| autoSelectFamilyAttemptTimeout: Reflect.get(args[0], "autoSelectFamilyAttemptTimeout") | ||
| }, callback]; | ||
| return [{ | ||
| path: args[0].path || "", | ||
| family: Reflect.get(args[0], "family"), | ||
| session: Reflect.get(args[0], "session"), | ||
| auth: Reflect.get(args[0], "auth"), | ||
| timeout: Reflect.get(args[0], "timeout"), | ||
| allowHalfOpen: Reflect.get(args[0], "allowHalfOpen") | ||
| }, callback]; | ||
| } | ||
| throw new Error(`Invalid arguments passed to net.connect: ${args}`); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/flush-writes.ts | ||
| /** | ||
| * Write the given pending data to the socket using its public | ||
| * "write()" method. Unlike direct "_writeGeneric()" calls, public | ||
| * writes go through the "Writable" queue that only dispatches the | ||
| * next write once the previous one completes. TLS sockets rely on | ||
| * that serialization: their "TLSWrap" handle supports a single | ||
| * write in flight and crashes the process with a native | ||
| * "Assertion failed: !current_write_" abort when writes overlap | ||
| * (e.g. multiple direct "_writeGeneric()" calls issued on a | ||
| * connecting socket replay back-to-back on "connect"). | ||
| */ | ||
| function writePendingData(socket, data, encoding, callback) { | ||
| if (Array.isArray(data)) { | ||
| for (let index = 0; index < data.length; index++) { | ||
| const entry = data[index]; | ||
| /** | ||
| * @note Per-entry callbacks are intentionally ignored, the same | ||
| * way "writevGeneric()" only honors the batch-level callback. | ||
| */ | ||
| if (index === data.length - 1) socket.write(entry.chunk, entry.encoding, callback); | ||
| else socket.write(entry.chunk, entry.encoding); | ||
| } | ||
| return; | ||
| } | ||
| socket.write(data, encoding, callback); | ||
| } | ||
| function unwrapPendingData(data, callback) { | ||
| if (Array.isArray(data)) for (const entry of data) callback(entry.chunk, entry.encoding, entry.callback); | ||
| else callback(data); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/get-tls-connect-options.ts | ||
| /** | ||
| * Returns the original options the given TLS socket was created with. | ||
| * The original `tls.connect()` stores them on the socket instance | ||
| * under an internal symbol before connecting its transport. | ||
| * @see https://github.com/nodejs/node/blob/3178a762d6a2b1a37b74f02266eea0f3d86603f1/lib/_tls_wrap.js#L1690 | ||
| */ | ||
| function getTlsConnectOptions(socket) { | ||
| const kConnectOptions = Object.getOwnPropertySymbols(socket).find((symbol) => { | ||
| return symbol.description === "connect-options"; | ||
| }); | ||
| if (kConnectOptions == null) return; | ||
| return Reflect.get(socket, kConnectOptions); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/address-info.ts | ||
| function getAddressInfoByConnectionOptions(options) { | ||
| if (options == null) return {}; | ||
| const isIPv6 = options.family === 6 || net.isIPv6(options.host || ""); | ||
| return { | ||
| address: isIPv6 ? "::1" : "127.0.0.1", | ||
| /** | ||
| * @note Coerce the port to a number. Connection options may | ||
| * describe it as a string (e.g. when created from a URL), while | ||
| * the address info always reports a numeric port. | ||
| */ | ||
| port: Number(options.port) || (options.protocol === "https:" ? 443 : 80), | ||
| family: isIPv6 ? "IPv6" : "IPv4" | ||
| }; | ||
| } | ||
| /** | ||
| * Get the local address info for the given connection options. | ||
| * This describes the client-side end of the connection: the socket | ||
| * is bound to the loopback interface and an ephemeral port, the same | ||
| * way the operating system binds an outgoing connection. | ||
| */ | ||
| function getLocalAddressInfoByConnectionOptions(options) { | ||
| if (options == null) return {}; | ||
| const isIPv6 = options.family === 6 || net.isIPv6(options.host || ""); | ||
| return { | ||
| address: options.localAddress || (isIPv6 ? "::1" : "127.0.0.1"), | ||
| port: options.localPort || getEphemeralPort(), | ||
| family: isIPv6 ? "IPv6" : "IPv4" | ||
| }; | ||
| } | ||
| /** | ||
| * Get a random port from the ephemeral range (IANA: 49152-65535), | ||
| * the range the operating system draws from when binding an | ||
| * outgoing connection. | ||
| */ | ||
| function getEphemeralPort() { | ||
| return 49152 + Math.floor(Math.random() * 16384); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/socket-controller.ts | ||
| const kListenerWrap = Symbol("kListenerWrap"); | ||
| const kRawSocket = Symbol("kRawSocket"); | ||
| const kPatched = Symbol("kPatched"); | ||
| const logger$1 = createLogger("socket"); | ||
| function toServerSocket(socket) { | ||
| /** | ||
| * The server-side write buffer. Pushing data to the client honors | ||
| * the client's read backpressure: once "socket.push()" reports a | ||
| * full buffer, subsequent server writes queue here and flush when | ||
| * the client reads again ("_read"). This mirrors how a real server | ||
| * cannot write faster than the client consumes. | ||
| */ | ||
| const pendingWrites = []; | ||
| let pendingEnd; | ||
| let isBackpressured = false; | ||
| let isFlushScheduled = false; | ||
| const flushPendingWrites = () => { | ||
| /** | ||
| * @note The mocked connection is not established yet. Flush once | ||
| * it is, the same way a real server cannot write to a client | ||
| * that has not connected. | ||
| */ | ||
| if (socket.connecting) { | ||
| isBackpressured = true; | ||
| if (!isFlushScheduled) { | ||
| isFlushScheduled = true; | ||
| socket.once("ready", () => { | ||
| isFlushScheduled = false; | ||
| flushPendingWrites(); | ||
| }); | ||
| } | ||
| return false; | ||
| } | ||
| while (pendingWrites.length > 0) { | ||
| const nextWrite = pendingWrites.shift(); | ||
| socket._unrefTimer(); | ||
| const canPushMore = socket.push(toBuffer(nextWrite.chunk, nextWrite.encoding), nextWrite.encoding); | ||
| nextWrite.callback?.(); | ||
| if (!canPushMore) { | ||
| isBackpressured = true; | ||
| return false; | ||
| } | ||
| } | ||
| const wasBackpressured = isBackpressured; | ||
| isBackpressured = false; | ||
| if (pendingEnd) { | ||
| const finalEnd = pendingEnd; | ||
| pendingEnd = void 0; | ||
| if (finalEnd.chunk != null) socket.push(toBuffer(finalEnd.chunk, finalEnd.encoding), finalEnd.encoding); | ||
| socket.push(null); | ||
| finalEnd.callback?.(); | ||
| } | ||
| if (wasBackpressured) socket.emit("internal:drain"); | ||
| return true; | ||
| }; | ||
| /** | ||
| * @note "_read" is the client asking for more data. Flush the | ||
| * buffered server writes so the delivery resumes as the client | ||
| * reads, completing the backpressure loop. | ||
| */ | ||
| const realRead = socket._read.bind(socket); | ||
| socket._read = (size) => { | ||
| realRead(size); | ||
| if (pendingWrites.length > 0 || pendingEnd != null || isBackpressured) flushPendingWrites(); | ||
| }; | ||
| return new Proxy(socket, { get: (target, property, receiver) => { | ||
| const getRealValue = () => { | ||
| return Reflect.get(target, property, receiver); | ||
| }; | ||
| if (property === "on" || property === "addListener" || property === "once" || property === "prependListener" || property === "prependOnceListener") { | ||
| const realAddListener = getRealValue(); | ||
| return (event, listener) => { | ||
| if (event === "data") { | ||
| const listenerWrap = (chunk, encoding) => { | ||
| listener(toBuffer(chunk, encoding)); | ||
| }; | ||
| Object.defineProperty(listener, kListenerWrap, { | ||
| enumerable: false, | ||
| writable: false, | ||
| value: listenerWrap | ||
| }); | ||
| /** | ||
| * @note Subscribe using the same method (e.g. "once") so its | ||
| * listener semantics apply to the internal channel too. | ||
| */ | ||
| Reflect.apply(realAddListener, target, ["internal:write", listenerWrap]); | ||
| return target; | ||
| } | ||
| /** | ||
| * @note The "drain" event on the server socket signals the | ||
| * flush of the server-side write buffer. It is routed through | ||
| * an internal channel so it does not clash with the "drain" | ||
| * event of the underlying client socket. | ||
| */ | ||
| if (event === "drain") { | ||
| Reflect.apply(realAddListener, target, ["internal:drain", listener]); | ||
| return target; | ||
| } | ||
| return realAddListener.call(target, event, listener); | ||
| }; | ||
| } | ||
| if (property === "off" || property === "removeListener") { | ||
| const realRemoveListener = getRealValue(); | ||
| return (event, listener) => { | ||
| if (event === "data") { | ||
| const listenerWrap = listener[kListenerWrap]; | ||
| if (listenerWrap) return realRemoveListener.call(target, "internal:write", listenerWrap); | ||
| } | ||
| if (event === "drain") return realRemoveListener.call(target, "internal:drain", listener); | ||
| return realRemoveListener.call(target, event, listener); | ||
| }; | ||
| } | ||
| if (property === "write") return ((chunk, encoding, callback) => { | ||
| if (typeof encoding === "function") { | ||
| callback = encoding; | ||
| encoding = void 0; | ||
| } | ||
| pendingWrites.push({ | ||
| chunk, | ||
| encoding, | ||
| callback | ||
| }); | ||
| /** | ||
| * @note Do not push more data to a client that is not | ||
| * reading. The write stays buffered until the client reads | ||
| * again, and "drain" signals the flush. | ||
| */ | ||
| if (isBackpressured) return false; | ||
| return flushPendingWrites(); | ||
| }); | ||
| if (property === "end") return ((...args) => { | ||
| const callback = args[args.length - 1]; | ||
| /** | ||
| * @note The end-of-stream is delivered once the buffered | ||
| * writes flush so the client never observes the EOF before | ||
| * the data that preceded it. | ||
| */ | ||
| pendingEnd = { | ||
| chunk: typeof args[0] === "function" ? void 0 : args[0], | ||
| encoding: typeof args[1] === "string" ? args[1] : void 0, | ||
| callback: typeof callback === "function" ? callback : void 0 | ||
| }; | ||
| flushPendingWrites(); | ||
| /** | ||
| * @note Do not end the client socket's writable side. | ||
| * The server ending the connection only signals EOF to the | ||
| * client (the client may keep writing on half-open sockets). | ||
| */ | ||
| return target; | ||
| }); | ||
| return getRealValue(); | ||
| } }); | ||
| } | ||
| var SocketController = class SocketController { | ||
| static { | ||
| this.PENDING = 0; | ||
| } | ||
| static { | ||
| this.CLAIMED = 1; | ||
| } | ||
| static { | ||
| this.PASSTHROUGH = 2; | ||
| } | ||
| #awaitedVerdicts = 0; | ||
| constructor(socket) { | ||
| this[kRawSocket] = socket; | ||
| socket[kPatched] = true; | ||
| this.readyState = SocketController.PENDING; | ||
| } | ||
| /** | ||
| * Claim this socket. Once claimed, the connection attempt succeeds | ||
| * regardless of the requested host and the interceptor becomes the | ||
| * mocked server for this connection. | ||
| */ | ||
| claim() { | ||
| invariant(this.readyState === SocketController.PENDING, "Failed to claim a socket connection: already handled (%s)", this.readyState); | ||
| this.readyState = SocketController.CLAIMED; | ||
| } | ||
| /** | ||
| * Establish this socket connection as-is. | ||
| */ | ||
| passthrough() { | ||
| invariant(this.readyState === SocketController.PENDING, "Failed to passthrough a socket connection: already handled (%s)", this.readyState); | ||
| this.readyState = SocketController.PASSTHROUGH; | ||
| } | ||
| /** | ||
| * Await a verdict on this connection from the given number of | ||
| * subscribers. A connection nobody awaits to inspect (or one that | ||
| * every awaited subscriber has declined) is passed through as-is. | ||
| * This makes "unclaimed after everyone declined" a state owned by | ||
| * the controller instead of the individual subscribers. | ||
| */ | ||
| awaitVerdicts(count) { | ||
| this.#awaitedVerdicts = count; | ||
| if (this.#awaitedVerdicts === 0) this.passthrough(); | ||
| } | ||
| /** | ||
| * Decline this socket connection. Declining means the subscriber | ||
| * has inspected the connection and will not handle it (e.g. the | ||
| * traffic is not of the protocol that subscriber implements). | ||
| * Once every awaited subscriber declines, the connection is | ||
| * passed through as-is. | ||
| */ | ||
| decline() { | ||
| if (this.readyState !== SocketController.PENDING) return; | ||
| this.#awaitedVerdicts -= 1; | ||
| if (this.#awaitedVerdicts <= 0) | ||
| /** | ||
| * @note Defer the passthrough so it never transitions this | ||
| * controller in the middle of a client write. Declines are | ||
| * issued while the written data is being pushed to the server | ||
| * socket, and a synchronous transition would race the write's | ||
| * own bookkeeping (e.g. re-buffering the write after a reset | ||
| * at an exchange boundary). | ||
| */ | ||
| process.nextTick(() => { | ||
| if (this.readyState === SocketController.PENDING && !this[kRawSocket].destroyed) this.passthrough(); | ||
| }); | ||
| } | ||
| }; | ||
| var TcpSocketController = class extends SocketController { | ||
| #connectionOptions; | ||
| #retargetedConnectionOptions; | ||
| #realWriteGeneric; | ||
| #passthroughSocket = null; | ||
| #bufferedWrites = []; | ||
| #readsCorked = false; | ||
| #corkedReads = []; | ||
| #realHandleSwapped = false; | ||
| #resetScheduled = false; | ||
| #clientCloseEmitted = false; | ||
| #clientEndPushed = false; | ||
| #connectEmulated = false; | ||
| constructor(socket, createConnection, connectionOptions) { | ||
| super(socket); | ||
| this.socket = socket; | ||
| this.createConnection = createConnection; | ||
| /** | ||
| * @note Plain TCP sockets capture the connection options from the | ||
| * "socket.connect()" proxy below. TLS sockets carry additional | ||
| * TLS-level options that never pass through "socket.connect()", | ||
| * so those are provided explicitly (see "TlsSocketController"). | ||
| */ | ||
| this.#connectionOptions = connectionOptions; | ||
| this.socket._read = () => { | ||
| /** | ||
| * @note Resume the passthrough socket when the consumer asks for | ||
| * more data. Node.js calls "_read()" once the consumer drains the | ||
| * read buffer (e.g. after "resume()"). The passthrough socket may | ||
| * have been paused when the consumer's buffer got full, and this | ||
| * is the only signal to continue reading. | ||
| */ | ||
| this.#passthroughSocket?.resume(); | ||
| }; | ||
| this.#realWriteGeneric = this.socket._writeGeneric; | ||
| this.#bufferedWrites = []; | ||
| this.socket._writeGeneric = (...args) => { | ||
| this.#handleWrite(args); | ||
| }; | ||
| this.socket.connect = new Proxy(this.socket.connect, { apply: (target, thisArg, args) => { | ||
| logger$1.verbose("socket.connect() %o", args); | ||
| this.#connectionOptions = args[0]; | ||
| /** | ||
| * @note Do not bind the intercepted socket to the requested | ||
| * local address/port. Binding reserves the port for real, and | ||
| * the passthrough connection (created with the original options) | ||
| * would then bind the same port again, resulting in a conflict. | ||
| * The requested values are still reflected in the address info | ||
| * of a claimed socket (see "claim()"). | ||
| */ | ||
| if (args[0] != null && typeof args[0] === "object" && (args[0].localAddress != null || args[0].localPort != null)) args[0] = { | ||
| ...args[0], | ||
| localAddress: void 0, | ||
| localPort: void 0 | ||
| }; | ||
| return Reflect.apply(target, thisArg, args); | ||
| } }); | ||
| /** | ||
| * @note A single socket can be reused for connections to the same host. | ||
| * When one connection ends, the Agent frees the socket, then uses it | ||
| * to write the next request's HTTP message immediately. Use the "free" | ||
| * event to transition the controller into the pending state so the | ||
| * next exchange is handled anew. | ||
| */ | ||
| socket.on("free", () => { | ||
| logger$1.verbose("client socket freed!"); | ||
| this.reset(); | ||
| }).on("close", () => { | ||
| logger$1.verbose("client socket closed!"); | ||
| this.#clientCloseEmitted = true; | ||
| /** | ||
| * @note Destroy the passthrough socket, if any. The client | ||
| * socket is done, and a passthrough connection left open would | ||
| * linger until the server closes it (or error unhandled if it | ||
| * is still connecting). | ||
| */ | ||
| this.#passthroughSocket?.destroy(); | ||
| this.#passthroughSocket = null; | ||
| this.#bufferedWrites = []; | ||
| this.#readsCorked = false; | ||
| this.#corkedReads = []; | ||
| }); | ||
| this.serverSocket = toServerSocket(this.socket); | ||
| this.pendingConnection = Promise.withResolvers(); | ||
| this.#reset(); | ||
| } | ||
| /** | ||
| * Reset this controller to the pending state so the next exchange | ||
| * on this socket can be handled anew. This is meant for kept-alive | ||
| * sockets that are reused for multiple exchanges by clients that | ||
| * don't emit the "free" event on the socket (e.g. Undici). | ||
| * | ||
| * Providing connection options retargets this connection: the | ||
| * exchanges that follow belong to the given target (e.g. the | ||
| * authority of an established "CONNECT" tunnel). An unclaimed | ||
| * exchange then passes through to that target instead of the | ||
| * originally dialed one, and a claimed exchange reports it as the | ||
| * peer. The verdict count is deliberately not re-armed: subscribers | ||
| * that declined this connection's protocol stay declined across | ||
| * the exchanges, retargeted or not. | ||
| */ | ||
| reset(connectionOptions) { | ||
| if (connectionOptions != null) { | ||
| this.#retargetedConnectionOptions = connectionOptions; | ||
| this.#connectionOptions = connectionOptions; | ||
| /** | ||
| * @note The passthrough connection to the original target, if | ||
| * any, cannot serve the retargeted exchanges. Detach its | ||
| * forwarding listeners before destroying it so its teardown is | ||
| * not mistaken for the client connection's own (e.g. its "close" | ||
| * must not close the client socket). | ||
| */ | ||
| if (this.#passthroughSocket) { | ||
| this.#passthroughSocket.removeListener("connect", this.#onRealSocketConnect).removeListener("connectionAttemptFailed", this.#onRealSocketConnectionAttemptFailed).removeListener("connectionAttemptTimeout", this.#onRealSocketConnectionAttemptTimeout).removeListener("data", this.#onRealSocketData).removeListener("error", this.#onRealSocketError).removeListener("end", this.#onRealSocketEnd).removeListener("close", this.#onRealSocketClose).destroy(); | ||
| this.#passthroughSocket = null; | ||
| /** | ||
| * @note The handle swapped in from the destroyed connection, | ||
| * if any, no longer carries this socket's traffic. Treat the | ||
| * socket as not swapped so the retargeted passthrough writes | ||
| * directly to the new connection until its own handle swap. | ||
| */ | ||
| this.#realHandleSwapped = false; | ||
| } | ||
| } | ||
| /** | ||
| * @note Only settled (claimed or passed-through) sockets need a reset. | ||
| * Resetting a pending socket again would discard the writes already | ||
| * buffered for the next exchange (e.g. the Agent "free" event firing | ||
| * after the parser has reset this controller at a message boundary). | ||
| */ | ||
| if (this.readyState === SocketController.PENDING) return; | ||
| this.#reset(); | ||
| } | ||
| /** | ||
| * Schedule a reset of this controller to happen right before the | ||
| * next client write. The consumer signals the boundary of the | ||
| * current exchange (e.g. an HTTP message boundary) the moment it is | ||
| * parsed — mid-delivery of the exchange's final chunk — when an | ||
| * immediate reset would discard that chunk. Deferring the reset to | ||
| * the next write lets the current exchange settle as-is while the | ||
| * next exchange's first write already buffers for a new verdict | ||
| * (instead of following the previous one, e.g. leaking to the | ||
| * passthrough connection). | ||
| */ | ||
| scheduleReset() { | ||
| this.#resetScheduled = true; | ||
| } | ||
| #reset() { | ||
| logger$1.verbose("resetting the socket..."); | ||
| this.#resetScheduled = false; | ||
| this.readyState = SocketController.PENDING; | ||
| this.pendingConnection = Promise.withResolvers(); | ||
| this.#bufferedWrites = []; | ||
| this.#connectEmulated = false; | ||
| this.socket._pendingData = null; | ||
| this.socket._pendingEncoding = ""; | ||
| const wrapHandle = (handle) => { | ||
| this.pendingConnection.promise.then(() => { | ||
| logger$1.verbose("connection request resolved!", this.readyState); | ||
| process.nextTick(() => { | ||
| /** | ||
| * @note If by this point the socket hasn't been handled, | ||
| * is still connecting, doesn't have any writes buffered, | ||
| * and has a "connect" listener, assume it's the "write after connect" | ||
| * scenario (e.g. undici). In that case, auto-claim the socket to | ||
| * transition to the connected state appropriately to its handle. | ||
| */ | ||
| if (this.readyState === SocketController.PENDING && this.socket.connecting && this.#bufferedWrites.length === 0 && this.socket.listenerCount("connect") > 0) { | ||
| logger$1.verbose("assume connect->write socket, calling \"connect\" listeners..."); | ||
| this.emulateConnect(); | ||
| } | ||
| }); | ||
| }); | ||
| /** | ||
| * Remove the "setTypeOfService" from the handle, if present (Node.js v24+). | ||
| * Removing it has no effect on the socket but prevents the "setTypeOfService EBADF" error. | ||
| * @see https://github.com/nodejs/node/blob/69a970f76814d40f55cf162d0cc3632fe8a7e599/lib/net.js#L661 | ||
| * @see https://github.com/nodejs/undici/blob/bf684f7de01616708a33a5d1c092177622394442/lib/dispatcher/client-h1.js#L1136 | ||
| */ | ||
| if (handle.setTypeOfService) handle.setTypeOfService = void 0; | ||
| handle.connect = handle.connect6 = (request) => { | ||
| logger$1.verbose("handle.connect()"); | ||
| this.pendingConnection.resolve([request, handle]); | ||
| }; | ||
| logger$1.verbose("socket handle wrapped! waiting for connection request..."); | ||
| }; | ||
| if (this.socket._handle) wrapHandle(this.socket._handle); | ||
| else this.socket.prependOnceListener("connectionAttempt", () => { | ||
| wrapHandle(this.socket._handle); | ||
| }); | ||
| } | ||
| /** | ||
| * Handle a write performed on the client socket. Installed once as | ||
| * "_writeGeneric", this is the single write path for every controller | ||
| * state, dispatching on "readyState". | ||
| */ | ||
| #handleWrite(args) { | ||
| const data = args[1]; | ||
| logger$1.verbose("socket write (state: %d) %o", this.readyState, args); | ||
| /** | ||
| * @note A scheduled reset marks the boundary of the previous | ||
| * exchange (see "scheduleReset"). This write is the first one past | ||
| * the boundary; reset before dispatching so it opens the next | ||
| * exchange instead of following the previous exchange's verdict. | ||
| */ | ||
| if (this.#resetScheduled) this.reset(); | ||
| /** | ||
| * @note Buffer the write BEFORE pushing the data to the server socket. | ||
| * Handling the pushed data may transition this controller within the | ||
| * same call stack, and each transition settles the buffered entry: | ||
| * "passthrough()" flushes it to the real socket, "claim()" drops it, | ||
| * and a reset at an HTTP message boundary keeps it for the next exchange. | ||
| */ | ||
| this.#bufferedWrites.push(args); | ||
| if (this.readyState === SocketController.PENDING) { | ||
| /** | ||
| * @note Reflect the buffered write in "_pendingData" so it counts | ||
| * toward "bytesWritten", the same way Node.js counts the pending | ||
| * data of a connecting socket. Flushing the buffered writes resets | ||
| * "_pendingData" (see `passthrough()`). | ||
| */ | ||
| const pendingData = Array.isArray(this.socket._pendingData) ? this.socket._pendingData : []; | ||
| unwrapPendingData(data, (chunk, chunkEncoding) => { | ||
| pendingData.push({ | ||
| chunk, | ||
| encoding: chunkEncoding | ||
| }); | ||
| }); | ||
| this.socket._pendingData = pendingData; | ||
| if (this.socket.listenerCount("internal:write") === 0) { | ||
| logger$1.verbose("no server data listeners, scheduling to the next tick..."); | ||
| process.nextTick(() => { | ||
| this.#push(data); | ||
| }); | ||
| } else this.#push(data); | ||
| } else this.#push(data); | ||
| /** | ||
| * Dispatch on the state observed AFTER the push since handling the | ||
| * pushed data may have transitioned this controller synchronously. | ||
| */ | ||
| switch (this.readyState) { | ||
| case SocketController.PENDING: | ||
| /** | ||
| * @note The write either awaits the verdict on this exchange or, | ||
| * if the push reset this controller at a message boundary, opens | ||
| * the next exchange (the reset cleared the buffer; re-buffer it). | ||
| */ | ||
| if (!this.#bufferedWrites.includes(args)) this.#bufferedWrites.push(args); | ||
| this.#acknowledgeWrite(args); | ||
| return; | ||
| case SocketController.CLAIMED: | ||
| /** | ||
| * @note Once claimed, there's nowhere else to write chunks to. | ||
| * The data was delivered to the server socket; complete the write | ||
| * so the socket's writable state settles (enabling "finish"). | ||
| */ | ||
| this.#removeBufferedWrite(args); | ||
| this.#acknowledgeWrite(args); | ||
| return; | ||
| case SocketController.PASSTHROUGH: | ||
| /** | ||
| * @note A synchronous "passthrough()" has already flushed this | ||
| * write (with its callback) to the real socket. | ||
| */ | ||
| if (!this.#removeBufferedWrite(args)) return; | ||
| /** | ||
| * @note Until the handle swap, the client socket's own handle | ||
| * cannot carry any data (it never actually connects). Write | ||
| * directly to the passthrough socket instead. This also prevents | ||
| * the "connecting" write replay of `Socket.prototype._writeGeneric` | ||
| * from pushing the same data to the server socket twice. | ||
| */ | ||
| if (!this.#realHandleSwapped && this.#passthroughSocket) { | ||
| writePendingData(this.#passthroughSocket, data, args[2], args[3]); | ||
| return; | ||
| } | ||
| this.#realWriteGeneric.apply(this.socket, args); | ||
| } | ||
| } | ||
| /** | ||
| * Complete the given write by invoking its callback. The callback is | ||
| * then removed from the write entry so flushing that entry later | ||
| * (e.g. on passthrough) does not invoke it twice. | ||
| */ | ||
| #acknowledgeWrite(args) { | ||
| const callback = args[3]; | ||
| if (typeof callback === "function") { | ||
| callback(); | ||
| args[3] = void 0; | ||
| } | ||
| } | ||
| #removeBufferedWrite(args) { | ||
| const index = this.#bufferedWrites.indexOf(args); | ||
| if (index === -1) return false; | ||
| this.#bufferedWrites.splice(index, 1); | ||
| return true; | ||
| } | ||
| emulateConnect() { | ||
| this.#connectEmulated = true; | ||
| /** | ||
| * @note Reflect the connected state before notifying the listeners, | ||
| * the same way Node.js does before emitting "connect". | ||
| */ | ||
| Reflect.set(this.socket, "connecting", false); | ||
| /** | ||
| * @note Invoke the raw listeners so the "once" wrappers get | ||
| * consumed. This prevents listeners like the connection callback | ||
| * from being invoked again when "connect" is emitted for real | ||
| * (e.g. once the claimed connection completes). | ||
| */ | ||
| for (const listener of this.socket.rawListeners("connect")) listener.apply(this.socket); | ||
| } | ||
| /** | ||
| * Push the given data to the server socket. | ||
| * This has no effect on the public-facing socket and is used | ||
| * only for the interceptors to subscribe to "socket.on('data')" | ||
| * before the data is actually written anywhere. | ||
| */ | ||
| #push = (data) => { | ||
| if (data == null) return; | ||
| logger$1.verbose("server push %o", data); | ||
| unwrapPendingData(data, (chunk, encoding) => { | ||
| logger$1.verbose("server emitting \"data\" %o", { | ||
| chunk, | ||
| encoding | ||
| }); | ||
| this.socket.emit("internal:write", chunk, encoding); | ||
| }); | ||
| }; | ||
| #onRealSocketConnect = () => { | ||
| if (!this.#passthroughSocket) return; | ||
| /** | ||
| * @note The consumer may destroy the socket while the passthrough | ||
| * connection is still being established. The passthrough "connect" | ||
| * (I/O poll phase) can arrive before the client's "close" callback | ||
| * (close phase) within the same event loop iteration. A destroyed | ||
| * socket must not emit "connect"/"ready". | ||
| */ | ||
| if (this.socket.destroyed) { | ||
| this.#passthroughSocket.destroy(); | ||
| return; | ||
| } | ||
| const replacedHandle = this.socket._handle; | ||
| const wasUnrefed = replacedHandle != null && typeof replacedHandle.hasRef === "function" && !replacedHandle.hasRef(); | ||
| this.socket._handle = this.#passthroughSocket._handle; | ||
| this.#realHandleSwapped = true; | ||
| /** | ||
| * @note Preserve the ref state across the handle swap. If the | ||
| * consumer unrefed the socket while it was connecting, the swapped | ||
| * handle must not hold the process alive either. | ||
| */ | ||
| if (wasUnrefed) this.socket._handle.unref?.(); | ||
| /** | ||
| * @note Close the replaced handle. Nothing references it past this | ||
| * point, and left open, it keeps the process alive indefinitely. | ||
| * For TLS sockets, the replaced handle is a TLSWrap; close its | ||
| * underlying transport too (closing the wrap alone does not close | ||
| * the TCP handle it sits on). | ||
| */ | ||
| if (replacedHandle != null) { | ||
| replacedHandle.close(); | ||
| replacedHandle._parent?.close(); | ||
| } | ||
| Reflect.set(this.socket, "connecting", false); | ||
| /** | ||
| * @note Read the remote address info once so it gets cached on the | ||
| * socket. The passthrough socket controls the swapped handle and may | ||
| * close it at any point (e.g. once the server ends the connection), | ||
| * while Node.js keeps serving the cached info for sockets that | ||
| * have connected. Reading must happen after the socket is no longer | ||
| * connecting (the info of connecting sockets is never cached). | ||
| */ | ||
| this.socket.remoteAddress; | ||
| this.socket.emit("connect"); | ||
| this.socket.emit("ready"); | ||
| }; | ||
| #onRealSocketConnectionAttemptFailed = (address, port, family, error) => { | ||
| this.socket.emit("connectionAttemptFailed", address, port, family, error); | ||
| }; | ||
| #onRealSocketConnectionAttemptTimeout = (address, port, family) => { | ||
| this.socket.emit("connectionAttemptTimeout", address, port, family); | ||
| }; | ||
| #onRealSocketData = (data) => { | ||
| logger$1.verbose("real socket \"data\" event %o", data); | ||
| /** | ||
| * @note Receiving data is socket activity. Refresh the idle timer | ||
| * of the client socket the same way its own reads would | ||
| * ("socket.setTimeout()" must not fire during an active transfer). | ||
| * The data arrives on the real socket, which only refreshes the | ||
| * real socket's timer. | ||
| */ | ||
| this.socket._unrefTimer(); | ||
| if (this.#readsCorked) { | ||
| logger$1.verbose("reads are corked, buffering the data..."); | ||
| this.#corkedReads.push({ | ||
| type: "data", | ||
| chunk: data | ||
| }); | ||
| return; | ||
| } | ||
| if (!this.socket.push(data)) { | ||
| logger$1.verbose("client socket forbade more pushes, pausing the passthrough socket..."); | ||
| this.#passthroughSocket?.pause(); | ||
| } | ||
| }; | ||
| #onRealSocketError = (error) => { | ||
| logger$1.verbose("real socket \"error\" event %o", error); | ||
| if (this.socket.destroyed) { | ||
| logger$1.verbose("real socket errored but client socket already destroyed, skipping..."); | ||
| return; | ||
| } | ||
| logger$1.verbose("real socket errored, forwarding %o", error); | ||
| this.socket.destroy(error); | ||
| if (this.#realHandleSwapped) process.nextTick(() => this.socket.emit("close", true)); | ||
| }; | ||
| #onRealSocketEnd = () => { | ||
| this.socket._unrefTimer(); | ||
| if (this.#readsCorked) { | ||
| this.#corkedReads.push({ type: "end" }); | ||
| return; | ||
| } | ||
| this.#clientEndPushed = true; | ||
| this.socket.push(null); | ||
| }; | ||
| #onRealSocketClose = (hadError) => { | ||
| /** | ||
| * @note The connection is fully closed and the swapped handle | ||
| * cannot shut down anymore. Report a synchronous shutdown so the | ||
| * automatic "end()" of a half-closed socket ("allowHalfOpen: false") | ||
| * finishes without errors once the consumer reads the end-of-stream. | ||
| */ | ||
| if (this.#realHandleSwapped && this.socket._handle) this.socket._handle.shutdown = () => 1; | ||
| if (this.#readsCorked) { | ||
| this.#corkedReads.push({ | ||
| type: "close", | ||
| hadError | ||
| }); | ||
| return; | ||
| } | ||
| if (this.#clientCloseEmitted) return; | ||
| if (this.socket.destroyed && !this.#realHandleSwapped) return; | ||
| this.#emitClientClose(hadError); | ||
| }; | ||
| /** | ||
| * Emit the "close" event on the client socket, honoring the order | ||
| * of the socket teardown events. | ||
| * @note The client may be paused with the received data (and the | ||
| * end-of-stream) still buffered. Node.js never emits "close" before | ||
| * "end" on a gracefully closed connection: the teardown waits until | ||
| * the consumer reads the buffered data. | ||
| */ | ||
| #emitClientClose(hadError) { | ||
| if (this.#clientEndPushed && !this.socket.readableEnded && !this.socket.destroyed) { | ||
| let closeDelivered = false; | ||
| const deliverClose = (hadErrorOverride) => { | ||
| if (closeDelivered) return; | ||
| closeDelivered = true; | ||
| process.nextTick(() => { | ||
| if (!this.#clientCloseEmitted) this.socket.emit("close", hadErrorOverride ?? hadError); | ||
| }); | ||
| }; | ||
| this.socket.once("end", () => { | ||
| deliverClose(); | ||
| }); | ||
| /** | ||
| * @note The consumer may also destroy the socket before reading | ||
| * the buffered data. Node.js still emits "close" for such | ||
| * sockets, but the destroy machinery of a socket with a swapped | ||
| * (already closed) handle never completes. Deliver "close" here. | ||
| */ | ||
| const realDestroy = this.socket._destroy; | ||
| this.socket._destroy = (error, callback) => { | ||
| deliverClose(error != null); | ||
| return realDestroy.call(this.socket, error, callback); | ||
| }; | ||
| return; | ||
| } | ||
| this.socket.emit("close", hadError); | ||
| } | ||
| #onMockSocketDrain = () => { | ||
| logger$1.verbose("client socket drained!"); | ||
| this.#passthroughSocket?.resume(); | ||
| }; | ||
| /** | ||
| * Forward the client's half-close to the passthrough socket. | ||
| * The client's writable side may finish before the real handle is | ||
| * swapped in ("_final" of a connected client runs against the mock | ||
| * handle), leaving the FIN unsent. Once the handle is swapped, the | ||
| * client's own shutdown reaches the real connection natively. | ||
| */ | ||
| #forwardClientFinish = () => { | ||
| if (!this.#realHandleSwapped) this.#passthroughSocket?.end(); | ||
| }; | ||
| /** | ||
| * Suspend forwarding of the passthrough socket events ("data", "end", "close") | ||
| * to the client socket. The events are buffered in order until `uncorkReads()` | ||
| * is called. This allows the consumer to delay the delivery of the original | ||
| * response to the client (e.g. until its own asynchronous logic settles). | ||
| * | ||
| * @note Pausing the client socket is not enough to delay the delivery. | ||
| * Consumers like Undici read the pushed data from a paused socket | ||
| * directly via `socket.read()`, which is unaffected by `socket.pause()`. | ||
| */ | ||
| corkReads() { | ||
| this.#readsCorked = true; | ||
| } | ||
| /** | ||
| * Resume forwarding of the passthrough socket events to the client socket, | ||
| * replaying any events buffered while the reads were corked. | ||
| */ | ||
| uncorkReads() { | ||
| if (!this.#readsCorked) return; | ||
| this.#readsCorked = false; | ||
| for (const corkedRead of this.#corkedReads.splice(0)) switch (corkedRead.type) { | ||
| case "data": | ||
| if (!this.socket.push(corkedRead.chunk)) { | ||
| logger$1.verbose("client socket forbade more pushes, pausing the passthrough socket..."); | ||
| this.#passthroughSocket?.pause(); | ||
| } | ||
| break; | ||
| case "end": | ||
| this.#clientEndPushed = true; | ||
| this.socket.push(null); | ||
| break; | ||
| case "close": | ||
| this.#emitClientClose(corkedRead.hadError); | ||
| break; | ||
| } | ||
| } | ||
| claim() { | ||
| super.claim(); | ||
| /** | ||
| * @note The client may destroy the socket before the connection | ||
| * is claimed (e.g. abort a request in-flight). There is no | ||
| * connection to mock then, and the destroyed socket has no handle. | ||
| */ | ||
| if (this.socket.destroyed) { | ||
| logger$1.verbose("socket already destroyed, skipping claim..."); | ||
| return; | ||
| } | ||
| /** | ||
| * @note Skip already connected sockets (e.g. kept-alive sockets | ||
| * reused for the next exchange). Sockets with an emulated "connect" | ||
| * only appear connected and must still complete the mock connection. | ||
| */ | ||
| if (!this.socket.connecting && !this.#connectEmulated) { | ||
| logger$1.verbose("socket already connected, skipping claim..."); | ||
| return; | ||
| } | ||
| logger$1.verbose("-> claim!"); | ||
| /** | ||
| * @note Reflect the local end of the claimed socket, the same way | ||
| * the operating system reports the bound address of an outgoing | ||
| * connection via "socket.address()"/"localAddress"/"localPort". | ||
| * Patching the handle also prevents Node.js from handling the | ||
| * "getsockname" errors of the never-connected raw handle. | ||
| * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/net.js#L971 | ||
| */ | ||
| this.socket._handle.getsockname = (addressInfo) => { | ||
| Object.assign(addressInfo, getLocalAddressInfoByConnectionOptions(this.#connectionOptions)); | ||
| return 0; | ||
| }; | ||
| /** | ||
| * @note Reflect the connection target as the peer of the claimed | ||
| * socket, the same way a connected socket reports the server it | ||
| * connected to via "remoteAddress"/"remotePort"/"remoteFamily". | ||
| */ | ||
| this.socket._handle.getpeername = (addressInfo) => { | ||
| Object.assign(addressInfo, getAddressInfoByConnectionOptions(this.#connectionOptions)); | ||
| return 0; | ||
| }; | ||
| this.#bufferedWrites = []; | ||
| this.socket._pendingData = null; | ||
| this.socket._pendingEncoding = ""; | ||
| this.pendingConnection.promise.then(([request, handle]) => { | ||
| logger$1.verbose("connection request resolved, mocking the connection..."); | ||
| /** | ||
| * @note "afterConnect" asserts that the socket is connecting. | ||
| * Restore the flag if the connect was emulated earlier (emulation | ||
| * flips it so its listeners observe a connected socket). | ||
| * "afterConnect" itself sets it back to false. | ||
| */ | ||
| if (this.#connectEmulated) Reflect.set(this.socket, "connecting", true); | ||
| /** | ||
| * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 | ||
| */ | ||
| request.oncomplete(0, handle, request, true, true); | ||
| }); | ||
| } | ||
| passthrough(flushPendingData) { | ||
| super.passthrough(); | ||
| logger$1.verbose("-> passthrough!"); | ||
| const createRealSocket = () => { | ||
| const realSocket = this.#retargetedConnectionOptions ? this.#createRetargetedConnection(this.#retargetedConnectionOptions) : this.createConnection(); | ||
| realSocket[kPatched] = true; | ||
| if (this.socket.timeout != null) realSocket.setTimeout(this.socket.timeout); | ||
| return realSocket; | ||
| }; | ||
| const realSocket = this.#passthroughSocket && !this.#passthroughSocket.destroyed ? this.#passthroughSocket : createRealSocket(); | ||
| if (realSocket !== this.#passthroughSocket) this.#passthroughSocket = realSocket; | ||
| if (this.#bufferedWrites.length === 0) logger$1.verbose("passthrough with empty writes buffer (state: %d)", this.readyState); | ||
| /** | ||
| * Flush any writes during the pending phase to the passthrough socket. | ||
| * @note These are written directly on the passthrough socket to prevent | ||
| * them from being forwarded as "data" events on the server (already emitted). | ||
| */ | ||
| for (let i = 0; i < this.#bufferedWrites.length; i++) { | ||
| const pendingWrite = this.#bufferedWrites[i]; | ||
| if (i === 0 && typeof flushPendingData === "function") { | ||
| const data = pendingWrite[1]; | ||
| const encoding = pendingWrite[2]; | ||
| flushPendingData(data, encoding, (nextData) => { | ||
| pendingWrite[1] = nextData; | ||
| }); | ||
| } | ||
| const [, data, encoding, callback] = pendingWrite; | ||
| writePendingData(realSocket, data, encoding, callback); | ||
| } | ||
| this.#bufferedWrites = []; | ||
| this.socket._pendingData = null; | ||
| this.socket._pendingEncoding = ""; | ||
| this.socket.address = realSocket.address.bind(realSocket); | ||
| this.socket.removeListener("drain", this.#onMockSocketDrain); | ||
| this.socket.on("drain", this.#onMockSocketDrain); | ||
| realSocket.removeListener("connect", this.#onRealSocketConnect).removeListener("connectionAttemptFailed", this.#onRealSocketConnectionAttemptFailed).removeListener("connectionAttemptTimeout", this.#onRealSocketConnectionAttemptTimeout).removeListener("data", this.#onRealSocketData).removeListener("error", this.#onRealSocketError).removeListener("end", this.#onRealSocketEnd).removeListener("close", this.#onRealSocketClose); | ||
| realSocket.once("connect", this.#onRealSocketConnect).on("connectionAttemptFailed", this.#onRealSocketConnectionAttemptFailed).on("connectionAttemptTimeout", this.#onRealSocketConnectionAttemptTimeout).on("data", this.#onRealSocketData).on("error", this.#onRealSocketError).on("end", this.#onRealSocketEnd).on("close", this.#onRealSocketClose); | ||
| /** | ||
| * @note Forward the client's half-close, unless the real handle | ||
| * is already swapped in — the client's own shutdown then reaches | ||
| * the real connection natively (see "#forwardClientFinish"). | ||
| */ | ||
| if (!this.#realHandleSwapped) if (this.socket.writableFinished) this.#forwardClientFinish(); | ||
| else { | ||
| this.socket.removeListener("finish", this.#forwardClientFinish); | ||
| this.socket.once("finish", this.#forwardClientFinish); | ||
| } | ||
| return realSocket; | ||
| } | ||
| /** | ||
| * Create the passthrough connection to the target this controller | ||
| * was retargeted to (see `reset()`). The original `createConnection` | ||
| * dials the originally requested target and cannot serve retargeted | ||
| * exchanges. | ||
| */ | ||
| #createRetargetedConnection(connectionOptions) { | ||
| const realSocket = new net.Socket(); | ||
| /** | ||
| * @note Mark the socket as patched before connecting: the patched | ||
| * "Socket.prototype.connect" exempts such sockets, establishing | ||
| * the connection for real. | ||
| */ | ||
| realSocket[kPatched] = true; | ||
| return realSocket.connect(connectionOptions); | ||
| } | ||
| }; | ||
| var TlsSocketController = class extends TcpSocketController { | ||
| /** | ||
| * @note The TLS connection options must be provided explicitly. | ||
| * They cannot be captured from "socket.connect()" like for plain | ||
| * TCP sockets because "tls.connect()" fixes them at the TLS socket | ||
| * construction, before its transport ever connects. | ||
| */ | ||
| #tlsConnectionOptions; | ||
| constructor(socket, createConnection, tlsConnectionOptions) { | ||
| super(socket, createConnection, tlsConnectionOptions); | ||
| this.socket = socket; | ||
| this.createConnection = createConnection; | ||
| this.#tlsConnectionOptions = tlsConnectionOptions; | ||
| socket.prependListener("secureConnect", () => { | ||
| /** | ||
| * @note Reflect the negotiated ALPN protocol from the handle that | ||
| * completed the handshake. The socket sets "alpnProtocol" only in | ||
| * its own "_finishInit", which never runs for passthrough | ||
| * connections (the handshake completes on the passthrough socket | ||
| * whose handle this socket inherits). | ||
| */ | ||
| socket.alpnProtocol = socket._handle.getALPNNegotiatedProtocol(); | ||
| }); | ||
| } | ||
| emulateConnect() { | ||
| super.emulateConnect(); | ||
| for (const listener of this.socket.rawListeners("secureConnect")) listener.apply(this.socket); | ||
| } | ||
| claim() { | ||
| /** | ||
| * @note The client may destroy the socket before the connection | ||
| * is claimed. Defer to the parent class, which transitions the | ||
| * ready state and skips the mock connection of destroyed sockets. | ||
| */ | ||
| if (this.socket.destroyed) { | ||
| super.claim(); | ||
| return; | ||
| } | ||
| /** | ||
| * @note Reflect that the mocked connection is not authorized. | ||
| * There is no real peer certificate to verify. The identity check | ||
| * itself is skipped for claimed connections (see the | ||
| * "isSessionReused" mock below). | ||
| */ | ||
| this.socket.prependOnceListener("secureConnect", () => { | ||
| Reflect.set(this.socket, "authorized", false); | ||
| Reflect.set(this.socket, "authorizationError", "MOCKED_CONNECTION_NOT_VERIFIED"); | ||
| }); | ||
| const handle = this.socket._handle; | ||
| handle.start = () => void 0; | ||
| /** | ||
| * Mock this to prevent the "Error: Worker exited unexpectedly" error. | ||
| * This will trigger when "secure" is emitted. | ||
| * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 | ||
| */ | ||
| handle.verifyError = () => void 0; | ||
| handle.getSession = () => { | ||
| return Buffer.from("mocked session"); | ||
| }; | ||
| /** | ||
| * @note Skip the server identity check for this mocked connection. | ||
| * Node.js runs "options.checkServerIdentity" in "onConnectSecure" | ||
| * against the peer certificate, and a mocked connection has no | ||
| * real peer certificate — the caller's (or the default) validation | ||
| * would fail and destroy the socket. The check is overridden on the | ||
| * socket's internal connect options since the emulated handshake | ||
| * still completes through the regular "onConnectSecure" path. | ||
| * @see https://github.com/nodejs/node/blob/3178a762d6a2b1a37b74f02266eea0f3d86603f1/lib/_tls_wrap.js#L1621 | ||
| */ | ||
| const realTlsConnectOptions = getTlsConnectOptions(this.socket); | ||
| if (realTlsConnectOptions) realTlsConnectOptions.checkServerIdentity = () => {}; | ||
| handle.getCipher = () => { | ||
| return { | ||
| name: "TLS_AES_256_GCM_SHA384", | ||
| standardName: "TLS_AES_256_GCM_SHA384", | ||
| version: "TLSv1.3" | ||
| }; | ||
| }; | ||
| /** | ||
| * Mock this to prevent a segfault on Node.js 26+. The native | ||
| * implementation reads the negotiated group ("SSL_get0_group_name") | ||
| * of a handshake that never happened. Node.js itself calls this | ||
| * in "onConnectSecure" to validate the "minDHSize" option. | ||
| * Reflect the ephemeral key exchange matching the mocked cipher. | ||
| * @see https://github.com/nodejs/node/blob/3178a762d6a2b1a37b74f02266eea0f3d86603f1/lib/_tls_wrap.js#L1636 | ||
| */ | ||
| handle.getEphemeralKeyInfo = () => { | ||
| return { | ||
| type: "ECDH", | ||
| name: "X25519", | ||
| size: 253 | ||
| }; | ||
| }; | ||
| const requestedAlpnProtocols = this.#tlsConnectionOptions?.ALPNProtocols; | ||
| if (Array.isArray(requestedAlpnProtocols) && requestedAlpnProtocols.length > 0) { | ||
| const [preferredProtocol] = requestedAlpnProtocols; | ||
| /** | ||
| * @note Reflect the client's preferred ALPN protocol as the | ||
| * negotiated one. The mocked server accepts whatever the | ||
| * client prefers. | ||
| */ | ||
| handle.getALPNNegotiatedProtocol = () => { | ||
| return typeof preferredProtocol === "string" ? preferredProtocol : false; | ||
| }; | ||
| } | ||
| this.socket.once("connect", () => { | ||
| /** | ||
| * @note A TLS 1.3 handshake derives five secrets, each reported | ||
| * via a separate "keylog" event before the handshake completes. | ||
| * Reflect them with mocked key material matching the mocked | ||
| * cipher (SHA-384 secrets; the format is "LABEL <client random> | ||
| * <secret>", each value hex-encoded). | ||
| */ | ||
| const mockedClientRandom = "0".repeat(64); | ||
| const mockedSecret = "0".repeat(96); | ||
| for (const keylogLabel of [ | ||
| "SERVER_HANDSHAKE_TRAFFIC_SECRET", | ||
| "EXPORTER_SECRET", | ||
| "SERVER_TRAFFIC_SECRET_0", | ||
| "CLIENT_HANDSHAKE_TRAFFIC_SECRET", | ||
| "CLIENT_TRAFFIC_SECRET_0" | ||
| ]) this.socket.emit("keylog", Buffer.from(`${keylogLabel} ${mockedClientRandom} ${mockedSecret}\n`)); | ||
| handle.onhandshakedone(); | ||
| /** | ||
| * @note A TLS 1.3 server issues two session tickets by default, | ||
| * each emitting a separate "session" event on the client. | ||
| */ | ||
| handle.onnewsession(1, Buffer.from("mocked session")); | ||
| handle.onnewsession(2, Buffer.from("mocked session")); | ||
| }); | ||
| super.claim(); | ||
| } | ||
| passthrough(flushPendingData) { | ||
| const realSocket = super.passthrough(flushPendingData); | ||
| /** | ||
| * @note Remove the internal "connect" listener added by the TLS socket. | ||
| * Normally, that listener manages the SSL handshake. But since we're in passthrough, | ||
| * we delegate that to the real socket. Leaving the listener on the mock socket while | ||
| * inheriting the real socket's handle will result in the handshake performed twice, which is a no-op. | ||
| * @see https://github.com/nodejs/node/blob/abddfc921bf2af02a04a6a5d2bca8e2d91d80958/lib/internal/tls/wrap.js#L1105 | ||
| * | ||
| * This prevents the following error: | ||
| * # node (vitest 4)[8686]: static void node::crypto::TLSWrap::Start(const FunctionCallbackInfo<Value> &) at ../src/crypto/crypto_tls.cc:589 | ||
| # Assertion failed: !wrap->started_ | ||
| */ | ||
| for (const connectListener of this.socket.listeners("connect")) if (connectListener === this.socket._start || "listener" in connectListener && connectListener.listener === this.socket._start) this.socket.removeListener("connect", connectListener); | ||
| realSocket.on("secure", () => { | ||
| this.socket.emit("secure"); | ||
| }).on("session", (...args) => { | ||
| this.socket.emit("session", ...args); | ||
| }).on("keylog", (...args) => { | ||
| this.socket.emit("keylog", ...args); | ||
| }).on("OCSPResponse", (...args) => { | ||
| this.socket.emit("OCSPResponse", ...args); | ||
| }); | ||
| return realSocket; | ||
| } | ||
| }; | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/normalize-tls-connect-args.ts | ||
| function normalizeTlsConnectArgs(args) { | ||
| /** | ||
| * @note Despite incorrect type definitions, "tls.connect()" has all the | ||
| * options of "net.connect()" and then those specific to TLS connections, | ||
| * like "session" or "socket". | ||
| * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1615 | ||
| */ | ||
| const netConnectArgs = normalizeNetConnectArgs(args); | ||
| const options = netConnectArgs[0]; | ||
| const callback = netConnectArgs[1]; | ||
| if (args[0] !== null && typeof args[0] === "object") Object.assign(options, args[0]); | ||
| else if (args[1] !== null && typeof args[1] === "object") Object.assign(options, args[1]); | ||
| else if (args[2] !== null && typeof args[2] === "object") Object.assign(options, args[2]); | ||
| return callback ? [options, callback] : [options]; | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/index.ts | ||
| var SocketConnectionEvent = class extends TypedEvent { | ||
| constructor(data) { | ||
| super(...["connection", {}]); | ||
| this.socket = data.socket; | ||
| this.connectionOptions = data.connectionOptions; | ||
| this.controller = data.controller; | ||
| } | ||
| }; | ||
| const logger = createLogger("socket"); | ||
| /** | ||
| * A DNS lookup function for intercepted sockets. It always succeeds, | ||
| * resolving any hostname to the loopback address. This ensures the | ||
| * "lookup"/"connectionAttempt" socket events fire even for non-existent | ||
| * hosts, and no real DNS resolution is performed. Passthrough | ||
| * connections are created with the original options and use the real | ||
| * (or the caller's custom) lookup instead. | ||
| */ | ||
| const mockLookup = (hostname, dnsOptions, callback) => { | ||
| const family = dnsOptions.family === 6 ? 6 : 4; | ||
| const address = family === 6 ? "::1" : "127.0.0.1"; | ||
| /** | ||
| * @note Call back asynchronously since DNS lookup is always | ||
| * asynchronous in Node.js. Calling back synchronously emits | ||
| * the "lookup"/"connectionAttempt" socket events before the | ||
| * consumer gets a chance to add listeners for them. | ||
| */ | ||
| process.nextTick(() => { | ||
| /** | ||
| * @note Honor the Node.js lookup contract: the callback receives | ||
| * an array of addresses only when the "all" option is set | ||
| * (e.g. during the family autoselection). Otherwise, it receives | ||
| * a single address and its family. Node.js rejects an array in | ||
| * the latter case with "ERR_INVALID_IP_ADDRESS". | ||
| */ | ||
| if (dnsOptions.all) { | ||
| callback(null, [{ | ||
| address, | ||
| family | ||
| }]); | ||
| return; | ||
| } | ||
| callback(null, address, family); | ||
| }); | ||
| }; | ||
| /** | ||
| * Interceptor for `net.Socket` connections. | ||
| */ | ||
| var SocketInterceptor = class extends Interceptor { | ||
| static { | ||
| this.symbol = Symbol.for("socket-interceptor"); | ||
| } | ||
| predicate() { | ||
| return true; | ||
| } | ||
| setup() { | ||
| const interceptor = this; | ||
| /** | ||
| * @note A synchronous re-entrancy latch for creating passthrough | ||
| * TLS connections. The original "tls.connect()" constructs a TLS | ||
| * socket and calls "socket.connect()" on it synchronously, and that | ||
| * call must reach Node.js as-is instead of being intercepted again. | ||
| */ | ||
| let isCreatingPassthroughConnection = false; | ||
| this.subscriptions.push( | ||
| /** | ||
| * @note Intercept connections at the "net.Socket.prototype.connect" | ||
| * level instead of patching the "net.connect()" module function. | ||
| * ESM consumers snapshot the module bindings at import time | ||
| * ("import * as net from 'node:net'"), so reassigning "net.connect" | ||
| * is invisible to them. Every client connection ends up calling | ||
| * "Socket.prototype.connect" (including the one made by the original | ||
| * "net.connect()"), and prototype mutations are visible regardless | ||
| * of how the module was imported. | ||
| */ | ||
| patchesRegistry.applyPatch(net.Socket.prototype, "connect", (realSocketConnect) => { | ||
| return function connect(...args) { | ||
| const socket = this; | ||
| /** | ||
| * @note Skip the sockets this interceptor already controls | ||
| * (the mock connect call below, re-connects of an intercepted | ||
| * socket, passthrough sockets) and the sockets created while | ||
| * dialing a passthrough TLS connection. Their connects must | ||
| * reach Node.js as-is. | ||
| */ | ||
| if (socket[kPatched] || isCreatingPassthroughConnection) return realSocketConnect.apply(socket, args); | ||
| logger.verbose("socket.connect() %o", args); | ||
| /** | ||
| * @note The original "net.connect()"/"tls.connect()" normalize | ||
| * the arguments themselves and call this method with the | ||
| * normalized array instead of the individual arguments. | ||
| */ | ||
| const connectArgs = Array.isArray(args[0]) ? args[0] : args; | ||
| const [transportConnectionOptions, connectionCallback] = normalizeNetConnectArgs(connectArgs); | ||
| logger.verbose("connection options %o", { | ||
| transportConnectionOptions, | ||
| connectionCallback | ||
| }); | ||
| let controller; | ||
| let connectionOptions; | ||
| if (socket instanceof tls.TLSSocket) { | ||
| /** | ||
| * @note TLS sockets arrive here from the original | ||
| * "tls.connect()", which connects the transport of the TLS | ||
| * socket it constructs ("connectionCallback" is its internal | ||
| * "_start" handshake trigger). The original TLS connection | ||
| * options are recovered from the socket instance since the | ||
| * construction has already happened. | ||
| */ | ||
| const realTlsConnectionOptions = getTlsConnectOptions(socket); | ||
| const [tlsConnectionOptions] = normalizeTlsConnectArgs([{ | ||
| ...transportConnectionOptions, | ||
| ...realTlsConnectionOptions | ||
| }]); | ||
| connectionOptions = tlsConnectionOptions; | ||
| controller = new TlsSocketController(socket, () => { | ||
| /** | ||
| * @note Create the passthrough connection via the original | ||
| * "tls.connect()" with the original connection options | ||
| * (the real DNS lookup and the caller's certificate | ||
| * validation included). The latch exempts the transport | ||
| * connect of that connection from interception. | ||
| */ | ||
| isCreatingPassthroughConnection = true; | ||
| try { | ||
| return tls.connect(realTlsConnectionOptions ?? tlsConnectionOptions); | ||
| } finally { | ||
| isCreatingPassthroughConnection = false; | ||
| } | ||
| }, tlsConnectionOptions); | ||
| } else { | ||
| /** | ||
| * @note Create passthrough connections without the connection | ||
| * callback. The callback is already registered as a "connect" | ||
| * listener on the consumer's socket. Passing it to the | ||
| * passthrough connection would invoke it twice. | ||
| */ | ||
| const passthroughArgs = connectArgs.filter((arg) => { | ||
| return typeof arg !== "function"; | ||
| }); | ||
| /** | ||
| * @note Create the passthrough socket with the original | ||
| * options, the same way "net.connect()" does. Connect it via | ||
| * the unpatched method so the passthrough connection is not | ||
| * intercepted again. | ||
| */ | ||
| const socketOptions = connectArgs[0] !== null && typeof connectArgs[0] === "object" && !("href" in connectArgs[0]) ? connectArgs[0] : {}; | ||
| connectionOptions = transportConnectionOptions; | ||
| controller = new TcpSocketController(socket, () => { | ||
| const passthroughSocket = new net.Socket(socketOptions); | ||
| Reflect.apply(realSocketConnect, passthroughSocket, passthroughArgs); | ||
| return passthroughSocket; | ||
| }); | ||
| } | ||
| process.nextTick(() => { | ||
| if (socket.destroyed) return; | ||
| /** | ||
| * @note Expect a verdict on this connection from every | ||
| * "connection" listener before emitting the event. With no | ||
| * listeners to claim the connection (or once every listener | ||
| * declines it), the controller passes it through as-is. | ||
| */ | ||
| controller.awaitVerdicts(interceptor.listenerCount("connection")); | ||
| interceptor.emitter.emit(new SocketConnectionEvent({ | ||
| socket: controller.serverSocket, | ||
| controller, | ||
| connectionOptions | ||
| })); | ||
| logger.verbose("emitted \"connection\" event!"); | ||
| }); | ||
| logger.verbose("connecting the socket..."); | ||
| /** | ||
| * @note The requested local address/port are stripped from the | ||
| * actual "socket.connect()" call by the controller to prevent | ||
| * binding the intercepted socket (see the "connect" proxy). | ||
| */ | ||
| const mockConnectionOptions = { ...transportConnectionOptions }; | ||
| mockConnectionOptions.lookup = mockLookup; | ||
| try { | ||
| /** | ||
| * @note The normalized options are looser than the declared | ||
| * "SocketConnectOpts" (e.g. the port may be a string when a | ||
| * URL is passed). Node.js validates them at runtime. | ||
| * This call goes through the controller's "connect" proxy | ||
| * and lands back in this patch, where the "kPatched" check | ||
| * above delegates it to the unpatched method. | ||
| */ | ||
| return socket.connect(mockConnectionOptions, connectionCallback ?? void 0); | ||
| } catch (error) { | ||
| /** | ||
| * @note "socket.connect()" can throw synchronously on invalid | ||
| * input (e.g. a bad port). Destroy the socket so the pending | ||
| * interception tick does not act on it, then let the error | ||
| * propagate to the consumer like in Node.js. | ||
| */ | ||
| socket.destroy(); | ||
| throw error; | ||
| } | ||
| }; | ||
| }), | ||
| this.#stopReusingUnpatchedSockets() | ||
| ); | ||
| /** | ||
| * @note "net.connect()"/"net.createConnection()" need no patching of | ||
| * their own: both construct a "net.Socket" and call the patched | ||
| * "Socket.prototype.connect" on it. | ||
| */ | ||
| } | ||
| /** | ||
| * Prevent the `net.Socket` instances created before this interceptor | ||
| * was applied from getting reused by an `Agent`. Purging them from the | ||
| * keep-alive pool forces the agent to establish new (intercepted) | ||
| * connections instead. | ||
| */ | ||
| #stopReusingUnpatchedSockets() { | ||
| /** | ||
| * @note "Agent.prototype.addRequest" is undocumented but stable: | ||
| * its signature changed once (the keep-alive Agent rewrite in | ||
| * Node.js 0.12), and the ecosystem (agent-base, agentkeepalive, | ||
| * APM wrappers) has relied on it ever since. "https.Agent" | ||
| * inherits it, so a single patch covers both. | ||
| */ | ||
| if (typeof http.Agent.prototype.addRequest !== "function") return () => {}; | ||
| return patchesRegistry.applyPatch(http.Agent.prototype, "addRequest", (realAddRequest) => { | ||
| return function(...args) { | ||
| /** | ||
| * @note Destroy the free sockets created before the interceptor | ||
| * was applied. Destroying flips their "destroyed" state | ||
| * synchronously, so the original "addRequest" below discards | ||
| * them and dials a new (intercepted) connection instead. | ||
| * The "freeSockets" pool only contains idle sockets by | ||
| * definition, so destroying them aborts nothing in-flight. | ||
| */ | ||
| for (const sockets of Object.values(this.freeSockets)) { | ||
| if (sockets == null) continue; | ||
| for (const socket of sockets) if (!socket[kPatched]) socket.destroy(); | ||
| } | ||
| return realAddRequest?.apply(this, args); | ||
| }; | ||
| }); | ||
| } | ||
| }; | ||
| //#endregion | ||
| export { getDeepPropertyDescriptor as a, unwrapPendingData as i, SocketController as n, patchesRegistry as o, kRawSocket as r, SocketInterceptor as t }; | ||
| //# sourceMappingURL=net-Ca8p1rIe.js.map |
Sorry, the diff of this file is too big to display
| import { a as createLogger, i as Interceptor, o as formatRequest, r as toBuffer } from "./buffer-utils-BvPY1Tc-.js"; | ||
| import { a as isResponseError, c as isObject, d as createRequestId, f as RequestController, l as getRawFetchHeaders, n as FetchResponse, o as isResponseLike, p as InterceptorError, r as createServerErrorResponse, s as kErrorResponse, t as FetchRequest, u as recordRawFetchHeaders } from "./fetch-utils-Dw1PsmtX.js"; | ||
| import { i as unwrapPendingData, n as SocketController, r as kRawSocket, t as SocketInterceptor } from "./net-Ca8p1rIe.js"; | ||
| import { TypedEvent } from "rettime"; | ||
| import { invariant } from "outvariant"; | ||
| import { IncomingMessage, METHODS, STATUS_CODES, ServerResponse } from "node:http"; | ||
| import { AsyncLocalStorage } from "node:async_hooks"; | ||
| import net from "node:net"; | ||
| import tls from "node:tls"; | ||
| import { Readable } from "node:stream"; | ||
| import fs from "node:fs"; | ||
| import { until } from "@open-draft/until"; | ||
| //#region \0rolldown/runtime.js | ||
| var __create = Object.create; | ||
| var __defProp = Object.defineProperty; | ||
| var __getOwnPropDesc = Object.getOwnPropertyDescriptor; | ||
| var __getOwnPropNames = Object.getOwnPropertyNames; | ||
| var __getProtoOf = Object.getPrototypeOf; | ||
| var __hasOwnProp = Object.prototype.hasOwnProperty; | ||
| var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports); | ||
| var __copyProps = (to, from, except, desc) => { | ||
| if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { | ||
| key = keys[i]; | ||
| if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { | ||
| get: ((k) => from[k]).bind(null, key), | ||
| enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable | ||
| }); | ||
| } | ||
| return to; | ||
| }; | ||
| var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { | ||
| value: mod, | ||
| enumerable: true | ||
| }) : target, mod)); | ||
| //#endregion | ||
| //#region src/request-context.ts | ||
| const requestContext = new AsyncLocalStorage(); | ||
| function runInRequestContext(callback, logger) { | ||
| /** | ||
| * @note Never shadow an existing request context. Nested calls | ||
| * (e.g. a patched entry point re-entered synchronously, or a request | ||
| * made within the fetch/XMLHttpRequest interceptor context) must run | ||
| * within the parent context so the sockets they create capture it. | ||
| */ | ||
| if (requestContext.getStore()) return callback(); | ||
| /** | ||
| * @note The initiator is the callback's return value (e.g. the | ||
| * "ClientRequest" instance), so it cannot be known before running | ||
| * the callback. The context is mutated in place once the callback | ||
| * returns; readers hold the context object by reference and sample | ||
| * "initiator" only after the request has been written. | ||
| */ | ||
| const context = { | ||
| initiator: void 0, | ||
| logger | ||
| }; | ||
| return requestContext.run(context, () => { | ||
| const initiator = callback(); | ||
| context.initiator = initiator; | ||
| return initiator; | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/http/forward-events.ts | ||
| const logger = createLogger("http-request"); | ||
| function forwardHttpEvents(options) { | ||
| const controller = new AbortController(); | ||
| const { source, emitter, predicate, responsePredicate } = options; | ||
| source.on("request", async (event) => { | ||
| if (predicate(event.initiator)) { | ||
| logger.verbose("forwarding \"request\" event %o", { requestId: event.requestId }); | ||
| await emitter.emitAsPromise(event); | ||
| } | ||
| }, { signal: controller.signal }); | ||
| const responseListener = async (event) => { | ||
| if (predicate(event.initiator) && (responsePredicate == null || responsePredicate(event))) { | ||
| logger.verbose("forwarding \"response\" event %o", { | ||
| requestId: event.requestId, | ||
| responseType: event.responseType | ||
| }); | ||
| await emitter.emitAsPromise(event); | ||
| } | ||
| }; | ||
| const unhandledExceptionListener = async (event) => { | ||
| if (predicate(event.initiator)) { | ||
| logger.verbose("forwarding \"unhandledException\" event %o", { requestId: event.requestId }); | ||
| await emitter.emitAsPromise(event); | ||
| } | ||
| }; | ||
| const addResponseListener = () => { | ||
| if (!source.listeners("response").includes(responseListener)) source.on("response", responseListener, { signal: controller.signal }); | ||
| }; | ||
| const addUnhandledExceptionListener = () => { | ||
| if (!source.listeners("unhandledException").includes(unhandledExceptionListener)) source.on("unhandledException", unhandledExceptionListener, { signal: controller.signal }); | ||
| }; | ||
| if (emitter.listenerCount("response") > 0) addResponseListener(); | ||
| if (emitter.listenerCount("unhandledException") > 0) addUnhandledExceptionListener(); | ||
| emitter.hooks.on("newListener", (type) => { | ||
| if (type === "response") addResponseListener(); | ||
| if (type === "unhandledException") addUnhandledExceptionListener(); | ||
| }, { | ||
| signal: controller.signal, | ||
| persist: true | ||
| }); | ||
| emitter.hooks.on("removeListener", (type) => { | ||
| if (type === "response" && emitter.listenerCount("response") === 0) source.removeListener("response", responseListener); | ||
| if (type === "unhandledException" && emitter.listenerCount("unhandledException") === 0) source.removeListener("unhandledException", unhandledExceptionListener); | ||
| }, { | ||
| signal: controller.signal, | ||
| persist: true | ||
| }); | ||
| return () => { | ||
| controller.abort(); | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/events/http.ts | ||
| var HttpRequestEvent = class extends TypedEvent { | ||
| constructor(data) { | ||
| super(...["request", {}]); | ||
| this.request = data.request; | ||
| this.requestId = data.requestId; | ||
| this.initiator = data.initiator; | ||
| this.controller = data.controller; | ||
| } | ||
| }; | ||
| var HttpResponseEvent = class extends TypedEvent { | ||
| constructor(data) { | ||
| super(...["response", {}]); | ||
| this.response = data.response; | ||
| this.responseType = data.responseType; | ||
| this.request = data.request; | ||
| this.requestId = data.requestId; | ||
| this.initiator = data.initiator; | ||
| } | ||
| }; | ||
| var UnhandledHttpException = class extends TypedEvent { | ||
| constructor(data) { | ||
| super(...["unhandledException", {}]); | ||
| this.error = data.error; | ||
| this.request = data.request; | ||
| this.requestId = data.requestId; | ||
| this.initiator = data.initiator; | ||
| this.controller = data.controller; | ||
| } | ||
| }; | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/connection-options-to-url.ts | ||
| /** | ||
| * Creates a `URL` instance out of the `net.connect()` options. | ||
| * @note This implies that the passed connection is an HTTP connection. | ||
| */ | ||
| function connectionOptionsToUrl(options, socket) { | ||
| const isIPv6 = net.isIPv6(options.host || ""); | ||
| const protocol = socket instanceof tls.TLSSocket ? "https:" : getProtocolByConnectionOptions(options); | ||
| const host = options.host || "localhost"; | ||
| const url = new URL(`${protocol}//${isIPv6 ? `[${host}]` : host}`); | ||
| if (options.path) url.pathname = options.path; | ||
| if (options.port) url.port = options.port.toString(); | ||
| if (options.auth) { | ||
| const [username, password] = options.auth.split(":"); | ||
| /** | ||
| * Authentication options are provided as plain values. | ||
| * Encode them to form a valid URL. | ||
| * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/url.js#L1452 | ||
| */ | ||
| url.username = encodeURIComponent(username); | ||
| url.password = encodeURIComponent(password); | ||
| } | ||
| return url; | ||
| } | ||
| function getProtocolByConnectionOptions(options) { | ||
| if (options.protocol) return options.protocol; | ||
| if (options.port === 443) return "https:"; | ||
| return "http:"; | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/http/http-parser/index.ts | ||
| var import_constants = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports) => { | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.SPECIAL_HEADERS = exports.MINOR = exports.MAJOR = exports.QDTEXT = exports.CONNECTION_TOKEN_CHARS = exports.RELAXED_HEADER_CHARS = exports.HEADER_CHARS = exports.HTAB_SP_VCHAR_OBS_TEXT = exports.SP = exports.HTAB = exports.TOKEN = exports.HEX = exports.URL_CHAR = exports.USERINFO_CHARS = exports.MARK = exports.ALPHANUM = exports.DIGIT = exports.HEX_MAP = exports.NUM_MAP = exports.ALPHA = exports.METHODS = exports.METHODS_HTTP = exports.METHODS_HTTP2 = exports.METHODS_HTTP1 = exports.METHODS_RTSP = exports.METHODS_RAOP = exports.METHODS_AIRPLAY = exports.METHODS_ICECAST = exports.METHODS_NON_STANDARD = exports.METHODS_CALDAV = exports.METHODS_UPNP = exports.METHODS_SUBVERSION = exports.METHODS_WEBDAV = exports.METHODS_BASIC_HTTP = exports.METHODS_HTTP1_HEAD = exports.HEADER_STATE = exports.FINISH = exports.STATUSES = exports.LENIENT_FLAGS = exports.FLAGS = exports.TYPE = exports.ERROR = void 0; | ||
| exports.ERROR = { | ||
| OK: 0, | ||
| INTERNAL: 1, | ||
| STRICT: 2, | ||
| CR_EXPECTED: 25, | ||
| LF_EXPECTED: 3, | ||
| UNEXPECTED_CONTENT_LENGTH: 4, | ||
| UNEXPECTED_SPACE: 30, | ||
| CLOSED_CONNECTION: 5, | ||
| INVALID_METHOD: 6, | ||
| INVALID_URL: 7, | ||
| INVALID_CONSTANT: 8, | ||
| INVALID_VERSION: 9, | ||
| INVALID_HEADER_TOKEN: 10, | ||
| INVALID_CONTENT_LENGTH: 11, | ||
| INVALID_CHUNK_SIZE: 12, | ||
| INVALID_STATUS: 13, | ||
| INVALID_EOF_STATE: 14, | ||
| INVALID_TRANSFER_ENCODING: 15, | ||
| CB_MESSAGE_BEGIN: 16, | ||
| CB_HEADERS_COMPLETE: 17, | ||
| CB_MESSAGE_COMPLETE: 18, | ||
| CB_CHUNK_HEADER: 19, | ||
| CB_CHUNK_COMPLETE: 20, | ||
| PAUSED: 21, | ||
| PAUSED_UPGRADE: 22, | ||
| PAUSED_H2_UPGRADE: 23, | ||
| USER: 24, | ||
| CB_URL_COMPLETE: 26, | ||
| CB_STATUS_COMPLETE: 27, | ||
| CB_METHOD_COMPLETE: 32, | ||
| CB_VERSION_COMPLETE: 33, | ||
| CB_HEADER_FIELD_COMPLETE: 28, | ||
| CB_HEADER_VALUE_COMPLETE: 29, | ||
| CB_CHUNK_EXTENSION_NAME_COMPLETE: 34, | ||
| CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35, | ||
| CB_RESET: 31, | ||
| CB_PROTOCOL_COMPLETE: 38 | ||
| }; | ||
| exports.TYPE = { | ||
| BOTH: 0, | ||
| REQUEST: 1, | ||
| RESPONSE: 2 | ||
| }; | ||
| exports.FLAGS = { | ||
| CONNECTION_KEEP_ALIVE: 1, | ||
| CONNECTION_CLOSE: 2, | ||
| CONNECTION_UPGRADE: 4, | ||
| CHUNKED: 8, | ||
| UPGRADE: 16, | ||
| CONTENT_LENGTH: 32, | ||
| SKIPBODY: 64, | ||
| TRAILING: 128, | ||
| TRANSFER_ENCODING: 512 | ||
| }; | ||
| exports.LENIENT_FLAGS = { | ||
| HEADERS: 1, | ||
| CHUNKED_LENGTH: 2, | ||
| KEEP_ALIVE: 4, | ||
| TRANSFER_ENCODING: 8, | ||
| VERSION: 16, | ||
| DATA_AFTER_CLOSE: 32, | ||
| OPTIONAL_LF_AFTER_CR: 64, | ||
| OPTIONAL_CRLF_AFTER_CHUNK: 128, | ||
| OPTIONAL_CR_BEFORE_LF: 256, | ||
| SPACES_AFTER_CHUNK_SIZE: 512, | ||
| HEADER_VALUE_RELAXED: 1024 | ||
| }; | ||
| exports.STATUSES = { | ||
| CONTINUE: 100, | ||
| SWITCHING_PROTOCOLS: 101, | ||
| PROCESSING: 102, | ||
| EARLY_HINTS: 103, | ||
| RESPONSE_IS_STALE: 110, | ||
| REVALIDATION_FAILED: 111, | ||
| DISCONNECTED_OPERATION: 112, | ||
| HEURISTIC_EXPIRATION: 113, | ||
| MISCELLANEOUS_WARNING: 199, | ||
| OK: 200, | ||
| CREATED: 201, | ||
| ACCEPTED: 202, | ||
| NON_AUTHORITATIVE_INFORMATION: 203, | ||
| NO_CONTENT: 204, | ||
| RESET_CONTENT: 205, | ||
| PARTIAL_CONTENT: 206, | ||
| MULTI_STATUS: 207, | ||
| ALREADY_REPORTED: 208, | ||
| TRANSFORMATION_APPLIED: 214, | ||
| IM_USED: 226, | ||
| MISCELLANEOUS_PERSISTENT_WARNING: 299, | ||
| MULTIPLE_CHOICES: 300, | ||
| MOVED_PERMANENTLY: 301, | ||
| FOUND: 302, | ||
| SEE_OTHER: 303, | ||
| NOT_MODIFIED: 304, | ||
| USE_PROXY: 305, | ||
| SWITCH_PROXY: 306, | ||
| TEMPORARY_REDIRECT: 307, | ||
| PERMANENT_REDIRECT: 308, | ||
| BAD_REQUEST: 400, | ||
| UNAUTHORIZED: 401, | ||
| PAYMENT_REQUIRED: 402, | ||
| FORBIDDEN: 403, | ||
| NOT_FOUND: 404, | ||
| METHOD_NOT_ALLOWED: 405, | ||
| NOT_ACCEPTABLE: 406, | ||
| PROXY_AUTHENTICATION_REQUIRED: 407, | ||
| REQUEST_TIMEOUT: 408, | ||
| CONFLICT: 409, | ||
| GONE: 410, | ||
| LENGTH_REQUIRED: 411, | ||
| PRECONDITION_FAILED: 412, | ||
| PAYLOAD_TOO_LARGE: 413, | ||
| URI_TOO_LONG: 414, | ||
| UNSUPPORTED_MEDIA_TYPE: 415, | ||
| RANGE_NOT_SATISFIABLE: 416, | ||
| EXPECTATION_FAILED: 417, | ||
| IM_A_TEAPOT: 418, | ||
| PAGE_EXPIRED: 419, | ||
| ENHANCE_YOUR_CALM: 420, | ||
| MISDIRECTED_REQUEST: 421, | ||
| UNPROCESSABLE_ENTITY: 422, | ||
| LOCKED: 423, | ||
| FAILED_DEPENDENCY: 424, | ||
| TOO_EARLY: 425, | ||
| UPGRADE_REQUIRED: 426, | ||
| PRECONDITION_REQUIRED: 428, | ||
| TOO_MANY_REQUESTS: 429, | ||
| REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL: 430, | ||
| REQUEST_HEADER_FIELDS_TOO_LARGE: 431, | ||
| LOGIN_TIMEOUT: 440, | ||
| NO_RESPONSE: 444, | ||
| RETRY_WITH: 449, | ||
| BLOCKED_BY_PARENTAL_CONTROL: 450, | ||
| UNAVAILABLE_FOR_LEGAL_REASONS: 451, | ||
| CLIENT_CLOSED_LOAD_BALANCED_REQUEST: 460, | ||
| INVALID_X_FORWARDED_FOR: 463, | ||
| REQUEST_HEADER_TOO_LARGE: 494, | ||
| SSL_CERTIFICATE_ERROR: 495, | ||
| SSL_CERTIFICATE_REQUIRED: 496, | ||
| HTTP_REQUEST_SENT_TO_HTTPS_PORT: 497, | ||
| INVALID_TOKEN: 498, | ||
| CLIENT_CLOSED_REQUEST: 499, | ||
| INTERNAL_SERVER_ERROR: 500, | ||
| NOT_IMPLEMENTED: 501, | ||
| BAD_GATEWAY: 502, | ||
| SERVICE_UNAVAILABLE: 503, | ||
| GATEWAY_TIMEOUT: 504, | ||
| HTTP_VERSION_NOT_SUPPORTED: 505, | ||
| VARIANT_ALSO_NEGOTIATES: 506, | ||
| INSUFFICIENT_STORAGE: 507, | ||
| LOOP_DETECTED: 508, | ||
| BANDWIDTH_LIMIT_EXCEEDED: 509, | ||
| NOT_EXTENDED: 510, | ||
| NETWORK_AUTHENTICATION_REQUIRED: 511, | ||
| WEB_SERVER_UNKNOWN_ERROR: 520, | ||
| WEB_SERVER_IS_DOWN: 521, | ||
| CONNECTION_TIMEOUT: 522, | ||
| ORIGIN_IS_UNREACHABLE: 523, | ||
| TIMEOUT_OCCURED: 524, | ||
| SSL_HANDSHAKE_FAILED: 525, | ||
| INVALID_SSL_CERTIFICATE: 526, | ||
| RAILGUN_ERROR: 527, | ||
| SITE_IS_OVERLOADED: 529, | ||
| SITE_IS_FROZEN: 530, | ||
| IDENTITY_PROVIDER_AUTHENTICATION_ERROR: 561, | ||
| NETWORK_READ_TIMEOUT: 598, | ||
| NETWORK_CONNECT_TIMEOUT: 599 | ||
| }; | ||
| exports.FINISH = { | ||
| SAFE: 0, | ||
| SAFE_WITH_CB: 1, | ||
| UNSAFE: 2 | ||
| }; | ||
| exports.HEADER_STATE = { | ||
| GENERAL: 0, | ||
| CONNECTION: 1, | ||
| CONTENT_LENGTH: 2, | ||
| TRANSFER_ENCODING: 3, | ||
| UPGRADE: 4, | ||
| CONNECTION_KEEP_ALIVE: 5, | ||
| CONNECTION_CLOSE: 6, | ||
| CONNECTION_UPGRADE: 7, | ||
| TRANSFER_ENCODING_CHUNKED: 8 | ||
| }; | ||
| exports.METHODS_HTTP1_HEAD = { HEAD: 2 }; | ||
| /** | ||
| * HTTP methods as defined by RFC-9110 and other specifications. | ||
| * @see https://httpwg.org/specs/rfc9110.html#method.definitions | ||
| */ | ||
| exports.METHODS_BASIC_HTTP = { | ||
| DELETE: 0, | ||
| GET: 1, | ||
| ...exports.METHODS_HTTP1_HEAD, | ||
| POST: 3, | ||
| PUT: 4, | ||
| CONNECT: 5, | ||
| OPTIONS: 6, | ||
| TRACE: 7, | ||
| /** | ||
| * @see https://www.rfc-editor.org/rfc/rfc5789.html | ||
| */ | ||
| PATCH: 28, | ||
| LINK: 31, | ||
| UNLINK: 32 | ||
| }; | ||
| exports.METHODS_WEBDAV = { | ||
| COPY: 8, | ||
| LOCK: 9, | ||
| MKCOL: 10, | ||
| MOVE: 11, | ||
| PROPFIND: 12, | ||
| PROPPATCH: 13, | ||
| SEARCH: 14, | ||
| UNLOCK: 15, | ||
| BIND: 16, | ||
| REBIND: 17, | ||
| UNBIND: 18, | ||
| ACL: 19 | ||
| }; | ||
| exports.METHODS_SUBVERSION = { | ||
| REPORT: 20, | ||
| MKACTIVITY: 21, | ||
| CHECKOUT: 22, | ||
| MERGE: 23 | ||
| }; | ||
| exports.METHODS_UPNP = { | ||
| "M-SEARCH": 24, | ||
| NOTIFY: 25, | ||
| SUBSCRIBE: 26, | ||
| UNSUBSCRIBE: 27 | ||
| }; | ||
| exports.METHODS_CALDAV = { MKCALENDAR: 30 }; | ||
| exports.METHODS_NON_STANDARD = { | ||
| /** | ||
| * Not defined in any RFC but commonly used | ||
| */ | ||
| PURGE: 29, | ||
| QUERY: 46 | ||
| }; | ||
| exports.METHODS_ICECAST = { SOURCE: 33 }; | ||
| exports.METHODS_AIRPLAY = { | ||
| GET: 1, | ||
| POST: 3 | ||
| }; | ||
| exports.METHODS_RAOP = { FLUSH: 45 }; | ||
| exports.METHODS_RTSP = { | ||
| OPTIONS: exports.METHODS_BASIC_HTTP.OPTIONS, | ||
| DESCRIBE: 35, | ||
| ANNOUNCE: 36, | ||
| SETUP: 37, | ||
| PLAY: 38, | ||
| PAUSE: 39, | ||
| TEARDOWN: 40, | ||
| GET_PARAMETER: 41, | ||
| SET_PARAMETER: 42, | ||
| REDIRECT: 43, | ||
| RECORD: 44, | ||
| ...exports.METHODS_AIRPLAY, | ||
| ...exports.METHODS_RAOP | ||
| }; | ||
| exports.METHODS_HTTP1 = { | ||
| ...exports.METHODS_BASIC_HTTP, | ||
| ...exports.METHODS_WEBDAV, | ||
| ...exports.METHODS_SUBVERSION, | ||
| ...exports.METHODS_UPNP, | ||
| ...exports.METHODS_CALDAV, | ||
| ...exports.METHODS_NON_STANDARD, | ||
| ...exports.METHODS_ICECAST | ||
| }; | ||
| exports.METHODS_HTTP2 = { | ||
| /** | ||
| * RFC-9113, section 11.6 | ||
| * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface | ||
| */ | ||
| PRI: 34 }; | ||
| exports.METHODS_HTTP = { | ||
| ...exports.METHODS_HTTP1, | ||
| ...exports.METHODS_HTTP2 | ||
| }; | ||
| exports.METHODS = { | ||
| ...exports.METHODS_HTTP1, | ||
| ...exports.METHODS_HTTP2, | ||
| ...exports.METHODS_RTSP | ||
| }; | ||
| exports.ALPHA = [ | ||
| "A", | ||
| "a", | ||
| "B", | ||
| "b", | ||
| "C", | ||
| "c", | ||
| "D", | ||
| "d", | ||
| "E", | ||
| "e", | ||
| "F", | ||
| "f", | ||
| "G", | ||
| "g", | ||
| "H", | ||
| "h", | ||
| "I", | ||
| "i", | ||
| "J", | ||
| "j", | ||
| "K", | ||
| "k", | ||
| "L", | ||
| "l", | ||
| "M", | ||
| "m", | ||
| "N", | ||
| "n", | ||
| "O", | ||
| "o", | ||
| "P", | ||
| "p", | ||
| "Q", | ||
| "q", | ||
| "R", | ||
| "r", | ||
| "S", | ||
| "s", | ||
| "T", | ||
| "t", | ||
| "U", | ||
| "u", | ||
| "V", | ||
| "v", | ||
| "W", | ||
| "w", | ||
| "X", | ||
| "x", | ||
| "Y", | ||
| "y", | ||
| "Z", | ||
| "z" | ||
| ]; | ||
| exports.NUM_MAP = { | ||
| 0: 0, | ||
| 1: 1, | ||
| 2: 2, | ||
| 3: 3, | ||
| 4: 4, | ||
| 5: 5, | ||
| 6: 6, | ||
| 7: 7, | ||
| 8: 8, | ||
| 9: 9 | ||
| }; | ||
| exports.HEX_MAP = { | ||
| 0: 0, | ||
| 1: 1, | ||
| 2: 2, | ||
| 3: 3, | ||
| 4: 4, | ||
| 5: 5, | ||
| 6: 6, | ||
| 7: 7, | ||
| 8: 8, | ||
| 9: 9, | ||
| A: 10, | ||
| B: 11, | ||
| C: 12, | ||
| D: 13, | ||
| E: 14, | ||
| F: 15, | ||
| a: 10, | ||
| b: 11, | ||
| c: 12, | ||
| d: 13, | ||
| e: 14, | ||
| f: 15 | ||
| }; | ||
| exports.DIGIT = [ | ||
| "0", | ||
| "1", | ||
| "2", | ||
| "3", | ||
| "4", | ||
| "5", | ||
| "6", | ||
| "7", | ||
| "8", | ||
| "9" | ||
| ]; | ||
| exports.ALPHANUM = [...exports.ALPHA, ...exports.DIGIT]; | ||
| exports.MARK = [ | ||
| "-", | ||
| "_", | ||
| ".", | ||
| "!", | ||
| "~", | ||
| "*", | ||
| "'", | ||
| "(", | ||
| ")" | ||
| ]; | ||
| exports.USERINFO_CHARS = [ | ||
| ...exports.ALPHANUM, | ||
| ...exports.MARK, | ||
| "%", | ||
| ";", | ||
| ":", | ||
| "&", | ||
| "=", | ||
| "+", | ||
| "$", | ||
| "," | ||
| ]; | ||
| exports.URL_CHAR = [ | ||
| "!", | ||
| "\"", | ||
| "$", | ||
| "%", | ||
| "&", | ||
| "'", | ||
| "(", | ||
| ")", | ||
| "*", | ||
| "+", | ||
| ",", | ||
| "-", | ||
| ".", | ||
| "/", | ||
| ":", | ||
| ";", | ||
| "<", | ||
| "=", | ||
| ">", | ||
| "@", | ||
| "[", | ||
| "\\", | ||
| "]", | ||
| "^", | ||
| "_", | ||
| "`", | ||
| "{", | ||
| "|", | ||
| "}", | ||
| "~", | ||
| ...exports.ALPHANUM | ||
| ]; | ||
| exports.HEX = [ | ||
| ...exports.DIGIT, | ||
| "a", | ||
| "b", | ||
| "c", | ||
| "d", | ||
| "e", | ||
| "f", | ||
| "A", | ||
| "B", | ||
| "C", | ||
| "D", | ||
| "E", | ||
| "F" | ||
| ]; | ||
| exports.TOKEN = [ | ||
| "!", | ||
| "#", | ||
| "$", | ||
| "%", | ||
| "&", | ||
| "'", | ||
| "*", | ||
| "+", | ||
| "-", | ||
| ".", | ||
| "^", | ||
| "_", | ||
| "`", | ||
| "|", | ||
| "~", | ||
| ...exports.ALPHANUM | ||
| ]; | ||
| exports.HTAB = [" "]; | ||
| exports.SP = [" "]; | ||
| const VCHAR = [ | ||
| 33, | ||
| 34, | ||
| 35, | ||
| 36, | ||
| 37, | ||
| 38, | ||
| 39, | ||
| 40, | ||
| 41, | ||
| 42, | ||
| 43, | ||
| 44, | ||
| 45, | ||
| 46, | ||
| 47, | ||
| 48, | ||
| 49, | ||
| 50, | ||
| 51, | ||
| 52, | ||
| 53, | ||
| 54, | ||
| 55, | ||
| 56, | ||
| 57, | ||
| 58, | ||
| 59, | ||
| 60, | ||
| 61, | ||
| 62, | ||
| 63, | ||
| 64, | ||
| 65, | ||
| 66, | ||
| 67, | ||
| 68, | ||
| 69, | ||
| 70, | ||
| 71, | ||
| 72, | ||
| 73, | ||
| 74, | ||
| 75, | ||
| 76, | ||
| 77, | ||
| 78, | ||
| 79, | ||
| 80, | ||
| 81, | ||
| 82, | ||
| 83, | ||
| 84, | ||
| 85, | ||
| 86, | ||
| 87, | ||
| 88, | ||
| 89, | ||
| 90, | ||
| 91, | ||
| 92, | ||
| 93, | ||
| 94, | ||
| 95, | ||
| 96, | ||
| 97, | ||
| 98, | ||
| 99, | ||
| 100, | ||
| 101, | ||
| 102, | ||
| 103, | ||
| 104, | ||
| 105, | ||
| 106, | ||
| 107, | ||
| 108, | ||
| 109, | ||
| 110, | ||
| 111, | ||
| 112, | ||
| 113, | ||
| 114, | ||
| 115, | ||
| 116, | ||
| 117, | ||
| 118, | ||
| 119, | ||
| 120, | ||
| 121, | ||
| 122, | ||
| 123, | ||
| 124, | ||
| 125, | ||
| 126 | ||
| ]; | ||
| const OBS_TEXT = [ | ||
| 128, | ||
| 129, | ||
| 130, | ||
| 131, | ||
| 132, | ||
| 133, | ||
| 134, | ||
| 135, | ||
| 136, | ||
| 137, | ||
| 138, | ||
| 139, | ||
| 140, | ||
| 141, | ||
| 142, | ||
| 143, | ||
| 144, | ||
| 145, | ||
| 146, | ||
| 147, | ||
| 148, | ||
| 149, | ||
| 150, | ||
| 151, | ||
| 152, | ||
| 153, | ||
| 154, | ||
| 155, | ||
| 156, | ||
| 157, | ||
| 158, | ||
| 159, | ||
| 160, | ||
| 161, | ||
| 162, | ||
| 163, | ||
| 164, | ||
| 165, | ||
| 166, | ||
| 167, | ||
| 168, | ||
| 169, | ||
| 170, | ||
| 171, | ||
| 172, | ||
| 173, | ||
| 174, | ||
| 175, | ||
| 176, | ||
| 177, | ||
| 178, | ||
| 179, | ||
| 180, | ||
| 181, | ||
| 182, | ||
| 183, | ||
| 184, | ||
| 185, | ||
| 186, | ||
| 187, | ||
| 188, | ||
| 189, | ||
| 190, | ||
| 191, | ||
| 192, | ||
| 193, | ||
| 194, | ||
| 195, | ||
| 196, | ||
| 197, | ||
| 198, | ||
| 199, | ||
| 200, | ||
| 201, | ||
| 202, | ||
| 203, | ||
| 204, | ||
| 205, | ||
| 206, | ||
| 207, | ||
| 208, | ||
| 209, | ||
| 210, | ||
| 211, | ||
| 212, | ||
| 213, | ||
| 214, | ||
| 215, | ||
| 216, | ||
| 217, | ||
| 218, | ||
| 219, | ||
| 220, | ||
| 221, | ||
| 222, | ||
| 223, | ||
| 224, | ||
| 225, | ||
| 226, | ||
| 227, | ||
| 228, | ||
| 229, | ||
| 230, | ||
| 231, | ||
| 232, | ||
| 233, | ||
| 234, | ||
| 235, | ||
| 236, | ||
| 237, | ||
| 238, | ||
| 239, | ||
| 240, | ||
| 241, | ||
| 242, | ||
| 243, | ||
| 244, | ||
| 245, | ||
| 246, | ||
| 247, | ||
| 248, | ||
| 249, | ||
| 250, | ||
| 251, | ||
| 252, | ||
| 253, | ||
| 254, | ||
| 255 | ||
| ]; | ||
| exports.HTAB_SP_VCHAR_OBS_TEXT = [ | ||
| ...exports.HTAB, | ||
| ...exports.SP, | ||
| ...VCHAR, | ||
| ...OBS_TEXT | ||
| ]; | ||
| exports.HEADER_CHARS = exports.HTAB_SP_VCHAR_OBS_TEXT; | ||
| exports.RELAXED_HEADER_CHARS = [...[ | ||
| 1, | ||
| 2, | ||
| 3, | ||
| 4, | ||
| 5, | ||
| 6, | ||
| 7, | ||
| 8, | ||
| 11, | ||
| 12, | ||
| 14, | ||
| 15, | ||
| 16, | ||
| 17, | ||
| 18, | ||
| 19, | ||
| 20, | ||
| 21, | ||
| 22, | ||
| 23, | ||
| 24, | ||
| 25, | ||
| 26, | ||
| 27, | ||
| 28, | ||
| 29, | ||
| 30, | ||
| 31, | ||
| 127 | ||
| ], ...exports.HEADER_CHARS]; | ||
| exports.CONNECTION_TOKEN_CHARS = [ | ||
| ...exports.HTAB, | ||
| ...exports.SP, | ||
| 33, | ||
| 34, | ||
| 35, | ||
| 36, | ||
| 37, | ||
| 38, | ||
| 39, | ||
| 40, | ||
| 41, | ||
| 42, | ||
| 43, | ||
| 45, | ||
| 46, | ||
| 47, | ||
| 48, | ||
| 49, | ||
| 50, | ||
| 51, | ||
| 52, | ||
| 53, | ||
| 54, | ||
| 55, | ||
| 56, | ||
| 57, | ||
| 58, | ||
| 59, | ||
| 60, | ||
| 61, | ||
| 62, | ||
| 63, | ||
| 64, | ||
| 65, | ||
| 66, | ||
| 67, | ||
| 68, | ||
| 69, | ||
| 70, | ||
| 71, | ||
| 72, | ||
| 73, | ||
| 74, | ||
| 75, | ||
| 76, | ||
| 77, | ||
| 78, | ||
| 79, | ||
| 80, | ||
| 81, | ||
| 82, | ||
| 83, | ||
| 84, | ||
| 85, | ||
| 86, | ||
| 87, | ||
| 88, | ||
| 89, | ||
| 90, | ||
| 91, | ||
| 92, | ||
| 93, | ||
| 94, | ||
| 95, | ||
| 96, | ||
| 97, | ||
| 98, | ||
| 99, | ||
| 100, | ||
| 101, | ||
| 102, | ||
| 103, | ||
| 104, | ||
| 105, | ||
| 106, | ||
| 107, | ||
| 108, | ||
| 109, | ||
| 110, | ||
| 111, | ||
| 112, | ||
| 113, | ||
| 114, | ||
| 115, | ||
| 116, | ||
| 117, | ||
| 118, | ||
| 119, | ||
| 120, | ||
| 121, | ||
| 122, | ||
| 123, | ||
| 124, | ||
| 125, | ||
| 126, | ||
| ...OBS_TEXT | ||
| ]; | ||
| exports.QDTEXT = [ | ||
| ...exports.HTAB, | ||
| ...exports.SP, | ||
| 33, | ||
| 35, | ||
| 36, | ||
| 37, | ||
| 38, | ||
| 39, | ||
| 40, | ||
| 41, | ||
| 42, | ||
| 43, | ||
| 44, | ||
| 45, | ||
| 46, | ||
| 47, | ||
| 48, | ||
| 49, | ||
| 50, | ||
| 51, | ||
| 52, | ||
| 53, | ||
| 54, | ||
| 55, | ||
| 56, | ||
| 57, | ||
| 58, | ||
| 59, | ||
| 60, | ||
| 61, | ||
| 62, | ||
| 63, | ||
| 64, | ||
| 65, | ||
| 66, | ||
| 67, | ||
| 68, | ||
| 69, | ||
| 70, | ||
| 71, | ||
| 72, | ||
| 73, | ||
| 74, | ||
| 75, | ||
| 76, | ||
| 77, | ||
| 78, | ||
| 79, | ||
| 80, | ||
| 81, | ||
| 82, | ||
| 83, | ||
| 84, | ||
| 85, | ||
| 86, | ||
| 87, | ||
| 88, | ||
| 89, | ||
| 90, | ||
| 91, | ||
| 93, | ||
| 94, | ||
| 95, | ||
| 96, | ||
| 97, | ||
| 98, | ||
| 99, | ||
| 100, | ||
| 101, | ||
| 102, | ||
| 103, | ||
| 104, | ||
| 105, | ||
| 106, | ||
| 107, | ||
| 108, | ||
| 109, | ||
| 110, | ||
| 111, | ||
| 112, | ||
| 113, | ||
| 114, | ||
| 115, | ||
| 116, | ||
| 117, | ||
| 118, | ||
| 119, | ||
| 120, | ||
| 121, | ||
| 122, | ||
| 123, | ||
| 124, | ||
| 125, | ||
| 126, | ||
| ...OBS_TEXT | ||
| ]; | ||
| exports.MAJOR = exports.NUM_MAP; | ||
| exports.MINOR = exports.MAJOR; | ||
| exports.SPECIAL_HEADERS = { | ||
| "connection": exports.HEADER_STATE.CONNECTION, | ||
| "content-length": exports.HEADER_STATE.CONTENT_LENGTH, | ||
| "proxy-connection": exports.HEADER_STATE.CONNECTION, | ||
| "transfer-encoding": exports.HEADER_STATE.TRANSFER_ENCODING, | ||
| "upgrade": exports.HEADER_STATE.UPGRADE | ||
| }; | ||
| exports.default = { | ||
| ERROR: exports.ERROR, | ||
| TYPE: exports.TYPE, | ||
| FLAGS: exports.FLAGS, | ||
| LENIENT_FLAGS: exports.LENIENT_FLAGS, | ||
| STATUSES: exports.STATUSES, | ||
| FINISH: exports.FINISH, | ||
| HEADER_STATE: exports.HEADER_STATE, | ||
| ALPHA: exports.ALPHA, | ||
| NUM_MAP: exports.NUM_MAP, | ||
| HEX_MAP: exports.HEX_MAP, | ||
| DIGIT: exports.DIGIT, | ||
| ALPHANUM: exports.ALPHANUM, | ||
| MARK: exports.MARK, | ||
| USERINFO_CHARS: exports.USERINFO_CHARS, | ||
| URL_CHAR: exports.URL_CHAR, | ||
| HEX: exports.HEX, | ||
| TOKEN: exports.TOKEN, | ||
| HEADER_CHARS: exports.HEADER_CHARS, | ||
| RELAXED_HEADER_CHARS: exports.RELAXED_HEADER_CHARS, | ||
| CONNECTION_TOKEN_CHARS: exports.CONNECTION_TOKEN_CHARS, | ||
| QDTEXT: exports.QDTEXT, | ||
| HTAB_SP_VCHAR_OBS_TEXT: exports.HTAB_SP_VCHAR_OBS_TEXT, | ||
| MAJOR: exports.MAJOR, | ||
| MINOR: exports.MINOR, | ||
| SPECIAL_HEADERS: exports.SPECIAL_HEADERS, | ||
| METHODS: exports.METHODS, | ||
| METHODS_HTTP: exports.METHODS_HTTP, | ||
| METHODS_HTTP1_HEAD: exports.METHODS_HTTP1_HEAD, | ||
| METHODS_HTTP1: exports.METHODS_HTTP1, | ||
| METHODS_HTTP2: exports.METHODS_HTTP2, | ||
| METHODS_ICECAST: exports.METHODS_ICECAST, | ||
| METHODS_RTSP: exports.METHODS_RTSP | ||
| }; | ||
| })))(), 1); | ||
| const KIND_REQUEST = import_constants.TYPE.REQUEST; | ||
| import_constants.TYPE.RESPONSE; | ||
| const kPointer = Symbol("kPtr"); | ||
| const kUrl = Symbol("kUrl"); | ||
| const kStatusMessage = Symbol("kStatusMessage"); | ||
| const kHeadersFields = Symbol("kHeadersFields"); | ||
| const kHeadersValues = Symbol("kHeadersValues"); | ||
| const kLastHeaderCallback = Symbol("kLastHeaderCallback"); | ||
| const kCallbacks = Symbol("kCallbacks"); | ||
| const kType = Symbol("kType"); | ||
| const HEADER_CB_NONE = 0; | ||
| const HEADER_CB_FIELD = 1; | ||
| const HEADER_CB_VALUE = 2; | ||
| const parsersMap = /* @__PURE__ */ new Map(); | ||
| const methodNames = Object.fromEntries(Object.entries(import_constants.METHODS).map(([name, num]) => [num, name])); | ||
| function readStringFrom(pointer, length) { | ||
| return Buffer.from(llhttp_memory.buffer, pointer, length).toString("latin1"); | ||
| } | ||
| /** | ||
| * @note Reference the base URL through a variable. Bundlers (e.g. Vite) | ||
| * statically rewrite the `new URL('...', import.meta.url)` pattern into | ||
| * an asset URL resolved against the served origin, which breaks reading | ||
| * the WASM binary from the file system in DOM-like test environments. | ||
| */ | ||
| const wasmBaseUrl = import.meta.url; | ||
| const llhttpModule = new WebAssembly.Module(fs.readFileSync(new URL("./llhttp/llhttp.wasm", wasmBaseUrl))); | ||
| const llhttpInstance = new WebAssembly.Instance(llhttpModule, { env: { | ||
| wasm_on_message_begin(parserPointer) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| parser[kUrl] = ""; | ||
| parser[kStatusMessage] = ""; | ||
| parser[kHeadersFields] = []; | ||
| parser[kHeadersValues] = []; | ||
| parser[kLastHeaderCallback] = HEADER_CB_NONE; | ||
| return parser[kCallbacks].onMessageBegin?.() ?? 0; | ||
| }, | ||
| wasm_on_url(parserPointer, at, length) { | ||
| parsersMap.get(parserPointer)[kUrl] = readStringFrom(at, length); | ||
| return 0; | ||
| }, | ||
| wasm_on_status(parserPointer, at, length) { | ||
| parsersMap.get(parserPointer)[kStatusMessage] = readStringFrom(at, length); | ||
| return 0; | ||
| }, | ||
| wasm_on_header_field(parserPointer, at, length) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| const chunk = readStringFrom(at, length); | ||
| const fields = parser[kHeadersFields]; | ||
| if (parser[kLastHeaderCallback] === HEADER_CB_FIELD) fields[fields.length - 1] += chunk; | ||
| else { | ||
| fields.push(chunk); | ||
| parser[kLastHeaderCallback] = HEADER_CB_FIELD; | ||
| } | ||
| return 0; | ||
| }, | ||
| wasm_on_header_value(parserPointer, at, length) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| const chunk = readStringFrom(at, length); | ||
| const values = parser[kHeadersValues]; | ||
| if (parser[kLastHeaderCallback] === HEADER_CB_VALUE) values[values.length - 1] += chunk; | ||
| else { | ||
| values.push(chunk); | ||
| parser[kLastHeaderCallback] = HEADER_CB_VALUE; | ||
| } | ||
| return 0; | ||
| }, | ||
| wasm_on_headers_complete(parserPointer, statusCode, rawUpgrade, rawShouldKeepAlive) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| const versionMajor = llhttp_get_version_major(parserPointer); | ||
| const versionMinor = llhttp_get_version_minor(parserPointer); | ||
| const rawHeaders = []; | ||
| const upgrade = rawUpgrade === 1; | ||
| const shouldKeepAlive = rawShouldKeepAlive === 1; | ||
| for (let c = 0; c < parser[kHeadersFields].length; c++) rawHeaders.push(parser[kHeadersFields][c], parser[kHeadersValues][c]); | ||
| if (parser[kType] === KIND_REQUEST) { | ||
| const method = methodNames[llhttp_get_method(parserPointer)]; | ||
| const url = parser[kUrl]; | ||
| return parser[kCallbacks].onHeadersComplete?.({ | ||
| versionMajor, | ||
| versionMinor, | ||
| rawHeaders, | ||
| method, | ||
| url, | ||
| upgrade, | ||
| shouldKeepAlive | ||
| }) ?? 0; | ||
| } else { | ||
| const statusCode = llhttp_get_status_code(parserPointer); | ||
| const statusMessage = parser[kStatusMessage]; | ||
| return parser[kCallbacks].onHeadersComplete?.({ | ||
| versionMajor, | ||
| versionMinor, | ||
| rawHeaders, | ||
| statusCode, | ||
| statusMessage, | ||
| upgrade, | ||
| shouldKeepAlive | ||
| }) ?? 0; | ||
| } | ||
| }, | ||
| wasm_on_body(parserPointer, at, length) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| const body = Buffer.from(new Uint8Array(llhttp_memory.buffer, at, length)); | ||
| return parser[kCallbacks].onBody?.(body) ?? 0; | ||
| }, | ||
| wasm_on_message_complete(parserPointer) { | ||
| return parsersMap.get(parserPointer)[kCallbacks].onMessageComplete?.() ?? 0; | ||
| } | ||
| } }); | ||
| const llhttp_memory = llhttpInstance.exports.memory; | ||
| const llhttp_alloc = llhttpInstance.exports.llhttp_alloc; | ||
| const llhttp_malloc = llhttpInstance.exports.malloc; | ||
| const llhttp_execute = llhttpInstance.exports.llhttp_execute; | ||
| llhttpInstance.exports.llhttp_get_type; | ||
| llhttpInstance.exports.llhttp_get_upgrade; | ||
| llhttpInstance.exports.llhttp_should_keep_alive; | ||
| const llhttp_get_method = llhttpInstance.exports.llhttp_get_method; | ||
| const llhttp_get_status_code = llhttpInstance.exports.llhttp_get_status_code; | ||
| const llhttp_get_version_minor = llhttpInstance.exports.llhttp_get_http_minor; | ||
| const llhttp_get_version_major = llhttpInstance.exports.llhttp_get_http_major; | ||
| const llhttp_get_error_reason = llhttpInstance.exports.llhttp_get_error_reason; | ||
| const llhttp_get_error_pos = llhttpInstance.exports.llhttp_get_error_pos; | ||
| const llhttp_free = llhttpInstance.exports.free; | ||
| const initialize = llhttpInstance.exports._initialize; | ||
| initialize(); | ||
| var HttpParser = class { | ||
| constructor(type, callbacks) { | ||
| this[kUrl] = ""; | ||
| this[kStatusMessage] = ""; | ||
| this[kHeadersFields] = []; | ||
| this[kHeadersValues] = []; | ||
| this[kLastHeaderCallback] = HEADER_CB_NONE; | ||
| this[kType] = type; | ||
| this[kCallbacks] = callbacks; | ||
| const parserPointer = llhttp_alloc(type); | ||
| if (parserPointer === 0) throw new Error("Failed to allocate llhttp parser"); | ||
| this[kPointer] = parserPointer; | ||
| parsersMap.set(this[kPointer], this); | ||
| } | ||
| destroy() { | ||
| if (this[kPointer] === 0) return; | ||
| parsersMap.delete(this[kPointer]); | ||
| llhttp_free(this[kPointer]); | ||
| this[kPointer] = 0; | ||
| } | ||
| execute(data) { | ||
| const pointer = llhttp_malloc(data.byteLength); | ||
| if (pointer === 0) throw new Error("Failed to allocate llhttp input buffer"); | ||
| let ret; | ||
| try { | ||
| new Uint8Array(llhttp_memory.buffer).set(data, pointer); | ||
| ret = llhttp_execute(this[kPointer], pointer, data.byteLength); | ||
| } catch (error) { | ||
| llhttp_free(pointer); | ||
| throw error; | ||
| } | ||
| if (ret === import_constants.ERROR.PAUSED_UPGRADE) { | ||
| const consumed = llhttp_get_error_pos(this[kPointer]) - pointer; | ||
| llhttp_free(pointer); | ||
| return data.subarray(consumed); | ||
| } | ||
| llhttp_free(pointer); | ||
| this.#checkError(ret); | ||
| return null; | ||
| } | ||
| #checkError(errorCode) { | ||
| if (errorCode === import_constants.ERROR.OK) return; | ||
| const errorPointer = llhttp_get_error_reason(this[kPointer]); | ||
| const length = new Uint8Array(llhttp_memory.buffer).indexOf(0, errorPointer) - errorPointer; | ||
| throw new Error(readStringFrom(errorPointer, length)); | ||
| } | ||
| }; | ||
| //#endregion | ||
| //#region src/interceptors/http/http-parser.ts | ||
| var HttpRequestParser = class extends HttpParser { | ||
| #requestBodyStream; | ||
| #upgrade = false; | ||
| constructor(options) { | ||
| super(1, { | ||
| onHeadersComplete: ({ rawHeaders, method, url: path, upgrade }) => { | ||
| this.#upgrade = upgrade; | ||
| /** | ||
| * @note When the socket is reused, "connectionOptions" will point | ||
| * to the "net.connect()" call options that established the connection, | ||
| * which may differ from the description of the current request (e.g. method). | ||
| * Rely on the HTTPParser supplying us with the correct "rawMethod" number. | ||
| */ | ||
| const finalMethod = (method || options.connectionOptions.method || "GET").toUpperCase(); | ||
| const url = new URL(path || "", options.connectionOptions.url); | ||
| const headers = FetchResponse.parseRawHeaders([...rawHeaders]); | ||
| if (url.username || url.password) { | ||
| if (!headers.has("authorization")) { | ||
| const credentials = Buffer.from(`${url.username}:${url.password}`).toString("base64"); | ||
| headers.set("authorization", `Basic ${credentials}`); | ||
| } | ||
| url.username = ""; | ||
| url.password = ""; | ||
| } | ||
| this.#requestBodyStream = new Readable({ | ||
| /** | ||
| * @note Provide the `read()` method so a `Readable` could be | ||
| * used as the actual request body (the stream calls "read()"). | ||
| */ | ||
| read: () => {} }); | ||
| /** | ||
| * @note Expose an abort controller for the parsed request so the | ||
| * consumer can abort it (e.g. when the client destroys the | ||
| * connection before the request is handled). | ||
| */ | ||
| const abortController = new AbortController(); | ||
| const request = new FetchRequest(url, { | ||
| method: finalMethod, | ||
| headers, | ||
| credentials: "same-origin", | ||
| body: Readable.toWeb(this.#requestBodyStream), | ||
| signal: abortController.signal | ||
| }); | ||
| options.onRequest(request, abortController); | ||
| }, | ||
| onBody: (chunk) => { | ||
| invariant(this.#requestBodyStream, "Failed to write to a request stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub."); | ||
| this.#requestBodyStream.push(chunk); | ||
| }, | ||
| onMessageComplete: () => { | ||
| this.#requestBodyStream?.push(null); | ||
| /** | ||
| * @note An upgraded exchange (e.g. "CONNECT", WebSocket) has | ||
| * no message boundary: it takes the connection over, and the | ||
| * bytes that follow belong to it, not to a next exchange. | ||
| */ | ||
| if (!this.#upgrade) options.onMessageComplete?.(); | ||
| } | ||
| }); | ||
| } | ||
| free() { | ||
| this.destroy(); | ||
| this.#requestBodyStream?.destroy(); | ||
| this.#requestBodyStream = void 0; | ||
| } | ||
| }; | ||
| var HttpResponseParser = class extends HttpParser { | ||
| #responseBodyStream; | ||
| constructor(options) { | ||
| super(2, { | ||
| onHeadersComplete: ({ rawHeaders, statusCode: status, statusMessage: statusText }) => { | ||
| const headers = FetchResponse.parseRawHeaders([...rawHeaders]); | ||
| const response = new FetchResponse(FetchResponse.isResponseWithBody(status) ? Readable.toWeb(this.#responseBodyStream = new Readable({ read() {} })) : null, { | ||
| status, | ||
| statusText, | ||
| headers | ||
| }); | ||
| options.onResponse(response); | ||
| }, | ||
| onBody: (chunk) => { | ||
| invariant(this.#responseBodyStream, "Failed to read from a response stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub."); | ||
| this.#responseBodyStream.push(chunk); | ||
| }, | ||
| onMessageComplete: () => { | ||
| this.#responseBodyStream?.push(null); | ||
| } | ||
| }); | ||
| } | ||
| free() { | ||
| this.destroy(); | ||
| this.#responseBodyStream = null; | ||
| } | ||
| }; | ||
| //#endregion | ||
| //#region src/utils/is-node-like-error.ts | ||
| function isNodeLikeError(error) { | ||
| if (error == null) return false; | ||
| if (!(error instanceof Error)) return false; | ||
| return "code" in error && "errno" in error; | ||
| } | ||
| //#endregion | ||
| //#region src/utils/handle-request.ts | ||
| async function handleRequest(options) { | ||
| if (options.logger?.isEnabled("default")) formatRequest(options.request).then((message) => { | ||
| options.logger?.info("[%s] %s", options.requestId, message); | ||
| }); | ||
| const handleResponse = async (response) => { | ||
| if (response instanceof Error) { | ||
| await options.controller.errorWith(response); | ||
| return true; | ||
| } | ||
| if (isResponseError(response)) { | ||
| await options.controller.respondWith(response); | ||
| return true; | ||
| } | ||
| /** | ||
| * Handle normal responses or response-like objects. | ||
| * @note This must come before the arbitrary object check | ||
| * since Response instances are, in fact, objects. | ||
| */ | ||
| if (isResponseLike(response)) { | ||
| await options.controller.respondWith(response); | ||
| return true; | ||
| } | ||
| if (isObject(response)) { | ||
| await options.controller.errorWith(response); | ||
| return true; | ||
| } | ||
| return false; | ||
| }; | ||
| const handleResponseError = async (error) => { | ||
| if (error instanceof InterceptorError) throw resultError; | ||
| if (isNodeLikeError(error)) { | ||
| await options.controller.errorWith(error); | ||
| return true; | ||
| } | ||
| if (error instanceof Response) return await handleResponse(error); | ||
| return false; | ||
| }; | ||
| const requestAbortPromise = Promise.withResolvers(); | ||
| let requestAbortReason; | ||
| let isRequestAborted = false; | ||
| const onAbort = () => { | ||
| isRequestAborted = true; | ||
| requestAbortReason = options.request.signal?.reason; | ||
| requestAbortPromise.reject(requestAbortReason); | ||
| }; | ||
| /** | ||
| * @note `signal` is not always defined in React Native. | ||
| */ | ||
| if (options.request.signal) { | ||
| if (options.request.signal.aborted) { | ||
| await options.controller.errorWith(options.request.signal.reason); | ||
| return; | ||
| } | ||
| options.request.signal.addEventListener("abort", onAbort, { once: true }); | ||
| } | ||
| const [resultError] = await until(async () => { | ||
| const requestEvent = new HttpRequestEvent({ | ||
| initiator: options.initiator, | ||
| requestId: options.requestId, | ||
| request: options.request, | ||
| controller: options.controller | ||
| }); | ||
| const requestListenersPromise = options.emitter.emitAsPromise(requestEvent); | ||
| await Promise.race([ | ||
| requestAbortPromise.promise, | ||
| requestListenersPromise, | ||
| options.controller.handled | ||
| ]); | ||
| /** | ||
| * @note If the "request" listener has replaced the request instance, | ||
| * propagate that mutation back to the underlying insterceptor. | ||
| * This happens with XMLHttpRequest that replaces request instances | ||
| * to correctly reflect the "withCredentials" option on the Fetch API request. | ||
| */ | ||
| if (requestEvent.request !== options.request) options.request = requestEvent.request; | ||
| }); | ||
| options.request.signal?.removeEventListener("abort", onAbort); | ||
| if (isRequestAborted) { | ||
| await options.controller.errorWith(requestAbortReason); | ||
| return; | ||
| } | ||
| if (resultError) { | ||
| if (await handleResponseError(resultError)) return; | ||
| if (options.emitter.listenerCount("unhandledException") > 0) { | ||
| const unhandledExceptionController = new RequestController(options.request, { | ||
| /** | ||
| * @note Intentionally empty passthrough handle. | ||
| * This controller is created within another controller and we only need | ||
| * to know if `unhandledException` listeners handled the request. | ||
| */ | ||
| passthrough() {}, | ||
| async respondWith(response) { | ||
| await handleResponse(response); | ||
| }, | ||
| async errorWith(reason) { | ||
| /** | ||
| * @note Handle the result of the unhandled controller | ||
| * in the same way as the original request controller. | ||
| * The exception here is that thrown errors within the | ||
| * "unhandledException" event do NOT result in another | ||
| * emit of the same event. They are forwarded as-is. | ||
| */ | ||
| await options.controller.errorWith(reason); | ||
| } | ||
| }); | ||
| await options.emitter.emitAsPromise(new UnhandledHttpException({ | ||
| initiator: options.initiator, | ||
| error: resultError, | ||
| request: options.request, | ||
| requestId: options.requestId, | ||
| controller: unhandledExceptionController | ||
| })); | ||
| if (unhandledExceptionController.readyState !== RequestController.PENDING) return; | ||
| } | ||
| await options.controller.respondWith(createServerErrorResponse(resultError)); | ||
| return; | ||
| } | ||
| if (options.controller.readyState === RequestController.PENDING) return await options.controller.passthrough(); | ||
| return options.controller.handled; | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/http/source.ts | ||
| const httpLogger = createLogger("http-request"); | ||
| /** | ||
| * Interceptor for HTTP requests in Node.js. | ||
| * Routes socket connections through an HTTP parser. | ||
| */ | ||
| var NodeHttpRequestSource = class extends Interceptor { | ||
| static { | ||
| this.symbol = Symbol.for("node-http-request-source"); | ||
| } | ||
| predicate() { | ||
| return true; | ||
| } | ||
| setup() { | ||
| const socketInterceptor = Interceptor.singleton(SocketInterceptor); | ||
| socketInterceptor.apply(this); | ||
| this.subscriptions.push(() => { | ||
| socketInterceptor.dispose(this); | ||
| }); | ||
| /** | ||
| * @note Record the raw values provided to Headers set/append | ||
| * in order to support "IncomingMessage.prototype.rawHeaders". | ||
| * This is meant for the headers in mocked responses. | ||
| */ | ||
| this.subscriptions.push(recordRawFetchHeaders()); | ||
| const controller = new AbortController(); | ||
| this.subscriptions.push(() => controller.abort()); | ||
| socketInterceptor.on("connection", ({ connectionOptions, socket, controller: socketController }) => { | ||
| let isHttpConnection; | ||
| let requestParser; | ||
| let tunnelUrl; | ||
| let abortPendingRequest; | ||
| /** | ||
| * @note Capture the request context of the connection itself. | ||
| * The socket is created synchronously within the request async | ||
| * context (e.g. inside the patched `http.request()`), but the | ||
| * first data may reach the socket from a foreign context (e.g. | ||
| * a form-data stream piped into the request), where sampling | ||
| * the request context yields nothing. | ||
| */ | ||
| const connectionRequestContext = requestContext.getStore(); | ||
| /** | ||
| * @note The client destroys the socket synchronously (e.g. Undici | ||
| * on request abort) but the socket teardown events ("error", | ||
| * "close") are emitted asynchronously, after the consumer has | ||
| * already observed the rejected request promise. Hook into the | ||
| * destroy itself so the pending request is aborted before any | ||
| * of its listeners can resume. | ||
| */ | ||
| const rawSocket = socketController[kRawSocket]; | ||
| const realSocketDestroy = rawSocket._destroy.bind(rawSocket); | ||
| rawSocket._destroy = (error, callback) => { | ||
| abortPendingRequest?.(); | ||
| realSocketDestroy(error, callback); | ||
| }; | ||
| /** | ||
| * @note Only inspect the first sent packet to determine the protocol. | ||
| * A single socket cannot be used for different protocols. | ||
| */ | ||
| socket.on("data", (chunk) => { | ||
| if (isHttpConnection === false) return; | ||
| /** | ||
| * @note A mocked "CONNECT" request has established a tunnel. | ||
| * The data that follows belongs to a new exchange addressed to | ||
| * the tunnel target. The parser stopped at the tunnel boundary | ||
| * (HTTP upgrade semantics), so tear it down and detect the | ||
| * tunneled protocol anew, like on a fresh connection. | ||
| */ | ||
| if (tunnelUrl && requestParser) { | ||
| requestParser.free(); | ||
| requestParser = void 0; | ||
| isHttpConnection = void 0; | ||
| /** | ||
| * @note Retarget the connection to the tunnel authority. | ||
| * The exchanges that follow belong to the tunnel target, | ||
| * so an unclaimed exchange (HTTP or not) must pass through | ||
| * to that target — not to the proxy, which never actually | ||
| * established this tunnel — like a real established tunnel | ||
| * relays its traffic. | ||
| */ | ||
| socketController.reset({ | ||
| host: tunnelUrl.hostname, | ||
| port: Number(tunnelUrl.port) || 80, | ||
| path: null | ||
| }); | ||
| } | ||
| if (requestParser) { | ||
| requestParser.execute(toBuffer(chunk)); | ||
| return; | ||
| } | ||
| const httpMessage = chunk.toString(); | ||
| const httpMethod = httpMessage.split(" ")[0] || ""; | ||
| if (!METHODS.includes(httpMethod.toUpperCase())) { | ||
| isHttpConnection = false; | ||
| socketController.decline(); | ||
| return; | ||
| } | ||
| isHttpConnection = true; | ||
| const baseUrl = tunnelUrl ?? connectionOptionsToUrl(connectionOptions, socket); | ||
| httpLogger.verbose("handling http message %o", { | ||
| httpMessage, | ||
| httpMethod, | ||
| baseUrl | ||
| }); | ||
| const requestContextValue = requestContext.getStore() ?? connectionRequestContext; | ||
| const initiator = requestContextValue?.initiator || socket; | ||
| requestParser = new HttpRequestParser({ | ||
| connectionOptions: { | ||
| method: httpMethod, | ||
| url: baseUrl | ||
| }, | ||
| /** | ||
| * @note The message boundary ends the current exchange. | ||
| * Schedule the controller reset so the next write on this | ||
| * (kept-alive) socket opens a new exchange and buffers for | ||
| * its own verdict instead of following the settled one | ||
| * (e.g. leaking a mocked request to the server of a | ||
| * previously passed-through exchange). | ||
| */ | ||
| onMessageComplete: () => { | ||
| socketController.scheduleReset(); | ||
| }, | ||
| onRequest: async (parsedRequest, requestAbortController) => { | ||
| const request = requestContextValue?.transformRequest?.(parsedRequest) ?? parsedRequest; | ||
| /** | ||
| * @note A subsequent request arriving on a kept-alive socket | ||
| * that has already been handled (passed through or mocked). | ||
| * Clients like Undici reuse sockets without emitting the | ||
| * "free" event, so reset the controller here, at the HTTP | ||
| * message boundary, to handle the new request from the | ||
| * pending state again. | ||
| */ | ||
| if (socketController["readyState"] !== SocketController.PENDING) socketController.reset(); | ||
| const requestId = createRequestId(); | ||
| const requestLogger = requestContextValue?.logger ?? httpLogger; | ||
| httpLogger.verbose("received a parsed HTTP request %o", { | ||
| method: request.method, | ||
| url: request.url | ||
| }); | ||
| const requestController = new RequestController(request, { | ||
| respondWith: async (rawResponse) => { | ||
| httpLogger.verbose("respondWith() %o", { | ||
| status: rawResponse.status, | ||
| statusText: rawResponse.statusText, | ||
| hasBody: rawResponse.body != null | ||
| }); | ||
| /** | ||
| * @note The client may destroy the socket (e.g. on request | ||
| * abort) moments before a response arrives. A destroyed | ||
| * socket cannot be claimed and has no one reading it. | ||
| */ | ||
| if (socket.destroyed) return; | ||
| socketController.claim(); | ||
| const response = FetchResponse.from(rawResponse, { url: request.url }); | ||
| /** | ||
| * @note A successful mocked response to a "CONNECT" | ||
| * request establishes a tunnel to the requested authority | ||
| * (e.g. "127.0.0.1:80"). The exchange that follows on this | ||
| * socket is addressed to that authority, not to the proxy. | ||
| */ | ||
| if (request.method === "CONNECT" && response.ok) tunnelUrl = new URL(`http://${request.url}`); | ||
| /** | ||
| * @note Clone the response before "respondWith" because it will | ||
| * consume its body. This way, we can have a readable response copy | ||
| * for the "response" event below. | ||
| */ | ||
| const responseClone = isResponseError(response) ? null : response.clone(); | ||
| const respond = () => { | ||
| return this.respondWith({ | ||
| socket: socketController[kRawSocket], | ||
| request: context.request, | ||
| response | ||
| }); | ||
| }; | ||
| if (responseClone) await this.emitter.emitAsPromise(new HttpResponseEvent({ | ||
| initiator, | ||
| requestId, | ||
| request: context.request, | ||
| response: responseClone, | ||
| responseType: "mock" | ||
| })); | ||
| if (socket.connecting) socket.once("connect", respond); | ||
| else | ||
| /** | ||
| * @note Reused sockets stay connected between requests and will not | ||
| * emit "connect" anymore. If that's the case, respond immediately. | ||
| */ | ||
| await respond(); | ||
| }, | ||
| errorWith: (reason) => { | ||
| if (reason instanceof Error) socket.destroy(reason); | ||
| }, | ||
| passthrough: () => { | ||
| const realSocket = socketController.passthrough(this.#modifyHttpHeaders(context.request)); | ||
| if (this.emitter.listenerCount("response") > 0) { | ||
| httpLogger.verbose("found \"response\" listener, corking socket reads"); | ||
| /** | ||
| * Suspend the delivery of the original response to the client | ||
| * until the "response" event listeners settle. This guarantees | ||
| * that the request promise (e.g. `await fetch()`) does not | ||
| * resolve before the listeners are done. The real socket keeps | ||
| * emitting data for the response parser meanwhile. | ||
| */ | ||
| socketController.corkReads(); | ||
| const responseParser = new HttpResponseParser({ onResponse: async (response) => { | ||
| httpLogger.verbose("HTTP response parser parsed: %d %s", response.status, response.statusText); | ||
| if (isResponseError(response)) { | ||
| httpLogger.verbose("response is an error response, uncorking socket reads..."); | ||
| socketController.uncorkReads(); | ||
| return; | ||
| } | ||
| FetchResponse.setUrl(request.url, response); | ||
| try { | ||
| httpLogger.verbose("emitting \"response\" event"); | ||
| await this.emitter.emitAsPromise(new HttpResponseEvent({ | ||
| initiator, | ||
| requestId, | ||
| request: context.request, | ||
| response, | ||
| responseType: "original" | ||
| })); | ||
| } finally { | ||
| httpLogger.verbose("uncorking socket reads"); | ||
| socketController.uncorkReads(); | ||
| /** | ||
| * @note Informational responses other than | ||
| * "101 Switching Protocols" are followed by a final | ||
| * response on the same connection. Keep gating that | ||
| * final response on the "response" event listeners. | ||
| */ | ||
| if (response.status < 200 && response.status !== 101) socketController.corkReads(); | ||
| } | ||
| } }); | ||
| realSocket.on("data", (chunk) => responseParser.execute(chunk)).on("close", () => responseParser.free()); | ||
| } | ||
| } | ||
| }, { | ||
| logger: requestLogger, | ||
| requestId | ||
| }); | ||
| invariant(socketController["readyState"] === SocketController.PENDING, "CANNOT HANDLE ALREADY HANDLED REQUEST", request.method, request.url, socketController["readyState"]); | ||
| /** | ||
| * @note Create a request resolution context. | ||
| * This is so modifications to the "request" in upstream interceptors | ||
| * are correctly picked up by the underlying HTTP interceptor. | ||
| */ | ||
| const context = { | ||
| initiator, | ||
| requestId, | ||
| request, | ||
| controller: requestController, | ||
| emitter: this.emitter, | ||
| logger: requestLogger | ||
| }; | ||
| /** | ||
| * @note The client destroying the socket while the request | ||
| * is still pending means the request was aborted (e.g. via | ||
| * `AbortController`). Abort the parsed request so its | ||
| * handling settles and late interactions with the request | ||
| * controller become controlled errors. | ||
| */ | ||
| abortPendingRequest = () => { | ||
| if (requestController.readyState === RequestController.PENDING) requestAbortController.abort(); | ||
| }; | ||
| try { | ||
| await handleRequest(context); | ||
| } finally { | ||
| abortPendingRequest = void 0; | ||
| } | ||
| } | ||
| }); | ||
| requestParser.execute(toBuffer(chunk)); | ||
| }); | ||
| socket.on("close", () => requestParser?.free()); | ||
| }, { signal: controller.signal }); | ||
| } | ||
| async respondWith(args) { | ||
| const { socket, request, response } = args; | ||
| if (socket.destroyed) return; | ||
| if (isResponseError(response)) { | ||
| /** | ||
| * @note Reference the error response on the socket error so the | ||
| * client-side interceptors (e.g. fetch) can surface it to the | ||
| * consumer as the reason behind the failed request. Keep the | ||
| * reference non-enumerable so the error remains observably | ||
| * identical for the clients that expose it as-is. | ||
| */ | ||
| socket.destroy(Object.defineProperty(/* @__PURE__ */ new TypeError("Network error"), kErrorResponse, { | ||
| value: response, | ||
| enumerable: false | ||
| })); | ||
| return; | ||
| } | ||
| invariant(!socket.connecting, "Failed to mock a response for \"%s %s\": socket has not connected", request.method, request.url); | ||
| /** | ||
| * Use native server response handling in Node.js. | ||
| * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/_http_server.js#L202 | ||
| */ | ||
| const incomingMessage = new IncomingMessage(socket); | ||
| /** | ||
| * @note Describe the request method so the response body is | ||
| * handled appropriately (e.g. "HEAD" responses must not write | ||
| * a body). The HTTP version is deliberately left unset: with it, | ||
| * `ServerResponse` frames bodies of unknown length as chunked, | ||
| * polluting the mocked response headers with "Transfer-Encoding" | ||
| * the mock never specified. | ||
| */ | ||
| incomingMessage.method = request.method; | ||
| const serverResponse = new ServerResponse(incomingMessage); | ||
| const responseSocket = new net.Socket(); | ||
| responseSocket._writeGeneric = (writev, data, encoding, callback) => { | ||
| unwrapPendingData(data, (chunk, encoding) => { | ||
| socket.push(toBuffer(chunk), encoding); | ||
| }); | ||
| callback?.(); | ||
| }; | ||
| responseSocket._destroy = (error, callback) => { | ||
| /** | ||
| * Only destroy the socket on stream errors. | ||
| * On a clean end, the socket is already signaled via `socket.push(null)` | ||
| * in the main response flow. Destroying it here prematurely would prevent | ||
| * the client from processing the response (e.g. calling `response.destroy()`). | ||
| * @see https://github.com/mswjs/interceptors/issues/738 | ||
| */ | ||
| if (error) socket.destroy(); | ||
| callback(null); | ||
| }; | ||
| responseSocket.on("drain", () => serverResponse.emit("drain")); | ||
| serverResponse.assignSocket(responseSocket); | ||
| serverResponse.removeHeader("connection"); | ||
| serverResponse.removeHeader("date"); | ||
| const rawResponseHeaders = getRawFetchHeaders(response.headers); | ||
| serverResponse.writeHead(response.status, response.statusText || STATUS_CODES[response.status], rawResponseHeaders); | ||
| /** | ||
| * @note Override the socket's `_destroy` before writing the response body. | ||
| * The underlying TCP handle (from `socket.connect()`) makes `_destroy` async | ||
| * (`_handle.close()` callback), which delays the 'error' event. Since the real | ||
| * TCP connection is irrelevant for mocked responses, take the synchronous path | ||
| * so that user-initiated `response.destroy(error)` emits the error promptly. | ||
| * This must happen before `serverResponse.end()` because the HTTP parser may | ||
| * fire the 'response' event synchronously during `socket.push()`. | ||
| */ | ||
| socket._destroy = function(error, callback) { | ||
| if (error) | ||
| /** | ||
| * Emit the error event as a microtask instead of relying on the default | ||
| * `process.nextTick(emitErrorNT)` from `callback(error)`. This is necessary | ||
| * because `respondWith` runs inside a microtask (from `await reader.read()`). | ||
| * A resolved promise continuation (from toWebResponse) is queued as | ||
| * another microtask during the same phase. Since microtasks are drained before | ||
| * nextTick, the test's `await` would resolve before the error event fires. | ||
| * Using `queueMicrotask` ensures the error event is emitted within the current | ||
| * microtask phase, before other queued microtasks. | ||
| */ | ||
| queueMicrotask(() => this.emit("error", error)); | ||
| callback(null); | ||
| /** | ||
| * @note `net.Socket` is constructed with `emitClose: false`, so Node's | ||
| * stream destroy machinery does not emit `'close'` automatically; the | ||
| * stock `net.Socket._destroy` only emits it via `_handle.close()`. | ||
| * Since this override replaces `_destroy`, emit `'close'` here so the | ||
| * mocked socket completes its lifecycle (otherwise consumers waiting | ||
| * on `'close'`, like `http.ClientRequest`, hang). | ||
| */ | ||
| process.nextTick(() => this.emit("close", error != null)); | ||
| }; | ||
| if (response.body) { | ||
| const reader = response.body.getReader(); | ||
| try { | ||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) { | ||
| serverResponse.end(); | ||
| break; | ||
| } | ||
| if (!serverResponse.write(value)) await new Promise((resolve) => { | ||
| serverResponse.once("drain", resolve); | ||
| }); | ||
| } | ||
| } catch { | ||
| /** | ||
| * @note Delay the socket destruction to allow the event loop | ||
| * to flush already-pushed response data (headers + body chunks) | ||
| * through the HTTP parser. Without this, the socket is destroyed | ||
| * on the same tick as `socket.push(data)` and the client never | ||
| * reads the response. | ||
| */ | ||
| await new Promise((resolve) => process.nextTick(resolve)); | ||
| socket.destroy(); | ||
| return; | ||
| } | ||
| } else serverResponse.end(); | ||
| /** | ||
| * @note Self-delimiting responses (chunked, explicit "Content-Length", | ||
| * or bodiless by definition) must NOT signal the end-of-stream. | ||
| * The client parser completes them from their framing alone, and | ||
| * ending the socket would kill the kept-alive connection that | ||
| * agents pool and reuse for subsequent requests. | ||
| */ | ||
| const isSelfDelimitingResponse = request.method === "HEAD" || response.headers.has("content-length") || response.headers.has("transfer-encoding") || !FetchResponse.isResponseWithBody(response.status); | ||
| if (request.method === "CONNECT" && !response.ok || request.method !== "CONNECT" && !isSelfDelimitingResponse) { | ||
| /** | ||
| * @note Defer the end-of-stream signal so the HTTP parser has a chance | ||
| * to process already-pushed response data and fire the 'response' event | ||
| * before the socket is ended. Without this, the parser marks the response | ||
| * as "complete" before the client can interact with it (e.g. `response.destroy()`). | ||
| */ | ||
| await new Promise((resolve) => process.nextTick(resolve)); | ||
| socket.push(null); | ||
| } | ||
| } | ||
| #modifyHttpHeaders(request) { | ||
| const transformRequestMessage = (httpMessage, encoding) => { | ||
| /** | ||
| * @note Socket can write a buffer (e.g. uploaded file) even before | ||
| * it writes the HTTP message. Bypass those cases. | ||
| */ | ||
| if (encoding === "buffer") return httpMessage; | ||
| const parts = httpMessage.toString(encoding).split("\r\n"); | ||
| const headersEndIndex = parts.findIndex((field) => field === ""); | ||
| const httpMessageHeaderPairs = parts.slice(1, headersEndIndex); | ||
| const httpMessageRawHeaders = httpMessageHeaderPairs.map((line) => { | ||
| const separatorIndex = line.indexOf(": "); | ||
| return [line.slice(0, separatorIndex), line.slice(separatorIndex + 2)]; | ||
| }); | ||
| const requestRawHeaders = getRawFetchHeaders(request.headers); | ||
| if (httpMessageRawHeaders.length === requestRawHeaders.length && httpMessageRawHeaders.every((tuple, index) => { | ||
| const requestTuple = requestRawHeaders[index]; | ||
| return tuple[0] === requestTuple[0] && tuple[1] === requestTuple[1]; | ||
| })) return httpMessage; | ||
| const httpMessageHeaders = FetchResponse.parseRawHeaders(httpMessageHeaderPairs.flatMap((header) => header.split(": "))); | ||
| const visitedHeaders = /* @__PURE__ */ new Set(); | ||
| for (const [headerName] of requestRawHeaders) { | ||
| const normalizedHeaderName = headerName.toLowerCase(); | ||
| if (visitedHeaders.has(normalizedHeaderName)) continue; | ||
| visitedHeaders.add(normalizedHeaderName); | ||
| /** | ||
| * @note Forbidden Fetch headers (e.g. Host, Origin, Connection) | ||
| * are stripped from `request.headers` but remain in the raw | ||
| * headers list. Skip them so the original values from the | ||
| * outgoing HTTP message are preserved. | ||
| */ | ||
| const headerValue = request.headers.get(headerName); | ||
| if (headerValue === null) continue; | ||
| httpMessageHeaders.set(headerName, headerValue); | ||
| } | ||
| visitedHeaders.clear(); | ||
| const httpMessageHeadersString = Array.from(httpMessageHeaders).map(([name, value]) => `${name}: ${value}`).join("\r\n"); | ||
| parts.splice(1, headersEndIndex - 1, httpMessageHeadersString); | ||
| return parts.join("\r\n"); | ||
| }; | ||
| return (pendingData, encoding, callback) => { | ||
| if (Array.isArray(pendingData)) pendingData[0].chunk = transformRequestMessage(pendingData[0].chunk, pendingData[0].encoding); | ||
| else pendingData = transformRequestMessage(pendingData, encoding); | ||
| callback(pendingData); | ||
| }; | ||
| } | ||
| }; | ||
| //#endregion | ||
| export { requestContext as a, forwardHttpEvents as i, handleRequest as n, runInRequestContext as o, HttpResponseEvent as r, NodeHttpRequestSource as t }; | ||
| //# sourceMappingURL=source-CIgPng7r.js.map |
Sorry, the diff of this file is too big to display
@@ -1,4 +0,4 @@ | ||
| import { i as forwardHttpEvents, o as runInRequestContext, t as NodeHttpRequestSource } from "../../source-BUVce4Q1.js"; | ||
| import { i as forwardHttpEvents, o as runInRequestContext, t as NodeHttpRequestSource } from "../../source-CIgPng7r.js"; | ||
| import { i as Interceptor } from "../../buffer-utils-BvPY1Tc-.js"; | ||
| import { o as patchesRegistry } from "../../net-9sRKnjIG.js"; | ||
| import { o as patchesRegistry } from "../../net-Ca8p1rIe.js"; | ||
| import http from "node:http"; | ||
@@ -5,0 +5,0 @@ import https from "node:https"; |
@@ -1,6 +0,6 @@ | ||
| import { a as requestContext, i as forwardHttpEvents, t as NodeHttpRequestSource } from "../../source-BUVce4Q1.js"; | ||
| import { a as requestContext, i as forwardHttpEvents, t as NodeHttpRequestSource } from "../../source-CIgPng7r.js"; | ||
| import { i as Interceptor } from "../../buffer-utils-BvPY1Tc-.js"; | ||
| import { i as getErrorResponse } from "../../fetch-utils-Dw1PsmtX.js"; | ||
| import { o as patchesRegistry } from "../../net-9sRKnjIG.js"; | ||
| import { t as hasConfigurableGlobal } from "../../has-configurable-global-IskkJJOL.js"; | ||
| import { o as patchesRegistry } from "../../net-Ca8p1rIe.js"; | ||
| import { t as hasConfigurableGlobal } from "../../has-configurable-global-BU1sT1u4.js"; | ||
| //#region src/interceptors/fetch/node.ts | ||
@@ -7,0 +7,0 @@ /** |
@@ -1,2 +0,2 @@ | ||
| import { i as forwardHttpEvents, t as NodeHttpRequestSource } from "../../source-BUVce4Q1.js"; | ||
| import { i as forwardHttpEvents, t as NodeHttpRequestSource } from "../../source-CIgPng7r.js"; | ||
| import { i as Interceptor } from "../../buffer-utils-BvPY1Tc-.js"; | ||
@@ -3,0 +3,0 @@ //#region src/interceptors/http/index.ts |
@@ -164,2 +164,14 @@ import { t as Interceptor } from "../../interceptor-dtJZ_9QT.js"; | ||
| reset(connectionOptions?: NetworkConnectionOptions & net.SocketConnectOpts): void; | ||
| /** | ||
| * Schedule a reset of this controller to happen right before the | ||
| * next client write. The consumer signals the boundary of the | ||
| * current exchange (e.g. an HTTP message boundary) the moment it is | ||
| * parsed — mid-delivery of the exchange's final chunk — when an | ||
| * immediate reset would discard that chunk. Deferring the reset to | ||
| * the next write lets the current exchange settle as-is while the | ||
| * next exchange's first write already buffers for a new verdict | ||
| * (instead of following the previous one, e.g. leaking to the | ||
| * passthrough connection). | ||
| */ | ||
| scheduleReset(): void; | ||
| protected emulateConnect(): void; | ||
@@ -166,0 +178,0 @@ /** |
@@ -1,2 +0,2 @@ | ||
| import { t as SocketInterceptor } from "../../net-9sRKnjIG.js"; | ||
| import { t as SocketInterceptor } from "../../net-Ca8p1rIe.js"; | ||
| export { SocketInterceptor }; |
@@ -1,6 +0,6 @@ | ||
| import { a as requestContext, i as forwardHttpEvents, t as NodeHttpRequestSource } from "../../source-BUVce4Q1.js"; | ||
| import { a as requestContext, i as forwardHttpEvents, t as NodeHttpRequestSource } from "../../source-CIgPng7r.js"; | ||
| import { a as createLogger, i as Interceptor } from "../../buffer-utils-BvPY1Tc-.js"; | ||
| import { t as FetchRequest } from "../../fetch-utils-Dw1PsmtX.js"; | ||
| import { o as patchesRegistry } from "../../net-9sRKnjIG.js"; | ||
| import { t as hasConfigurableGlobal } from "../../has-configurable-global-IskkJJOL.js"; | ||
| import { o as patchesRegistry } from "../../net-Ca8p1rIe.js"; | ||
| import { t as hasConfigurableGlobal } from "../../has-configurable-global-BU1sT1u4.js"; | ||
| //#region src/interceptors/XMLHttpRequest/node.ts | ||
@@ -7,0 +7,0 @@ const logger = createLogger("xhr"); |
@@ -1,2 +0,2 @@ | ||
| import { n as handleRequest, r as HttpResponseEvent } from "./source-BUVce4Q1.js"; | ||
| import { n as handleRequest, r as HttpResponseEvent } from "./source-CIgPng7r.js"; | ||
| import { a as createLogger, i as Interceptor } from "./buffer-utils-BvPY1Tc-.js"; | ||
@@ -3,0 +3,0 @@ import { t as BatchInterceptor } from "./batch-interceptor-ByEZVih3.js"; |
+1
-1
@@ -5,3 +5,3 @@ { | ||
| "description": "Low-level network interception library for Node.js.", | ||
| "version": "0.42.2", | ||
| "version": "0.42.3", | ||
| "main": "./lib/node/index.js", | ||
@@ -8,0 +8,0 @@ "types": "./lib/node/index.d.ts", |
@@ -12,2 +12,3 @@ import { Readable } from 'node:stream' | ||
| onRequest: (request: Request, abortController: AbortController) => void | ||
| onMessageComplete?: () => void | ||
| } | ||
@@ -17,6 +18,8 @@ | ||
| #requestBodyStream?: Readable | ||
| #upgrade = false | ||
| constructor(options: HttpRequestParserOptions) { | ||
| super(1, { | ||
| onHeadersComplete: ({ rawHeaders, method, url: path }) => { | ||
| onHeadersComplete: ({ rawHeaders, method, url: path, upgrade }) => { | ||
| this.#upgrade = upgrade | ||
| /** | ||
@@ -84,2 +87,11 @@ * @note When the socket is reused, "connectionOptions" will point | ||
| this.#requestBodyStream?.push(null) | ||
| /** | ||
| * @note An upgraded exchange (e.g. "CONNECT", WebSocket) has | ||
| * no message boundary: it takes the connection over, and the | ||
| * bytes that follow belong to it, not to a next exchange. | ||
| */ | ||
| if (!this.#upgrade) { | ||
| options.onMessageComplete?.() | ||
| } | ||
| }, | ||
@@ -86,0 +98,0 @@ }) |
@@ -171,2 +171,13 @@ import net from 'node:net' | ||
| }, | ||
| /** | ||
| * @note The message boundary ends the current exchange. | ||
| * Schedule the controller reset so the next write on this | ||
| * (kept-alive) socket opens a new exchange and buffers for | ||
| * its own verdict instead of following the settled one | ||
| * (e.g. leaking a mocked request to the server of a | ||
| * previously passed-through exchange). | ||
| */ | ||
| onMessageComplete: () => { | ||
| socketController.scheduleReset() | ||
| }, | ||
| onRequest: async (parsedRequest, requestAbortController) => { | ||
@@ -173,0 +184,0 @@ const request = |
@@ -481,2 +481,3 @@ import net from 'node:net' | ||
| #realHandleSwapped = false | ||
| #resetScheduled = false | ||
| #clientCloseEmitted = false | ||
@@ -655,5 +656,21 @@ #clientEndPushed = false | ||
| /** | ||
| * Schedule a reset of this controller to happen right before the | ||
| * next client write. The consumer signals the boundary of the | ||
| * current exchange (e.g. an HTTP message boundary) the moment it is | ||
| * parsed — mid-delivery of the exchange's final chunk — when an | ||
| * immediate reset would discard that chunk. Deferring the reset to | ||
| * the next write lets the current exchange settle as-is while the | ||
| * next exchange's first write already buffers for a new verdict | ||
| * (instead of following the previous one, e.g. leaking to the | ||
| * passthrough connection). | ||
| */ | ||
| public scheduleReset(): void { | ||
| this.#resetScheduled = true | ||
| } | ||
| #reset(): void { | ||
| logger.verbose('resetting the socket...') | ||
| this.#resetScheduled = false | ||
| this.readyState = SocketController.PENDING | ||
@@ -733,2 +750,12 @@ this.pendingConnection = Promise.withResolvers() | ||
| /** | ||
| * @note A scheduled reset marks the boundary of the previous | ||
| * exchange (see "scheduleReset"). This write is the first one past | ||
| * the boundary; reset before dispatching so it opens the next | ||
| * exchange instead of following the previous exchange's verdict. | ||
| */ | ||
| if (this.#resetScheduled) { | ||
| this.reset() | ||
| } | ||
| /** | ||
| * @note Buffer the write BEFORE pushing the data to the server socket. | ||
@@ -735,0 +762,0 @@ * Handling the pushed data may transition this controller within the |
| import { a as getDeepPropertyDescriptor } from "./net-9sRKnjIG.js"; | ||
| //#region src/utils/has-configurable-global.ts | ||
| /** | ||
| * Returns a boolean indicating whether the given global property | ||
| * is defined and is configurable. | ||
| */ | ||
| function hasConfigurableGlobal(propertyName) { | ||
| const match = getDeepPropertyDescriptor(globalThis, propertyName); | ||
| if (typeof match === "undefined") return false; | ||
| const { descriptor } = match; | ||
| if (typeof descriptor.get === "function" && typeof descriptor.get() === "undefined") return false; | ||
| if (typeof descriptor.get === "undefined" && descriptor.value == null) return false; | ||
| if (typeof descriptor.set === "undefined" && !descriptor.configurable) { | ||
| console.error(`[MSW] Failed to apply interceptor: the global \`${propertyName}\` property is non-configurable. This is likely an issue with your environment. If you are using a framework, please open an issue about this in their repository.`); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| //#endregion | ||
| export { hasConfigurableGlobal as t }; | ||
| //# sourceMappingURL=has-configurable-global-IskkJJOL.js.map |
| {"version":3,"file":"has-configurable-global-IskkJJOL.js","names":[],"sources":["../../src/utils/has-configurable-global.ts"],"sourcesContent":["import { getDeepPropertyDescriptor } from './patches-registry'\n\n/**\n * Returns a boolean indicating whether the given global property\n * is defined and is configurable.\n */\nexport function hasConfigurableGlobal(propertyName: string): boolean {\n const match = getDeepPropertyDescriptor(globalThis, propertyName)\n\n // The property is not set at all.\n if (typeof match === 'undefined') {\n return false\n }\n\n const { descriptor } = match\n\n // The property is set to a getter that returns undefined.\n if (\n typeof descriptor.get === 'function' &&\n typeof descriptor.get() === 'undefined'\n ) {\n return false\n }\n\n // The property is set to a value equal to undefined.\n if (typeof descriptor.get === 'undefined' && descriptor.value == null) {\n return false\n }\n\n if (typeof descriptor.set === 'undefined' && !descriptor.configurable) {\n console.error(\n `[MSW] Failed to apply interceptor: the global \\`${propertyName}\\` property is non-configurable. This is likely an issue with your environment. If you are using a framework, please open an issue about this in their repository.`\n )\n return false\n }\n\n return true\n}\n"],"mappings":";;;;;;AAMA,SAAgB,sBAAsB,cAA+B;CACnE,MAAM,QAAQ,0BAA0B,YAAY,YAAY;CAGhE,IAAI,OAAO,UAAU,aACnB,OAAO;CAGT,MAAM,EAAE,eAAe;CAGvB,IACE,OAAO,WAAW,QAAQ,cAC1B,OAAO,WAAW,IAAI,MAAM,aAE5B,OAAO;CAIT,IAAI,OAAO,WAAW,QAAQ,eAAe,WAAW,SAAS,MAC/D,OAAO;CAGT,IAAI,OAAO,WAAW,QAAQ,eAAe,CAAC,WAAW,cAAc;EACrE,QAAQ,MACN,mDAAmD,aAAa,mKAClE;EACA,OAAO;CACT;CAEA,OAAO;AACT"} |
| import { a as createLogger, i as Interceptor, r as toBuffer } from "./buffer-utils-BvPY1Tc-.js"; | ||
| import { TypedEvent } from "rettime"; | ||
| import { invariant } from "outvariant"; | ||
| import http from "node:http"; | ||
| import net from "node:net"; | ||
| import tls from "node:tls"; | ||
| //#region src/utils/patches-registry.ts | ||
| var PatchesRegistry = class { | ||
| #replacements = /* @__PURE__ */ new Map(); | ||
| applyPatch(owner, key, getNextValue) { | ||
| const ownerReplacements = this.#replacements.get(owner); | ||
| invariant(!ownerReplacements?.has(key), `Failed to replace a global value at "${String(key)}": already replaced.`); | ||
| const match = getDeepPropertyDescriptor(owner, key); | ||
| if (typeof match === "undefined") { | ||
| console.warn(`Failed to replace a global value at "${String(key)}": not a global value.`); | ||
| return () => {}; | ||
| } | ||
| if (match.descriptor.configurable) Object.defineProperty(owner, key, { | ||
| value: getNextValue(owner[key]), | ||
| enumerable: true, | ||
| configurable: true | ||
| }); | ||
| else if (match.descriptor.writable) owner[key] = getNextValue(owner[key]); | ||
| else throw new Error(`Failed to patch a non-configurable non-writable property "${key.toString()}"`); | ||
| const restorePatch = () => { | ||
| const currentReplacements = this.#replacements.get(owner); | ||
| if (!currentReplacements?.has(key)) return; | ||
| if (match.owner === owner) | ||
| /** | ||
| * @note Restoring non-configurable properties works as long as "writable: true" | ||
| * and none of the other descriptor properties except for "value" have changed. | ||
| */ | ||
| Object.defineProperty(match.owner, key, match.descriptor); | ||
| else | ||
| /** | ||
| * @todo Delete the proxy property set by the registry. | ||
| * If the match's owner isn't the original owner, the property is likely nested in the prototype. | ||
| * The registry does not meddle with those, they are left intact. | ||
| */ | ||
| Reflect.deleteProperty(owner, key); | ||
| currentReplacements.delete(key); | ||
| if (currentReplacements.size === 0) this.#replacements.delete(owner); | ||
| }; | ||
| if (ownerReplacements) ownerReplacements.set(key, restorePatch); | ||
| else this.#replacements.set(owner, /* @__PURE__ */ new Map([[key, restorePatch]])); | ||
| return restorePatch; | ||
| } | ||
| restoreAllPatches() { | ||
| const errors = []; | ||
| for (const [, ownerReplacements] of this.#replacements) for (const [, restorePatch] of ownerReplacements) try { | ||
| restorePatch(); | ||
| } catch (error) { | ||
| if (error instanceof Error) errors.push(error); | ||
| else throw error; | ||
| } | ||
| if (errors.length > 0) throw new AggregateError(errors, "FOO!"); | ||
| } | ||
| }; | ||
| const patchesRegistry = new PatchesRegistry(); | ||
| /** | ||
| * Returns a property descriptor for the given property on the owner. | ||
| * Walks down the prototype chain if the property does not exist on the owner. | ||
| * Handy for getting a global property descriptor where `globalThis` is | ||
| * replaced with a controlled class (e.g. ServiceWorkerGlobalScope). | ||
| */ | ||
| function getDeepPropertyDescriptor(owner, key) { | ||
| let currentOwner = owner; | ||
| let descriptor; | ||
| while (currentOwner) { | ||
| descriptor = Object.getOwnPropertyDescriptor(currentOwner, key); | ||
| if (descriptor) return { | ||
| owner: currentOwner, | ||
| descriptor | ||
| }; | ||
| currentOwner = Object.getPrototypeOf(currentOwner); | ||
| } | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/normalize-net-connect-args.ts | ||
| /** | ||
| * Normalizes the arguments passed to `net.connect()`. | ||
| */ | ||
| function normalizeNetConnectArgs(args) { | ||
| if (args.length === 0) return [{ path: "" }, null]; | ||
| const callback = typeof args[1] === "function" ? args[1] : args[2] || null; | ||
| if (typeof args[0] === "string") return [{ path: args[0] }, callback]; | ||
| if (typeof args[0] === "number") return [{ | ||
| port: args[0], | ||
| path: "", | ||
| /** | ||
| * @note The host is optional in the "(port[, host][, callback])" | ||
| * signatures and defaults to "localhost" in Node.js. | ||
| */ | ||
| host: typeof args[1] === "string" ? args[1] : void 0 | ||
| }, callback]; | ||
| if (typeof args[0] === "object") { | ||
| /** | ||
| * @note URL arguments receive no special treatment on purpose. | ||
| * Node.js does not support them and reads a given URL as a plain | ||
| * options object (e.g. "url.port" is a string, "url.host" includes | ||
| * the port). The "port" branch below reproduces that reading. | ||
| */ | ||
| if ("port" in args[0]) return [{ | ||
| path: "", | ||
| port: Reflect.get(args[0], "port"), | ||
| host: Reflect.get(args[0], "host"), | ||
| auth: Reflect.get(args[0], "auth"), | ||
| family: Reflect.get(args[0], "family"), | ||
| hints: Reflect.get(args[0], "hints"), | ||
| session: Reflect.get(args[0], "session"), | ||
| localAddress: Reflect.get(args[0], "localAddress"), | ||
| localPort: Reflect.get(args[0], "localPort"), | ||
| timeout: Reflect.get(args[0], "timeout"), | ||
| lookup: Reflect.get(args[0], "lookup"), | ||
| allowHalfOpen: Reflect.get(args[0], "allowHalfOpen"), | ||
| noDelay: Reflect.get(args[0], "noDelay"), | ||
| keepAlive: Reflect.get(args[0], "keepAlive"), | ||
| keepAliveInitialDelay: Reflect.get(args[0], "keepAliveInitialDelay"), | ||
| autoSelectFamily: Reflect.get(args[0], "autoSelectFamily"), | ||
| autoSelectFamilyAttemptTimeout: Reflect.get(args[0], "autoSelectFamilyAttemptTimeout") | ||
| }, callback]; | ||
| return [{ | ||
| path: args[0].path || "", | ||
| family: Reflect.get(args[0], "family"), | ||
| session: Reflect.get(args[0], "session"), | ||
| auth: Reflect.get(args[0], "auth"), | ||
| timeout: Reflect.get(args[0], "timeout"), | ||
| allowHalfOpen: Reflect.get(args[0], "allowHalfOpen") | ||
| }, callback]; | ||
| } | ||
| throw new Error(`Invalid arguments passed to net.connect: ${args}`); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/flush-writes.ts | ||
| /** | ||
| * Write the given pending data to the socket using its public | ||
| * "write()" method. Unlike direct "_writeGeneric()" calls, public | ||
| * writes go through the "Writable" queue that only dispatches the | ||
| * next write once the previous one completes. TLS sockets rely on | ||
| * that serialization: their "TLSWrap" handle supports a single | ||
| * write in flight and crashes the process with a native | ||
| * "Assertion failed: !current_write_" abort when writes overlap | ||
| * (e.g. multiple direct "_writeGeneric()" calls issued on a | ||
| * connecting socket replay back-to-back on "connect"). | ||
| */ | ||
| function writePendingData(socket, data, encoding, callback) { | ||
| if (Array.isArray(data)) { | ||
| for (let index = 0; index < data.length; index++) { | ||
| const entry = data[index]; | ||
| /** | ||
| * @note Per-entry callbacks are intentionally ignored, the same | ||
| * way "writevGeneric()" only honors the batch-level callback. | ||
| */ | ||
| if (index === data.length - 1) socket.write(entry.chunk, entry.encoding, callback); | ||
| else socket.write(entry.chunk, entry.encoding); | ||
| } | ||
| return; | ||
| } | ||
| socket.write(data, encoding, callback); | ||
| } | ||
| function unwrapPendingData(data, callback) { | ||
| if (Array.isArray(data)) for (const entry of data) callback(entry.chunk, entry.encoding, entry.callback); | ||
| else callback(data); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/get-tls-connect-options.ts | ||
| /** | ||
| * Returns the original options the given TLS socket was created with. | ||
| * The original `tls.connect()` stores them on the socket instance | ||
| * under an internal symbol before connecting its transport. | ||
| * @see https://github.com/nodejs/node/blob/3178a762d6a2b1a37b74f02266eea0f3d86603f1/lib/_tls_wrap.js#L1690 | ||
| */ | ||
| function getTlsConnectOptions(socket) { | ||
| const kConnectOptions = Object.getOwnPropertySymbols(socket).find((symbol) => { | ||
| return symbol.description === "connect-options"; | ||
| }); | ||
| if (kConnectOptions == null) return; | ||
| return Reflect.get(socket, kConnectOptions); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/address-info.ts | ||
| function getAddressInfoByConnectionOptions(options) { | ||
| if (options == null) return {}; | ||
| const isIPv6 = options.family === 6 || net.isIPv6(options.host || ""); | ||
| return { | ||
| address: isIPv6 ? "::1" : "127.0.0.1", | ||
| /** | ||
| * @note Coerce the port to a number. Connection options may | ||
| * describe it as a string (e.g. when created from a URL), while | ||
| * the address info always reports a numeric port. | ||
| */ | ||
| port: Number(options.port) || (options.protocol === "https:" ? 443 : 80), | ||
| family: isIPv6 ? "IPv6" : "IPv4" | ||
| }; | ||
| } | ||
| /** | ||
| * Get the local address info for the given connection options. | ||
| * This describes the client-side end of the connection: the socket | ||
| * is bound to the loopback interface and an ephemeral port, the same | ||
| * way the operating system binds an outgoing connection. | ||
| */ | ||
| function getLocalAddressInfoByConnectionOptions(options) { | ||
| if (options == null) return {}; | ||
| const isIPv6 = options.family === 6 || net.isIPv6(options.host || ""); | ||
| return { | ||
| address: options.localAddress || (isIPv6 ? "::1" : "127.0.0.1"), | ||
| port: options.localPort || getEphemeralPort(), | ||
| family: isIPv6 ? "IPv6" : "IPv4" | ||
| }; | ||
| } | ||
| /** | ||
| * Get a random port from the ephemeral range (IANA: 49152-65535), | ||
| * the range the operating system draws from when binding an | ||
| * outgoing connection. | ||
| */ | ||
| function getEphemeralPort() { | ||
| return 49152 + Math.floor(Math.random() * 16384); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/socket-controller.ts | ||
| const kListenerWrap = Symbol("kListenerWrap"); | ||
| const kRawSocket = Symbol("kRawSocket"); | ||
| const kPatched = Symbol("kPatched"); | ||
| const logger$1 = createLogger("socket"); | ||
| function toServerSocket(socket) { | ||
| /** | ||
| * The server-side write buffer. Pushing data to the client honors | ||
| * the client's read backpressure: once "socket.push()" reports a | ||
| * full buffer, subsequent server writes queue here and flush when | ||
| * the client reads again ("_read"). This mirrors how a real server | ||
| * cannot write faster than the client consumes. | ||
| */ | ||
| const pendingWrites = []; | ||
| let pendingEnd; | ||
| let isBackpressured = false; | ||
| let isFlushScheduled = false; | ||
| const flushPendingWrites = () => { | ||
| /** | ||
| * @note The mocked connection is not established yet. Flush once | ||
| * it is, the same way a real server cannot write to a client | ||
| * that has not connected. | ||
| */ | ||
| if (socket.connecting) { | ||
| isBackpressured = true; | ||
| if (!isFlushScheduled) { | ||
| isFlushScheduled = true; | ||
| socket.once("ready", () => { | ||
| isFlushScheduled = false; | ||
| flushPendingWrites(); | ||
| }); | ||
| } | ||
| return false; | ||
| } | ||
| while (pendingWrites.length > 0) { | ||
| const nextWrite = pendingWrites.shift(); | ||
| socket._unrefTimer(); | ||
| const canPushMore = socket.push(toBuffer(nextWrite.chunk, nextWrite.encoding), nextWrite.encoding); | ||
| nextWrite.callback?.(); | ||
| if (!canPushMore) { | ||
| isBackpressured = true; | ||
| return false; | ||
| } | ||
| } | ||
| const wasBackpressured = isBackpressured; | ||
| isBackpressured = false; | ||
| if (pendingEnd) { | ||
| const finalEnd = pendingEnd; | ||
| pendingEnd = void 0; | ||
| if (finalEnd.chunk != null) socket.push(toBuffer(finalEnd.chunk, finalEnd.encoding), finalEnd.encoding); | ||
| socket.push(null); | ||
| finalEnd.callback?.(); | ||
| } | ||
| if (wasBackpressured) socket.emit("internal:drain"); | ||
| return true; | ||
| }; | ||
| /** | ||
| * @note "_read" is the client asking for more data. Flush the | ||
| * buffered server writes so the delivery resumes as the client | ||
| * reads, completing the backpressure loop. | ||
| */ | ||
| const realRead = socket._read.bind(socket); | ||
| socket._read = (size) => { | ||
| realRead(size); | ||
| if (pendingWrites.length > 0 || pendingEnd != null || isBackpressured) flushPendingWrites(); | ||
| }; | ||
| return new Proxy(socket, { get: (target, property, receiver) => { | ||
| const getRealValue = () => { | ||
| return Reflect.get(target, property, receiver); | ||
| }; | ||
| if (property === "on" || property === "addListener" || property === "once" || property === "prependListener" || property === "prependOnceListener") { | ||
| const realAddListener = getRealValue(); | ||
| return (event, listener) => { | ||
| if (event === "data") { | ||
| const listenerWrap = (chunk, encoding) => { | ||
| listener(toBuffer(chunk, encoding)); | ||
| }; | ||
| Object.defineProperty(listener, kListenerWrap, { | ||
| enumerable: false, | ||
| writable: false, | ||
| value: listenerWrap | ||
| }); | ||
| /** | ||
| * @note Subscribe using the same method (e.g. "once") so its | ||
| * listener semantics apply to the internal channel too. | ||
| */ | ||
| Reflect.apply(realAddListener, target, ["internal:write", listenerWrap]); | ||
| return target; | ||
| } | ||
| /** | ||
| * @note The "drain" event on the server socket signals the | ||
| * flush of the server-side write buffer. It is routed through | ||
| * an internal channel so it does not clash with the "drain" | ||
| * event of the underlying client socket. | ||
| */ | ||
| if (event === "drain") { | ||
| Reflect.apply(realAddListener, target, ["internal:drain", listener]); | ||
| return target; | ||
| } | ||
| return realAddListener.call(target, event, listener); | ||
| }; | ||
| } | ||
| if (property === "off" || property === "removeListener") { | ||
| const realRemoveListener = getRealValue(); | ||
| return (event, listener) => { | ||
| if (event === "data") { | ||
| const listenerWrap = listener[kListenerWrap]; | ||
| if (listenerWrap) return realRemoveListener.call(target, "internal:write", listenerWrap); | ||
| } | ||
| if (event === "drain") return realRemoveListener.call(target, "internal:drain", listener); | ||
| return realRemoveListener.call(target, event, listener); | ||
| }; | ||
| } | ||
| if (property === "write") return ((chunk, encoding, callback) => { | ||
| if (typeof encoding === "function") { | ||
| callback = encoding; | ||
| encoding = void 0; | ||
| } | ||
| pendingWrites.push({ | ||
| chunk, | ||
| encoding, | ||
| callback | ||
| }); | ||
| /** | ||
| * @note Do not push more data to a client that is not | ||
| * reading. The write stays buffered until the client reads | ||
| * again, and "drain" signals the flush. | ||
| */ | ||
| if (isBackpressured) return false; | ||
| return flushPendingWrites(); | ||
| }); | ||
| if (property === "end") return ((...args) => { | ||
| const callback = args[args.length - 1]; | ||
| /** | ||
| * @note The end-of-stream is delivered once the buffered | ||
| * writes flush so the client never observes the EOF before | ||
| * the data that preceded it. | ||
| */ | ||
| pendingEnd = { | ||
| chunk: typeof args[0] === "function" ? void 0 : args[0], | ||
| encoding: typeof args[1] === "string" ? args[1] : void 0, | ||
| callback: typeof callback === "function" ? callback : void 0 | ||
| }; | ||
| flushPendingWrites(); | ||
| /** | ||
| * @note Do not end the client socket's writable side. | ||
| * The server ending the connection only signals EOF to the | ||
| * client (the client may keep writing on half-open sockets). | ||
| */ | ||
| return target; | ||
| }); | ||
| return getRealValue(); | ||
| } }); | ||
| } | ||
| var SocketController = class SocketController { | ||
| static { | ||
| this.PENDING = 0; | ||
| } | ||
| static { | ||
| this.CLAIMED = 1; | ||
| } | ||
| static { | ||
| this.PASSTHROUGH = 2; | ||
| } | ||
| #awaitedVerdicts = 0; | ||
| constructor(socket) { | ||
| this[kRawSocket] = socket; | ||
| socket[kPatched] = true; | ||
| this.readyState = SocketController.PENDING; | ||
| } | ||
| /** | ||
| * Claim this socket. Once claimed, the connection attempt succeeds | ||
| * regardless of the requested host and the interceptor becomes the | ||
| * mocked server for this connection. | ||
| */ | ||
| claim() { | ||
| invariant(this.readyState === SocketController.PENDING, "Failed to claim a socket connection: already handled (%s)", this.readyState); | ||
| this.readyState = SocketController.CLAIMED; | ||
| } | ||
| /** | ||
| * Establish this socket connection as-is. | ||
| */ | ||
| passthrough() { | ||
| invariant(this.readyState === SocketController.PENDING, "Failed to passthrough a socket connection: already handled (%s)", this.readyState); | ||
| this.readyState = SocketController.PASSTHROUGH; | ||
| } | ||
| /** | ||
| * Await a verdict on this connection from the given number of | ||
| * subscribers. A connection nobody awaits to inspect (or one that | ||
| * every awaited subscriber has declined) is passed through as-is. | ||
| * This makes "unclaimed after everyone declined" a state owned by | ||
| * the controller instead of the individual subscribers. | ||
| */ | ||
| awaitVerdicts(count) { | ||
| this.#awaitedVerdicts = count; | ||
| if (this.#awaitedVerdicts === 0) this.passthrough(); | ||
| } | ||
| /** | ||
| * Decline this socket connection. Declining means the subscriber | ||
| * has inspected the connection and will not handle it (e.g. the | ||
| * traffic is not of the protocol that subscriber implements). | ||
| * Once every awaited subscriber declines, the connection is | ||
| * passed through as-is. | ||
| */ | ||
| decline() { | ||
| if (this.readyState !== SocketController.PENDING) return; | ||
| this.#awaitedVerdicts -= 1; | ||
| if (this.#awaitedVerdicts <= 0) | ||
| /** | ||
| * @note Defer the passthrough so it never transitions this | ||
| * controller in the middle of a client write. Declines are | ||
| * issued while the written data is being pushed to the server | ||
| * socket, and a synchronous transition would race the write's | ||
| * own bookkeeping (e.g. re-buffering the write after a reset | ||
| * at an exchange boundary). | ||
| */ | ||
| process.nextTick(() => { | ||
| if (this.readyState === SocketController.PENDING && !this[kRawSocket].destroyed) this.passthrough(); | ||
| }); | ||
| } | ||
| }; | ||
| var TcpSocketController = class extends SocketController { | ||
| #connectionOptions; | ||
| #retargetedConnectionOptions; | ||
| #realWriteGeneric; | ||
| #passthroughSocket = null; | ||
| #bufferedWrites = []; | ||
| #readsCorked = false; | ||
| #corkedReads = []; | ||
| #realHandleSwapped = false; | ||
| #clientCloseEmitted = false; | ||
| #clientEndPushed = false; | ||
| #connectEmulated = false; | ||
| constructor(socket, createConnection, connectionOptions) { | ||
| super(socket); | ||
| this.socket = socket; | ||
| this.createConnection = createConnection; | ||
| /** | ||
| * @note Plain TCP sockets capture the connection options from the | ||
| * "socket.connect()" proxy below. TLS sockets carry additional | ||
| * TLS-level options that never pass through "socket.connect()", | ||
| * so those are provided explicitly (see "TlsSocketController"). | ||
| */ | ||
| this.#connectionOptions = connectionOptions; | ||
| this.socket._read = () => { | ||
| /** | ||
| * @note Resume the passthrough socket when the consumer asks for | ||
| * more data. Node.js calls "_read()" once the consumer drains the | ||
| * read buffer (e.g. after "resume()"). The passthrough socket may | ||
| * have been paused when the consumer's buffer got full, and this | ||
| * is the only signal to continue reading. | ||
| */ | ||
| this.#passthroughSocket?.resume(); | ||
| }; | ||
| this.#realWriteGeneric = this.socket._writeGeneric; | ||
| this.#bufferedWrites = []; | ||
| this.socket._writeGeneric = (...args) => { | ||
| this.#handleWrite(args); | ||
| }; | ||
| this.socket.connect = new Proxy(this.socket.connect, { apply: (target, thisArg, args) => { | ||
| logger$1.verbose("socket.connect() %o", args); | ||
| this.#connectionOptions = args[0]; | ||
| /** | ||
| * @note Do not bind the intercepted socket to the requested | ||
| * local address/port. Binding reserves the port for real, and | ||
| * the passthrough connection (created with the original options) | ||
| * would then bind the same port again, resulting in a conflict. | ||
| * The requested values are still reflected in the address info | ||
| * of a claimed socket (see "claim()"). | ||
| */ | ||
| if (args[0] != null && typeof args[0] === "object" && (args[0].localAddress != null || args[0].localPort != null)) args[0] = { | ||
| ...args[0], | ||
| localAddress: void 0, | ||
| localPort: void 0 | ||
| }; | ||
| return Reflect.apply(target, thisArg, args); | ||
| } }); | ||
| /** | ||
| * @note A single socket can be reused for connections to the same host. | ||
| * When one connection ends, the Agent frees the socket, then uses it | ||
| * to write the next request's HTTP message immediately. Use the "free" | ||
| * event to transition the controller into the pending state so the | ||
| * next exchange is handled anew. | ||
| */ | ||
| socket.on("free", () => { | ||
| logger$1.verbose("client socket freed!"); | ||
| this.reset(); | ||
| }).on("close", () => { | ||
| logger$1.verbose("client socket closed!"); | ||
| this.#clientCloseEmitted = true; | ||
| /** | ||
| * @note Destroy the passthrough socket, if any. The client | ||
| * socket is done, and a passthrough connection left open would | ||
| * linger until the server closes it (or error unhandled if it | ||
| * is still connecting). | ||
| */ | ||
| this.#passthroughSocket?.destroy(); | ||
| this.#passthroughSocket = null; | ||
| this.#bufferedWrites = []; | ||
| this.#readsCorked = false; | ||
| this.#corkedReads = []; | ||
| }); | ||
| this.serverSocket = toServerSocket(this.socket); | ||
| this.pendingConnection = Promise.withResolvers(); | ||
| this.#reset(); | ||
| } | ||
| /** | ||
| * Reset this controller to the pending state so the next exchange | ||
| * on this socket can be handled anew. This is meant for kept-alive | ||
| * sockets that are reused for multiple exchanges by clients that | ||
| * don't emit the "free" event on the socket (e.g. Undici). | ||
| * | ||
| * Providing connection options retargets this connection: the | ||
| * exchanges that follow belong to the given target (e.g. the | ||
| * authority of an established "CONNECT" tunnel). An unclaimed | ||
| * exchange then passes through to that target instead of the | ||
| * originally dialed one, and a claimed exchange reports it as the | ||
| * peer. The verdict count is deliberately not re-armed: subscribers | ||
| * that declined this connection's protocol stay declined across | ||
| * the exchanges, retargeted or not. | ||
| */ | ||
| reset(connectionOptions) { | ||
| if (connectionOptions != null) { | ||
| this.#retargetedConnectionOptions = connectionOptions; | ||
| this.#connectionOptions = connectionOptions; | ||
| /** | ||
| * @note The passthrough connection to the original target, if | ||
| * any, cannot serve the retargeted exchanges. Detach its | ||
| * forwarding listeners before destroying it so its teardown is | ||
| * not mistaken for the client connection's own (e.g. its "close" | ||
| * must not close the client socket). | ||
| */ | ||
| if (this.#passthroughSocket) { | ||
| this.#passthroughSocket.removeListener("connect", this.#onRealSocketConnect).removeListener("connectionAttemptFailed", this.#onRealSocketConnectionAttemptFailed).removeListener("connectionAttemptTimeout", this.#onRealSocketConnectionAttemptTimeout).removeListener("data", this.#onRealSocketData).removeListener("error", this.#onRealSocketError).removeListener("end", this.#onRealSocketEnd).removeListener("close", this.#onRealSocketClose).destroy(); | ||
| this.#passthroughSocket = null; | ||
| /** | ||
| * @note The handle swapped in from the destroyed connection, | ||
| * if any, no longer carries this socket's traffic. Treat the | ||
| * socket as not swapped so the retargeted passthrough writes | ||
| * directly to the new connection until its own handle swap. | ||
| */ | ||
| this.#realHandleSwapped = false; | ||
| } | ||
| } | ||
| /** | ||
| * @note Only settled (claimed or passed-through) sockets need a reset. | ||
| * Resetting a pending socket again would discard the writes already | ||
| * buffered for the next exchange (e.g. the Agent "free" event firing | ||
| * after the parser has reset this controller at a message boundary). | ||
| */ | ||
| if (this.readyState === SocketController.PENDING) return; | ||
| this.#reset(); | ||
| } | ||
| #reset() { | ||
| logger$1.verbose("resetting the socket..."); | ||
| this.readyState = SocketController.PENDING; | ||
| this.pendingConnection = Promise.withResolvers(); | ||
| this.#bufferedWrites = []; | ||
| this.#connectEmulated = false; | ||
| this.socket._pendingData = null; | ||
| this.socket._pendingEncoding = ""; | ||
| const wrapHandle = (handle) => { | ||
| this.pendingConnection.promise.then(() => { | ||
| logger$1.verbose("connection request resolved!", this.readyState); | ||
| process.nextTick(() => { | ||
| /** | ||
| * @note If by this point the socket hasn't been handled, | ||
| * is still connecting, doesn't have any writes buffered, | ||
| * and has a "connect" listener, assume it's the "write after connect" | ||
| * scenario (e.g. undici). In that case, auto-claim the socket to | ||
| * transition to the connected state appropriately to its handle. | ||
| */ | ||
| if (this.readyState === SocketController.PENDING && this.socket.connecting && this.#bufferedWrites.length === 0 && this.socket.listenerCount("connect") > 0) { | ||
| logger$1.verbose("assume connect->write socket, calling \"connect\" listeners..."); | ||
| this.emulateConnect(); | ||
| } | ||
| }); | ||
| }); | ||
| /** | ||
| * Remove the "setTypeOfService" from the handle, if present (Node.js v24+). | ||
| * Removing it has no effect on the socket but prevents the "setTypeOfService EBADF" error. | ||
| * @see https://github.com/nodejs/node/blob/69a970f76814d40f55cf162d0cc3632fe8a7e599/lib/net.js#L661 | ||
| * @see https://github.com/nodejs/undici/blob/bf684f7de01616708a33a5d1c092177622394442/lib/dispatcher/client-h1.js#L1136 | ||
| */ | ||
| if (handle.setTypeOfService) handle.setTypeOfService = void 0; | ||
| handle.connect = handle.connect6 = (request) => { | ||
| logger$1.verbose("handle.connect()"); | ||
| this.pendingConnection.resolve([request, handle]); | ||
| }; | ||
| logger$1.verbose("socket handle wrapped! waiting for connection request..."); | ||
| }; | ||
| if (this.socket._handle) wrapHandle(this.socket._handle); | ||
| else this.socket.prependOnceListener("connectionAttempt", () => { | ||
| wrapHandle(this.socket._handle); | ||
| }); | ||
| } | ||
| /** | ||
| * Handle a write performed on the client socket. Installed once as | ||
| * "_writeGeneric", this is the single write path for every controller | ||
| * state, dispatching on "readyState". | ||
| */ | ||
| #handleWrite(args) { | ||
| const data = args[1]; | ||
| logger$1.verbose("socket write (state: %d) %o", this.readyState, args); | ||
| /** | ||
| * @note Buffer the write BEFORE pushing the data to the server socket. | ||
| * Handling the pushed data may transition this controller within the | ||
| * same call stack, and each transition settles the buffered entry: | ||
| * "passthrough()" flushes it to the real socket, "claim()" drops it, | ||
| * and a reset at an HTTP message boundary keeps it for the next exchange. | ||
| */ | ||
| this.#bufferedWrites.push(args); | ||
| if (this.readyState === SocketController.PENDING) { | ||
| /** | ||
| * @note Reflect the buffered write in "_pendingData" so it counts | ||
| * toward "bytesWritten", the same way Node.js counts the pending | ||
| * data of a connecting socket. Flushing the buffered writes resets | ||
| * "_pendingData" (see `passthrough()`). | ||
| */ | ||
| const pendingData = Array.isArray(this.socket._pendingData) ? this.socket._pendingData : []; | ||
| unwrapPendingData(data, (chunk, chunkEncoding) => { | ||
| pendingData.push({ | ||
| chunk, | ||
| encoding: chunkEncoding | ||
| }); | ||
| }); | ||
| this.socket._pendingData = pendingData; | ||
| if (this.socket.listenerCount("internal:write") === 0) { | ||
| logger$1.verbose("no server data listeners, scheduling to the next tick..."); | ||
| process.nextTick(() => { | ||
| this.#push(data); | ||
| }); | ||
| } else this.#push(data); | ||
| } else this.#push(data); | ||
| /** | ||
| * Dispatch on the state observed AFTER the push since handling the | ||
| * pushed data may have transitioned this controller synchronously. | ||
| */ | ||
| switch (this.readyState) { | ||
| case SocketController.PENDING: | ||
| /** | ||
| * @note The write either awaits the verdict on this exchange or, | ||
| * if the push reset this controller at a message boundary, opens | ||
| * the next exchange (the reset cleared the buffer; re-buffer it). | ||
| */ | ||
| if (!this.#bufferedWrites.includes(args)) this.#bufferedWrites.push(args); | ||
| this.#acknowledgeWrite(args); | ||
| return; | ||
| case SocketController.CLAIMED: | ||
| /** | ||
| * @note Once claimed, there's nowhere else to write chunks to. | ||
| * The data was delivered to the server socket; complete the write | ||
| * so the socket's writable state settles (enabling "finish"). | ||
| */ | ||
| this.#removeBufferedWrite(args); | ||
| this.#acknowledgeWrite(args); | ||
| return; | ||
| case SocketController.PASSTHROUGH: | ||
| /** | ||
| * @note A synchronous "passthrough()" has already flushed this | ||
| * write (with its callback) to the real socket. | ||
| */ | ||
| if (!this.#removeBufferedWrite(args)) return; | ||
| /** | ||
| * @note Until the handle swap, the client socket's own handle | ||
| * cannot carry any data (it never actually connects). Write | ||
| * directly to the passthrough socket instead. This also prevents | ||
| * the "connecting" write replay of `Socket.prototype._writeGeneric` | ||
| * from pushing the same data to the server socket twice. | ||
| */ | ||
| if (!this.#realHandleSwapped && this.#passthroughSocket) { | ||
| writePendingData(this.#passthroughSocket, data, args[2], args[3]); | ||
| return; | ||
| } | ||
| this.#realWriteGeneric.apply(this.socket, args); | ||
| } | ||
| } | ||
| /** | ||
| * Complete the given write by invoking its callback. The callback is | ||
| * then removed from the write entry so flushing that entry later | ||
| * (e.g. on passthrough) does not invoke it twice. | ||
| */ | ||
| #acknowledgeWrite(args) { | ||
| const callback = args[3]; | ||
| if (typeof callback === "function") { | ||
| callback(); | ||
| args[3] = void 0; | ||
| } | ||
| } | ||
| #removeBufferedWrite(args) { | ||
| const index = this.#bufferedWrites.indexOf(args); | ||
| if (index === -1) return false; | ||
| this.#bufferedWrites.splice(index, 1); | ||
| return true; | ||
| } | ||
| emulateConnect() { | ||
| this.#connectEmulated = true; | ||
| /** | ||
| * @note Reflect the connected state before notifying the listeners, | ||
| * the same way Node.js does before emitting "connect". | ||
| */ | ||
| Reflect.set(this.socket, "connecting", false); | ||
| /** | ||
| * @note Invoke the raw listeners so the "once" wrappers get | ||
| * consumed. This prevents listeners like the connection callback | ||
| * from being invoked again when "connect" is emitted for real | ||
| * (e.g. once the claimed connection completes). | ||
| */ | ||
| for (const listener of this.socket.rawListeners("connect")) listener.apply(this.socket); | ||
| } | ||
| /** | ||
| * Push the given data to the server socket. | ||
| * This has no effect on the public-facing socket and is used | ||
| * only for the interceptors to subscribe to "socket.on('data')" | ||
| * before the data is actually written anywhere. | ||
| */ | ||
| #push = (data) => { | ||
| if (data == null) return; | ||
| logger$1.verbose("server push %o", data); | ||
| unwrapPendingData(data, (chunk, encoding) => { | ||
| logger$1.verbose("server emitting \"data\" %o", { | ||
| chunk, | ||
| encoding | ||
| }); | ||
| this.socket.emit("internal:write", chunk, encoding); | ||
| }); | ||
| }; | ||
| #onRealSocketConnect = () => { | ||
| if (!this.#passthroughSocket) return; | ||
| /** | ||
| * @note The consumer may destroy the socket while the passthrough | ||
| * connection is still being established. The passthrough "connect" | ||
| * (I/O poll phase) can arrive before the client's "close" callback | ||
| * (close phase) within the same event loop iteration. A destroyed | ||
| * socket must not emit "connect"/"ready". | ||
| */ | ||
| if (this.socket.destroyed) { | ||
| this.#passthroughSocket.destroy(); | ||
| return; | ||
| } | ||
| const replacedHandle = this.socket._handle; | ||
| const wasUnrefed = replacedHandle != null && typeof replacedHandle.hasRef === "function" && !replacedHandle.hasRef(); | ||
| this.socket._handle = this.#passthroughSocket._handle; | ||
| this.#realHandleSwapped = true; | ||
| /** | ||
| * @note Preserve the ref state across the handle swap. If the | ||
| * consumer unrefed the socket while it was connecting, the swapped | ||
| * handle must not hold the process alive either. | ||
| */ | ||
| if (wasUnrefed) this.socket._handle.unref?.(); | ||
| /** | ||
| * @note Close the replaced handle. Nothing references it past this | ||
| * point, and left open, it keeps the process alive indefinitely. | ||
| * For TLS sockets, the replaced handle is a TLSWrap; close its | ||
| * underlying transport too (closing the wrap alone does not close | ||
| * the TCP handle it sits on). | ||
| */ | ||
| if (replacedHandle != null) { | ||
| replacedHandle.close(); | ||
| replacedHandle._parent?.close(); | ||
| } | ||
| Reflect.set(this.socket, "connecting", false); | ||
| /** | ||
| * @note Read the remote address info once so it gets cached on the | ||
| * socket. The passthrough socket controls the swapped handle and may | ||
| * close it at any point (e.g. once the server ends the connection), | ||
| * while Node.js keeps serving the cached info for sockets that | ||
| * have connected. Reading must happen after the socket is no longer | ||
| * connecting (the info of connecting sockets is never cached). | ||
| */ | ||
| this.socket.remoteAddress; | ||
| this.socket.emit("connect"); | ||
| this.socket.emit("ready"); | ||
| }; | ||
| #onRealSocketConnectionAttemptFailed = (address, port, family, error) => { | ||
| this.socket.emit("connectionAttemptFailed", address, port, family, error); | ||
| }; | ||
| #onRealSocketConnectionAttemptTimeout = (address, port, family) => { | ||
| this.socket.emit("connectionAttemptTimeout", address, port, family); | ||
| }; | ||
| #onRealSocketData = (data) => { | ||
| logger$1.verbose("real socket \"data\" event %o", data); | ||
| /** | ||
| * @note Receiving data is socket activity. Refresh the idle timer | ||
| * of the client socket the same way its own reads would | ||
| * ("socket.setTimeout()" must not fire during an active transfer). | ||
| * The data arrives on the real socket, which only refreshes the | ||
| * real socket's timer. | ||
| */ | ||
| this.socket._unrefTimer(); | ||
| if (this.#readsCorked) { | ||
| logger$1.verbose("reads are corked, buffering the data..."); | ||
| this.#corkedReads.push({ | ||
| type: "data", | ||
| chunk: data | ||
| }); | ||
| return; | ||
| } | ||
| if (!this.socket.push(data)) { | ||
| logger$1.verbose("client socket forbade more pushes, pausing the passthrough socket..."); | ||
| this.#passthroughSocket?.pause(); | ||
| } | ||
| }; | ||
| #onRealSocketError = (error) => { | ||
| logger$1.verbose("real socket \"error\" event %o", error); | ||
| if (this.socket.destroyed) { | ||
| logger$1.verbose("real socket errored but client socket already destroyed, skipping..."); | ||
| return; | ||
| } | ||
| logger$1.verbose("real socket errored, forwarding %o", error); | ||
| this.socket.destroy(error); | ||
| if (this.#realHandleSwapped) process.nextTick(() => this.socket.emit("close", true)); | ||
| }; | ||
| #onRealSocketEnd = () => { | ||
| this.socket._unrefTimer(); | ||
| if (this.#readsCorked) { | ||
| this.#corkedReads.push({ type: "end" }); | ||
| return; | ||
| } | ||
| this.#clientEndPushed = true; | ||
| this.socket.push(null); | ||
| }; | ||
| #onRealSocketClose = (hadError) => { | ||
| /** | ||
| * @note The connection is fully closed and the swapped handle | ||
| * cannot shut down anymore. Report a synchronous shutdown so the | ||
| * automatic "end()" of a half-closed socket ("allowHalfOpen: false") | ||
| * finishes without errors once the consumer reads the end-of-stream. | ||
| */ | ||
| if (this.#realHandleSwapped && this.socket._handle) this.socket._handle.shutdown = () => 1; | ||
| if (this.#readsCorked) { | ||
| this.#corkedReads.push({ | ||
| type: "close", | ||
| hadError | ||
| }); | ||
| return; | ||
| } | ||
| if (this.#clientCloseEmitted) return; | ||
| if (this.socket.destroyed && !this.#realHandleSwapped) return; | ||
| this.#emitClientClose(hadError); | ||
| }; | ||
| /** | ||
| * Emit the "close" event on the client socket, honoring the order | ||
| * of the socket teardown events. | ||
| * @note The client may be paused with the received data (and the | ||
| * end-of-stream) still buffered. Node.js never emits "close" before | ||
| * "end" on a gracefully closed connection: the teardown waits until | ||
| * the consumer reads the buffered data. | ||
| */ | ||
| #emitClientClose(hadError) { | ||
| if (this.#clientEndPushed && !this.socket.readableEnded && !this.socket.destroyed) { | ||
| let closeDelivered = false; | ||
| const deliverClose = (hadErrorOverride) => { | ||
| if (closeDelivered) return; | ||
| closeDelivered = true; | ||
| process.nextTick(() => { | ||
| if (!this.#clientCloseEmitted) this.socket.emit("close", hadErrorOverride ?? hadError); | ||
| }); | ||
| }; | ||
| this.socket.once("end", () => { | ||
| deliverClose(); | ||
| }); | ||
| /** | ||
| * @note The consumer may also destroy the socket before reading | ||
| * the buffered data. Node.js still emits "close" for such | ||
| * sockets, but the destroy machinery of a socket with a swapped | ||
| * (already closed) handle never completes. Deliver "close" here. | ||
| */ | ||
| const realDestroy = this.socket._destroy; | ||
| this.socket._destroy = (error, callback) => { | ||
| deliverClose(error != null); | ||
| return realDestroy.call(this.socket, error, callback); | ||
| }; | ||
| return; | ||
| } | ||
| this.socket.emit("close", hadError); | ||
| } | ||
| #onMockSocketDrain = () => { | ||
| logger$1.verbose("client socket drained!"); | ||
| this.#passthroughSocket?.resume(); | ||
| }; | ||
| /** | ||
| * Forward the client's half-close to the passthrough socket. | ||
| * The client's writable side may finish before the real handle is | ||
| * swapped in ("_final" of a connected client runs against the mock | ||
| * handle), leaving the FIN unsent. Once the handle is swapped, the | ||
| * client's own shutdown reaches the real connection natively. | ||
| */ | ||
| #forwardClientFinish = () => { | ||
| if (!this.#realHandleSwapped) this.#passthroughSocket?.end(); | ||
| }; | ||
| /** | ||
| * Suspend forwarding of the passthrough socket events ("data", "end", "close") | ||
| * to the client socket. The events are buffered in order until `uncorkReads()` | ||
| * is called. This allows the consumer to delay the delivery of the original | ||
| * response to the client (e.g. until its own asynchronous logic settles). | ||
| * | ||
| * @note Pausing the client socket is not enough to delay the delivery. | ||
| * Consumers like Undici read the pushed data from a paused socket | ||
| * directly via `socket.read()`, which is unaffected by `socket.pause()`. | ||
| */ | ||
| corkReads() { | ||
| this.#readsCorked = true; | ||
| } | ||
| /** | ||
| * Resume forwarding of the passthrough socket events to the client socket, | ||
| * replaying any events buffered while the reads were corked. | ||
| */ | ||
| uncorkReads() { | ||
| if (!this.#readsCorked) return; | ||
| this.#readsCorked = false; | ||
| for (const corkedRead of this.#corkedReads.splice(0)) switch (corkedRead.type) { | ||
| case "data": | ||
| if (!this.socket.push(corkedRead.chunk)) { | ||
| logger$1.verbose("client socket forbade more pushes, pausing the passthrough socket..."); | ||
| this.#passthroughSocket?.pause(); | ||
| } | ||
| break; | ||
| case "end": | ||
| this.#clientEndPushed = true; | ||
| this.socket.push(null); | ||
| break; | ||
| case "close": | ||
| this.#emitClientClose(corkedRead.hadError); | ||
| break; | ||
| } | ||
| } | ||
| claim() { | ||
| super.claim(); | ||
| /** | ||
| * @note The client may destroy the socket before the connection | ||
| * is claimed (e.g. abort a request in-flight). There is no | ||
| * connection to mock then, and the destroyed socket has no handle. | ||
| */ | ||
| if (this.socket.destroyed) { | ||
| logger$1.verbose("socket already destroyed, skipping claim..."); | ||
| return; | ||
| } | ||
| /** | ||
| * @note Skip already connected sockets (e.g. kept-alive sockets | ||
| * reused for the next exchange). Sockets with an emulated "connect" | ||
| * only appear connected and must still complete the mock connection. | ||
| */ | ||
| if (!this.socket.connecting && !this.#connectEmulated) { | ||
| logger$1.verbose("socket already connected, skipping claim..."); | ||
| return; | ||
| } | ||
| logger$1.verbose("-> claim!"); | ||
| /** | ||
| * @note Reflect the local end of the claimed socket, the same way | ||
| * the operating system reports the bound address of an outgoing | ||
| * connection via "socket.address()"/"localAddress"/"localPort". | ||
| * Patching the handle also prevents Node.js from handling the | ||
| * "getsockname" errors of the never-connected raw handle. | ||
| * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/net.js#L971 | ||
| */ | ||
| this.socket._handle.getsockname = (addressInfo) => { | ||
| Object.assign(addressInfo, getLocalAddressInfoByConnectionOptions(this.#connectionOptions)); | ||
| return 0; | ||
| }; | ||
| /** | ||
| * @note Reflect the connection target as the peer of the claimed | ||
| * socket, the same way a connected socket reports the server it | ||
| * connected to via "remoteAddress"/"remotePort"/"remoteFamily". | ||
| */ | ||
| this.socket._handle.getpeername = (addressInfo) => { | ||
| Object.assign(addressInfo, getAddressInfoByConnectionOptions(this.#connectionOptions)); | ||
| return 0; | ||
| }; | ||
| this.#bufferedWrites = []; | ||
| this.socket._pendingData = null; | ||
| this.socket._pendingEncoding = ""; | ||
| this.pendingConnection.promise.then(([request, handle]) => { | ||
| logger$1.verbose("connection request resolved, mocking the connection..."); | ||
| /** | ||
| * @note "afterConnect" asserts that the socket is connecting. | ||
| * Restore the flag if the connect was emulated earlier (emulation | ||
| * flips it so its listeners observe a connected socket). | ||
| * "afterConnect" itself sets it back to false. | ||
| */ | ||
| if (this.#connectEmulated) Reflect.set(this.socket, "connecting", true); | ||
| /** | ||
| * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 | ||
| */ | ||
| request.oncomplete(0, handle, request, true, true); | ||
| }); | ||
| } | ||
| passthrough(flushPendingData) { | ||
| super.passthrough(); | ||
| logger$1.verbose("-> passthrough!"); | ||
| const createRealSocket = () => { | ||
| const realSocket = this.#retargetedConnectionOptions ? this.#createRetargetedConnection(this.#retargetedConnectionOptions) : this.createConnection(); | ||
| realSocket[kPatched] = true; | ||
| if (this.socket.timeout != null) realSocket.setTimeout(this.socket.timeout); | ||
| return realSocket; | ||
| }; | ||
| const realSocket = this.#passthroughSocket && !this.#passthroughSocket.destroyed ? this.#passthroughSocket : createRealSocket(); | ||
| if (realSocket !== this.#passthroughSocket) this.#passthroughSocket = realSocket; | ||
| if (this.#bufferedWrites.length === 0) logger$1.verbose("passthrough with empty writes buffer (state: %d)", this.readyState); | ||
| /** | ||
| * Flush any writes during the pending phase to the passthrough socket. | ||
| * @note These are written directly on the passthrough socket to prevent | ||
| * them from being forwarded as "data" events on the server (already emitted). | ||
| */ | ||
| for (let i = 0; i < this.#bufferedWrites.length; i++) { | ||
| const pendingWrite = this.#bufferedWrites[i]; | ||
| if (i === 0 && typeof flushPendingData === "function") { | ||
| const data = pendingWrite[1]; | ||
| const encoding = pendingWrite[2]; | ||
| flushPendingData(data, encoding, (nextData) => { | ||
| pendingWrite[1] = nextData; | ||
| }); | ||
| } | ||
| const [, data, encoding, callback] = pendingWrite; | ||
| writePendingData(realSocket, data, encoding, callback); | ||
| } | ||
| this.#bufferedWrites = []; | ||
| this.socket._pendingData = null; | ||
| this.socket._pendingEncoding = ""; | ||
| this.socket.address = realSocket.address.bind(realSocket); | ||
| this.socket.removeListener("drain", this.#onMockSocketDrain); | ||
| this.socket.on("drain", this.#onMockSocketDrain); | ||
| realSocket.removeListener("connect", this.#onRealSocketConnect).removeListener("connectionAttemptFailed", this.#onRealSocketConnectionAttemptFailed).removeListener("connectionAttemptTimeout", this.#onRealSocketConnectionAttemptTimeout).removeListener("data", this.#onRealSocketData).removeListener("error", this.#onRealSocketError).removeListener("end", this.#onRealSocketEnd).removeListener("close", this.#onRealSocketClose); | ||
| realSocket.once("connect", this.#onRealSocketConnect).on("connectionAttemptFailed", this.#onRealSocketConnectionAttemptFailed).on("connectionAttemptTimeout", this.#onRealSocketConnectionAttemptTimeout).on("data", this.#onRealSocketData).on("error", this.#onRealSocketError).on("end", this.#onRealSocketEnd).on("close", this.#onRealSocketClose); | ||
| /** | ||
| * @note Forward the client's half-close, unless the real handle | ||
| * is already swapped in — the client's own shutdown then reaches | ||
| * the real connection natively (see "#forwardClientFinish"). | ||
| */ | ||
| if (!this.#realHandleSwapped) if (this.socket.writableFinished) this.#forwardClientFinish(); | ||
| else { | ||
| this.socket.removeListener("finish", this.#forwardClientFinish); | ||
| this.socket.once("finish", this.#forwardClientFinish); | ||
| } | ||
| return realSocket; | ||
| } | ||
| /** | ||
| * Create the passthrough connection to the target this controller | ||
| * was retargeted to (see `reset()`). The original `createConnection` | ||
| * dials the originally requested target and cannot serve retargeted | ||
| * exchanges. | ||
| */ | ||
| #createRetargetedConnection(connectionOptions) { | ||
| const realSocket = new net.Socket(); | ||
| /** | ||
| * @note Mark the socket as patched before connecting: the patched | ||
| * "Socket.prototype.connect" exempts such sockets, establishing | ||
| * the connection for real. | ||
| */ | ||
| realSocket[kPatched] = true; | ||
| return realSocket.connect(connectionOptions); | ||
| } | ||
| }; | ||
| var TlsSocketController = class extends TcpSocketController { | ||
| /** | ||
| * @note The TLS connection options must be provided explicitly. | ||
| * They cannot be captured from "socket.connect()" like for plain | ||
| * TCP sockets because "tls.connect()" fixes them at the TLS socket | ||
| * construction, before its transport ever connects. | ||
| */ | ||
| #tlsConnectionOptions; | ||
| constructor(socket, createConnection, tlsConnectionOptions) { | ||
| super(socket, createConnection, tlsConnectionOptions); | ||
| this.socket = socket; | ||
| this.createConnection = createConnection; | ||
| this.#tlsConnectionOptions = tlsConnectionOptions; | ||
| socket.prependListener("secureConnect", () => { | ||
| /** | ||
| * @note Reflect the negotiated ALPN protocol from the handle that | ||
| * completed the handshake. The socket sets "alpnProtocol" only in | ||
| * its own "_finishInit", which never runs for passthrough | ||
| * connections (the handshake completes on the passthrough socket | ||
| * whose handle this socket inherits). | ||
| */ | ||
| socket.alpnProtocol = socket._handle.getALPNNegotiatedProtocol(); | ||
| }); | ||
| } | ||
| emulateConnect() { | ||
| super.emulateConnect(); | ||
| for (const listener of this.socket.rawListeners("secureConnect")) listener.apply(this.socket); | ||
| } | ||
| claim() { | ||
| /** | ||
| * @note The client may destroy the socket before the connection | ||
| * is claimed. Defer to the parent class, which transitions the | ||
| * ready state and skips the mock connection of destroyed sockets. | ||
| */ | ||
| if (this.socket.destroyed) { | ||
| super.claim(); | ||
| return; | ||
| } | ||
| /** | ||
| * @note Reflect that the mocked connection is not authorized. | ||
| * There is no real peer certificate to verify. The identity check | ||
| * itself is skipped for claimed connections (see the | ||
| * "isSessionReused" mock below). | ||
| */ | ||
| this.socket.prependOnceListener("secureConnect", () => { | ||
| Reflect.set(this.socket, "authorized", false); | ||
| Reflect.set(this.socket, "authorizationError", "MOCKED_CONNECTION_NOT_VERIFIED"); | ||
| }); | ||
| const handle = this.socket._handle; | ||
| handle.start = () => void 0; | ||
| /** | ||
| * Mock this to prevent the "Error: Worker exited unexpectedly" error. | ||
| * This will trigger when "secure" is emitted. | ||
| * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 | ||
| */ | ||
| handle.verifyError = () => void 0; | ||
| handle.getSession = () => { | ||
| return Buffer.from("mocked session"); | ||
| }; | ||
| /** | ||
| * @note Skip the server identity check for this mocked connection. | ||
| * Node.js runs "options.checkServerIdentity" in "onConnectSecure" | ||
| * against the peer certificate, and a mocked connection has no | ||
| * real peer certificate — the caller's (or the default) validation | ||
| * would fail and destroy the socket. The check is overridden on the | ||
| * socket's internal connect options since the emulated handshake | ||
| * still completes through the regular "onConnectSecure" path. | ||
| * @see https://github.com/nodejs/node/blob/3178a762d6a2b1a37b74f02266eea0f3d86603f1/lib/_tls_wrap.js#L1621 | ||
| */ | ||
| const realTlsConnectOptions = getTlsConnectOptions(this.socket); | ||
| if (realTlsConnectOptions) realTlsConnectOptions.checkServerIdentity = () => {}; | ||
| handle.getCipher = () => { | ||
| return { | ||
| name: "TLS_AES_256_GCM_SHA384", | ||
| standardName: "TLS_AES_256_GCM_SHA384", | ||
| version: "TLSv1.3" | ||
| }; | ||
| }; | ||
| /** | ||
| * Mock this to prevent a segfault on Node.js 26+. The native | ||
| * implementation reads the negotiated group ("SSL_get0_group_name") | ||
| * of a handshake that never happened. Node.js itself calls this | ||
| * in "onConnectSecure" to validate the "minDHSize" option. | ||
| * Reflect the ephemeral key exchange matching the mocked cipher. | ||
| * @see https://github.com/nodejs/node/blob/3178a762d6a2b1a37b74f02266eea0f3d86603f1/lib/_tls_wrap.js#L1636 | ||
| */ | ||
| handle.getEphemeralKeyInfo = () => { | ||
| return { | ||
| type: "ECDH", | ||
| name: "X25519", | ||
| size: 253 | ||
| }; | ||
| }; | ||
| const requestedAlpnProtocols = this.#tlsConnectionOptions?.ALPNProtocols; | ||
| if (Array.isArray(requestedAlpnProtocols) && requestedAlpnProtocols.length > 0) { | ||
| const [preferredProtocol] = requestedAlpnProtocols; | ||
| /** | ||
| * @note Reflect the client's preferred ALPN protocol as the | ||
| * negotiated one. The mocked server accepts whatever the | ||
| * client prefers. | ||
| */ | ||
| handle.getALPNNegotiatedProtocol = () => { | ||
| return typeof preferredProtocol === "string" ? preferredProtocol : false; | ||
| }; | ||
| } | ||
| this.socket.once("connect", () => { | ||
| /** | ||
| * @note A TLS 1.3 handshake derives five secrets, each reported | ||
| * via a separate "keylog" event before the handshake completes. | ||
| * Reflect them with mocked key material matching the mocked | ||
| * cipher (SHA-384 secrets; the format is "LABEL <client random> | ||
| * <secret>", each value hex-encoded). | ||
| */ | ||
| const mockedClientRandom = "0".repeat(64); | ||
| const mockedSecret = "0".repeat(96); | ||
| for (const keylogLabel of [ | ||
| "SERVER_HANDSHAKE_TRAFFIC_SECRET", | ||
| "EXPORTER_SECRET", | ||
| "SERVER_TRAFFIC_SECRET_0", | ||
| "CLIENT_HANDSHAKE_TRAFFIC_SECRET", | ||
| "CLIENT_TRAFFIC_SECRET_0" | ||
| ]) this.socket.emit("keylog", Buffer.from(`${keylogLabel} ${mockedClientRandom} ${mockedSecret}\n`)); | ||
| handle.onhandshakedone(); | ||
| /** | ||
| * @note A TLS 1.3 server issues two session tickets by default, | ||
| * each emitting a separate "session" event on the client. | ||
| */ | ||
| handle.onnewsession(1, Buffer.from("mocked session")); | ||
| handle.onnewsession(2, Buffer.from("mocked session")); | ||
| }); | ||
| super.claim(); | ||
| } | ||
| passthrough(flushPendingData) { | ||
| const realSocket = super.passthrough(flushPendingData); | ||
| /** | ||
| * @note Remove the internal "connect" listener added by the TLS socket. | ||
| * Normally, that listener manages the SSL handshake. But since we're in passthrough, | ||
| * we delegate that to the real socket. Leaving the listener on the mock socket while | ||
| * inheriting the real socket's handle will result in the handshake performed twice, which is a no-op. | ||
| * @see https://github.com/nodejs/node/blob/abddfc921bf2af02a04a6a5d2bca8e2d91d80958/lib/internal/tls/wrap.js#L1105 | ||
| * | ||
| * This prevents the following error: | ||
| * # node (vitest 4)[8686]: static void node::crypto::TLSWrap::Start(const FunctionCallbackInfo<Value> &) at ../src/crypto/crypto_tls.cc:589 | ||
| # Assertion failed: !wrap->started_ | ||
| */ | ||
| for (const connectListener of this.socket.listeners("connect")) if (connectListener === this.socket._start || "listener" in connectListener && connectListener.listener === this.socket._start) this.socket.removeListener("connect", connectListener); | ||
| realSocket.on("secure", () => { | ||
| this.socket.emit("secure"); | ||
| }).on("session", (...args) => { | ||
| this.socket.emit("session", ...args); | ||
| }).on("keylog", (...args) => { | ||
| this.socket.emit("keylog", ...args); | ||
| }).on("OCSPResponse", (...args) => { | ||
| this.socket.emit("OCSPResponse", ...args); | ||
| }); | ||
| return realSocket; | ||
| } | ||
| }; | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/normalize-tls-connect-args.ts | ||
| function normalizeTlsConnectArgs(args) { | ||
| /** | ||
| * @note Despite incorrect type definitions, "tls.connect()" has all the | ||
| * options of "net.connect()" and then those specific to TLS connections, | ||
| * like "session" or "socket". | ||
| * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1615 | ||
| */ | ||
| const netConnectArgs = normalizeNetConnectArgs(args); | ||
| const options = netConnectArgs[0]; | ||
| const callback = netConnectArgs[1]; | ||
| if (args[0] !== null && typeof args[0] === "object") Object.assign(options, args[0]); | ||
| else if (args[1] !== null && typeof args[1] === "object") Object.assign(options, args[1]); | ||
| else if (args[2] !== null && typeof args[2] === "object") Object.assign(options, args[2]); | ||
| return callback ? [options, callback] : [options]; | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/net/index.ts | ||
| var SocketConnectionEvent = class extends TypedEvent { | ||
| constructor(data) { | ||
| super(...["connection", {}]); | ||
| this.socket = data.socket; | ||
| this.connectionOptions = data.connectionOptions; | ||
| this.controller = data.controller; | ||
| } | ||
| }; | ||
| const logger = createLogger("socket"); | ||
| /** | ||
| * A DNS lookup function for intercepted sockets. It always succeeds, | ||
| * resolving any hostname to the loopback address. This ensures the | ||
| * "lookup"/"connectionAttempt" socket events fire even for non-existent | ||
| * hosts, and no real DNS resolution is performed. Passthrough | ||
| * connections are created with the original options and use the real | ||
| * (or the caller's custom) lookup instead. | ||
| */ | ||
| const mockLookup = (hostname, dnsOptions, callback) => { | ||
| const family = dnsOptions.family === 6 ? 6 : 4; | ||
| const address = family === 6 ? "::1" : "127.0.0.1"; | ||
| /** | ||
| * @note Call back asynchronously since DNS lookup is always | ||
| * asynchronous in Node.js. Calling back synchronously emits | ||
| * the "lookup"/"connectionAttempt" socket events before the | ||
| * consumer gets a chance to add listeners for them. | ||
| */ | ||
| process.nextTick(() => { | ||
| /** | ||
| * @note Honor the Node.js lookup contract: the callback receives | ||
| * an array of addresses only when the "all" option is set | ||
| * (e.g. during the family autoselection). Otherwise, it receives | ||
| * a single address and its family. Node.js rejects an array in | ||
| * the latter case with "ERR_INVALID_IP_ADDRESS". | ||
| */ | ||
| if (dnsOptions.all) { | ||
| callback(null, [{ | ||
| address, | ||
| family | ||
| }]); | ||
| return; | ||
| } | ||
| callback(null, address, family); | ||
| }); | ||
| }; | ||
| /** | ||
| * Interceptor for `net.Socket` connections. | ||
| */ | ||
| var SocketInterceptor = class extends Interceptor { | ||
| static { | ||
| this.symbol = Symbol.for("socket-interceptor"); | ||
| } | ||
| predicate() { | ||
| return true; | ||
| } | ||
| setup() { | ||
| const interceptor = this; | ||
| /** | ||
| * @note A synchronous re-entrancy latch for creating passthrough | ||
| * TLS connections. The original "tls.connect()" constructs a TLS | ||
| * socket and calls "socket.connect()" on it synchronously, and that | ||
| * call must reach Node.js as-is instead of being intercepted again. | ||
| */ | ||
| let isCreatingPassthroughConnection = false; | ||
| this.subscriptions.push( | ||
| /** | ||
| * @note Intercept connections at the "net.Socket.prototype.connect" | ||
| * level instead of patching the "net.connect()" module function. | ||
| * ESM consumers snapshot the module bindings at import time | ||
| * ("import * as net from 'node:net'"), so reassigning "net.connect" | ||
| * is invisible to them. Every client connection ends up calling | ||
| * "Socket.prototype.connect" (including the one made by the original | ||
| * "net.connect()"), and prototype mutations are visible regardless | ||
| * of how the module was imported. | ||
| */ | ||
| patchesRegistry.applyPatch(net.Socket.prototype, "connect", (realSocketConnect) => { | ||
| return function connect(...args) { | ||
| const socket = this; | ||
| /** | ||
| * @note Skip the sockets this interceptor already controls | ||
| * (the mock connect call below, re-connects of an intercepted | ||
| * socket, passthrough sockets) and the sockets created while | ||
| * dialing a passthrough TLS connection. Their connects must | ||
| * reach Node.js as-is. | ||
| */ | ||
| if (socket[kPatched] || isCreatingPassthroughConnection) return realSocketConnect.apply(socket, args); | ||
| logger.verbose("socket.connect() %o", args); | ||
| /** | ||
| * @note The original "net.connect()"/"tls.connect()" normalize | ||
| * the arguments themselves and call this method with the | ||
| * normalized array instead of the individual arguments. | ||
| */ | ||
| const connectArgs = Array.isArray(args[0]) ? args[0] : args; | ||
| const [transportConnectionOptions, connectionCallback] = normalizeNetConnectArgs(connectArgs); | ||
| logger.verbose("connection options %o", { | ||
| transportConnectionOptions, | ||
| connectionCallback | ||
| }); | ||
| let controller; | ||
| let connectionOptions; | ||
| if (socket instanceof tls.TLSSocket) { | ||
| /** | ||
| * @note TLS sockets arrive here from the original | ||
| * "tls.connect()", which connects the transport of the TLS | ||
| * socket it constructs ("connectionCallback" is its internal | ||
| * "_start" handshake trigger). The original TLS connection | ||
| * options are recovered from the socket instance since the | ||
| * construction has already happened. | ||
| */ | ||
| const realTlsConnectionOptions = getTlsConnectOptions(socket); | ||
| const [tlsConnectionOptions] = normalizeTlsConnectArgs([{ | ||
| ...transportConnectionOptions, | ||
| ...realTlsConnectionOptions | ||
| }]); | ||
| connectionOptions = tlsConnectionOptions; | ||
| controller = new TlsSocketController(socket, () => { | ||
| /** | ||
| * @note Create the passthrough connection via the original | ||
| * "tls.connect()" with the original connection options | ||
| * (the real DNS lookup and the caller's certificate | ||
| * validation included). The latch exempts the transport | ||
| * connect of that connection from interception. | ||
| */ | ||
| isCreatingPassthroughConnection = true; | ||
| try { | ||
| return tls.connect(realTlsConnectionOptions ?? tlsConnectionOptions); | ||
| } finally { | ||
| isCreatingPassthroughConnection = false; | ||
| } | ||
| }, tlsConnectionOptions); | ||
| } else { | ||
| /** | ||
| * @note Create passthrough connections without the connection | ||
| * callback. The callback is already registered as a "connect" | ||
| * listener on the consumer's socket. Passing it to the | ||
| * passthrough connection would invoke it twice. | ||
| */ | ||
| const passthroughArgs = connectArgs.filter((arg) => { | ||
| return typeof arg !== "function"; | ||
| }); | ||
| /** | ||
| * @note Create the passthrough socket with the original | ||
| * options, the same way "net.connect()" does. Connect it via | ||
| * the unpatched method so the passthrough connection is not | ||
| * intercepted again. | ||
| */ | ||
| const socketOptions = connectArgs[0] !== null && typeof connectArgs[0] === "object" && !("href" in connectArgs[0]) ? connectArgs[0] : {}; | ||
| connectionOptions = transportConnectionOptions; | ||
| controller = new TcpSocketController(socket, () => { | ||
| const passthroughSocket = new net.Socket(socketOptions); | ||
| Reflect.apply(realSocketConnect, passthroughSocket, passthroughArgs); | ||
| return passthroughSocket; | ||
| }); | ||
| } | ||
| process.nextTick(() => { | ||
| if (socket.destroyed) return; | ||
| /** | ||
| * @note Expect a verdict on this connection from every | ||
| * "connection" listener before emitting the event. With no | ||
| * listeners to claim the connection (or once every listener | ||
| * declines it), the controller passes it through as-is. | ||
| */ | ||
| controller.awaitVerdicts(interceptor.listenerCount("connection")); | ||
| interceptor.emitter.emit(new SocketConnectionEvent({ | ||
| socket: controller.serverSocket, | ||
| controller, | ||
| connectionOptions | ||
| })); | ||
| logger.verbose("emitted \"connection\" event!"); | ||
| }); | ||
| logger.verbose("connecting the socket..."); | ||
| /** | ||
| * @note The requested local address/port are stripped from the | ||
| * actual "socket.connect()" call by the controller to prevent | ||
| * binding the intercepted socket (see the "connect" proxy). | ||
| */ | ||
| const mockConnectionOptions = { ...transportConnectionOptions }; | ||
| mockConnectionOptions.lookup = mockLookup; | ||
| try { | ||
| /** | ||
| * @note The normalized options are looser than the declared | ||
| * "SocketConnectOpts" (e.g. the port may be a string when a | ||
| * URL is passed). Node.js validates them at runtime. | ||
| * This call goes through the controller's "connect" proxy | ||
| * and lands back in this patch, where the "kPatched" check | ||
| * above delegates it to the unpatched method. | ||
| */ | ||
| return socket.connect(mockConnectionOptions, connectionCallback ?? void 0); | ||
| } catch (error) { | ||
| /** | ||
| * @note "socket.connect()" can throw synchronously on invalid | ||
| * input (e.g. a bad port). Destroy the socket so the pending | ||
| * interception tick does not act on it, then let the error | ||
| * propagate to the consumer like in Node.js. | ||
| */ | ||
| socket.destroy(); | ||
| throw error; | ||
| } | ||
| }; | ||
| }), | ||
| this.#stopReusingUnpatchedSockets() | ||
| ); | ||
| /** | ||
| * @note "net.connect()"/"net.createConnection()" need no patching of | ||
| * their own: both construct a "net.Socket" and call the patched | ||
| * "Socket.prototype.connect" on it. | ||
| */ | ||
| } | ||
| /** | ||
| * Prevent the `net.Socket` instances created before this interceptor | ||
| * was applied from getting reused by an `Agent`. Purging them from the | ||
| * keep-alive pool forces the agent to establish new (intercepted) | ||
| * connections instead. | ||
| */ | ||
| #stopReusingUnpatchedSockets() { | ||
| /** | ||
| * @note "Agent.prototype.addRequest" is undocumented but stable: | ||
| * its signature changed once (the keep-alive Agent rewrite in | ||
| * Node.js 0.12), and the ecosystem (agent-base, agentkeepalive, | ||
| * APM wrappers) has relied on it ever since. "https.Agent" | ||
| * inherits it, so a single patch covers both. | ||
| */ | ||
| if (typeof http.Agent.prototype.addRequest !== "function") return () => {}; | ||
| return patchesRegistry.applyPatch(http.Agent.prototype, "addRequest", (realAddRequest) => { | ||
| return function(...args) { | ||
| /** | ||
| * @note Destroy the free sockets created before the interceptor | ||
| * was applied. Destroying flips their "destroyed" state | ||
| * synchronously, so the original "addRequest" below discards | ||
| * them and dials a new (intercepted) connection instead. | ||
| * The "freeSockets" pool only contains idle sockets by | ||
| * definition, so destroying them aborts nothing in-flight. | ||
| */ | ||
| for (const sockets of Object.values(this.freeSockets)) { | ||
| if (sockets == null) continue; | ||
| for (const socket of sockets) if (!socket[kPatched]) socket.destroy(); | ||
| } | ||
| return realAddRequest?.apply(this, args); | ||
| }; | ||
| }); | ||
| } | ||
| }; | ||
| //#endregion | ||
| export { getDeepPropertyDescriptor as a, unwrapPendingData as i, SocketController as n, patchesRegistry as o, kRawSocket as r, SocketInterceptor as t }; | ||
| //# sourceMappingURL=net-9sRKnjIG.js.map |
Sorry, the diff of this file is too big to display
| import { a as createLogger, i as Interceptor, o as formatRequest, r as toBuffer } from "./buffer-utils-BvPY1Tc-.js"; | ||
| import { a as isResponseError, c as isObject, d as createRequestId, f as RequestController, l as getRawFetchHeaders, n as FetchResponse, o as isResponseLike, p as InterceptorError, r as createServerErrorResponse, s as kErrorResponse, t as FetchRequest, u as recordRawFetchHeaders } from "./fetch-utils-Dw1PsmtX.js"; | ||
| import { i as unwrapPendingData, n as SocketController, r as kRawSocket, t as SocketInterceptor } from "./net-9sRKnjIG.js"; | ||
| import { TypedEvent } from "rettime"; | ||
| import { invariant } from "outvariant"; | ||
| import { IncomingMessage, METHODS, STATUS_CODES, ServerResponse } from "node:http"; | ||
| import { AsyncLocalStorage } from "node:async_hooks"; | ||
| import net from "node:net"; | ||
| import tls from "node:tls"; | ||
| import { Readable } from "node:stream"; | ||
| import fs from "node:fs"; | ||
| import { until } from "@open-draft/until"; | ||
| //#region \0rolldown/runtime.js | ||
| var __create = Object.create; | ||
| var __defProp = Object.defineProperty; | ||
| var __getOwnPropDesc = Object.getOwnPropertyDescriptor; | ||
| var __getOwnPropNames = Object.getOwnPropertyNames; | ||
| var __getProtoOf = Object.getPrototypeOf; | ||
| var __hasOwnProp = Object.prototype.hasOwnProperty; | ||
| var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports); | ||
| var __copyProps = (to, from, except, desc) => { | ||
| if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { | ||
| key = keys[i]; | ||
| if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { | ||
| get: ((k) => from[k]).bind(null, key), | ||
| enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable | ||
| }); | ||
| } | ||
| return to; | ||
| }; | ||
| var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { | ||
| value: mod, | ||
| enumerable: true | ||
| }) : target, mod)); | ||
| //#endregion | ||
| //#region src/request-context.ts | ||
| const requestContext = new AsyncLocalStorage(); | ||
| function runInRequestContext(callback, logger) { | ||
| /** | ||
| * @note Never shadow an existing request context. Nested calls | ||
| * (e.g. a patched entry point re-entered synchronously, or a request | ||
| * made within the fetch/XMLHttpRequest interceptor context) must run | ||
| * within the parent context so the sockets they create capture it. | ||
| */ | ||
| if (requestContext.getStore()) return callback(); | ||
| /** | ||
| * @note The initiator is the callback's return value (e.g. the | ||
| * "ClientRequest" instance), so it cannot be known before running | ||
| * the callback. The context is mutated in place once the callback | ||
| * returns; readers hold the context object by reference and sample | ||
| * "initiator" only after the request has been written. | ||
| */ | ||
| const context = { | ||
| initiator: void 0, | ||
| logger | ||
| }; | ||
| return requestContext.run(context, () => { | ||
| const initiator = callback(); | ||
| context.initiator = initiator; | ||
| return initiator; | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/http/forward-events.ts | ||
| const logger = createLogger("http-request"); | ||
| function forwardHttpEvents(options) { | ||
| const controller = new AbortController(); | ||
| const { source, emitter, predicate, responsePredicate } = options; | ||
| source.on("request", async (event) => { | ||
| if (predicate(event.initiator)) { | ||
| logger.verbose("forwarding \"request\" event %o", { requestId: event.requestId }); | ||
| await emitter.emitAsPromise(event); | ||
| } | ||
| }, { signal: controller.signal }); | ||
| const responseListener = async (event) => { | ||
| if (predicate(event.initiator) && (responsePredicate == null || responsePredicate(event))) { | ||
| logger.verbose("forwarding \"response\" event %o", { | ||
| requestId: event.requestId, | ||
| responseType: event.responseType | ||
| }); | ||
| await emitter.emitAsPromise(event); | ||
| } | ||
| }; | ||
| const unhandledExceptionListener = async (event) => { | ||
| if (predicate(event.initiator)) { | ||
| logger.verbose("forwarding \"unhandledException\" event %o", { requestId: event.requestId }); | ||
| await emitter.emitAsPromise(event); | ||
| } | ||
| }; | ||
| const addResponseListener = () => { | ||
| if (!source.listeners("response").includes(responseListener)) source.on("response", responseListener, { signal: controller.signal }); | ||
| }; | ||
| const addUnhandledExceptionListener = () => { | ||
| if (!source.listeners("unhandledException").includes(unhandledExceptionListener)) source.on("unhandledException", unhandledExceptionListener, { signal: controller.signal }); | ||
| }; | ||
| if (emitter.listenerCount("response") > 0) addResponseListener(); | ||
| if (emitter.listenerCount("unhandledException") > 0) addUnhandledExceptionListener(); | ||
| emitter.hooks.on("newListener", (type) => { | ||
| if (type === "response") addResponseListener(); | ||
| if (type === "unhandledException") addUnhandledExceptionListener(); | ||
| }, { | ||
| signal: controller.signal, | ||
| persist: true | ||
| }); | ||
| emitter.hooks.on("removeListener", (type) => { | ||
| if (type === "response" && emitter.listenerCount("response") === 0) source.removeListener("response", responseListener); | ||
| if (type === "unhandledException" && emitter.listenerCount("unhandledException") === 0) source.removeListener("unhandledException", unhandledExceptionListener); | ||
| }, { | ||
| signal: controller.signal, | ||
| persist: true | ||
| }); | ||
| return () => { | ||
| controller.abort(); | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/events/http.ts | ||
| var HttpRequestEvent = class extends TypedEvent { | ||
| constructor(data) { | ||
| super(...["request", {}]); | ||
| this.request = data.request; | ||
| this.requestId = data.requestId; | ||
| this.initiator = data.initiator; | ||
| this.controller = data.controller; | ||
| } | ||
| }; | ||
| var HttpResponseEvent = class extends TypedEvent { | ||
| constructor(data) { | ||
| super(...["response", {}]); | ||
| this.response = data.response; | ||
| this.responseType = data.responseType; | ||
| this.request = data.request; | ||
| this.requestId = data.requestId; | ||
| this.initiator = data.initiator; | ||
| } | ||
| }; | ||
| var UnhandledHttpException = class extends TypedEvent { | ||
| constructor(data) { | ||
| super(...["unhandledException", {}]); | ||
| this.error = data.error; | ||
| this.request = data.request; | ||
| this.requestId = data.requestId; | ||
| this.initiator = data.initiator; | ||
| this.controller = data.controller; | ||
| } | ||
| }; | ||
| //#endregion | ||
| //#region src/interceptors/net/utils/connection-options-to-url.ts | ||
| /** | ||
| * Creates a `URL` instance out of the `net.connect()` options. | ||
| * @note This implies that the passed connection is an HTTP connection. | ||
| */ | ||
| function connectionOptionsToUrl(options, socket) { | ||
| const isIPv6 = net.isIPv6(options.host || ""); | ||
| const protocol = socket instanceof tls.TLSSocket ? "https:" : getProtocolByConnectionOptions(options); | ||
| const host = options.host || "localhost"; | ||
| const url = new URL(`${protocol}//${isIPv6 ? `[${host}]` : host}`); | ||
| if (options.path) url.pathname = options.path; | ||
| if (options.port) url.port = options.port.toString(); | ||
| if (options.auth) { | ||
| const [username, password] = options.auth.split(":"); | ||
| /** | ||
| * Authentication options are provided as plain values. | ||
| * Encode them to form a valid URL. | ||
| * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/url.js#L1452 | ||
| */ | ||
| url.username = encodeURIComponent(username); | ||
| url.password = encodeURIComponent(password); | ||
| } | ||
| return url; | ||
| } | ||
| function getProtocolByConnectionOptions(options) { | ||
| if (options.protocol) return options.protocol; | ||
| if (options.port === 443) return "https:"; | ||
| return "http:"; | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/http/http-parser/index.ts | ||
| var import_constants = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports) => { | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.SPECIAL_HEADERS = exports.MINOR = exports.MAJOR = exports.QDTEXT = exports.CONNECTION_TOKEN_CHARS = exports.RELAXED_HEADER_CHARS = exports.HEADER_CHARS = exports.HTAB_SP_VCHAR_OBS_TEXT = exports.SP = exports.HTAB = exports.TOKEN = exports.HEX = exports.URL_CHAR = exports.USERINFO_CHARS = exports.MARK = exports.ALPHANUM = exports.DIGIT = exports.HEX_MAP = exports.NUM_MAP = exports.ALPHA = exports.METHODS = exports.METHODS_HTTP = exports.METHODS_HTTP2 = exports.METHODS_HTTP1 = exports.METHODS_RTSP = exports.METHODS_RAOP = exports.METHODS_AIRPLAY = exports.METHODS_ICECAST = exports.METHODS_NON_STANDARD = exports.METHODS_CALDAV = exports.METHODS_UPNP = exports.METHODS_SUBVERSION = exports.METHODS_WEBDAV = exports.METHODS_BASIC_HTTP = exports.METHODS_HTTP1_HEAD = exports.HEADER_STATE = exports.FINISH = exports.STATUSES = exports.LENIENT_FLAGS = exports.FLAGS = exports.TYPE = exports.ERROR = void 0; | ||
| exports.ERROR = { | ||
| OK: 0, | ||
| INTERNAL: 1, | ||
| STRICT: 2, | ||
| CR_EXPECTED: 25, | ||
| LF_EXPECTED: 3, | ||
| UNEXPECTED_CONTENT_LENGTH: 4, | ||
| UNEXPECTED_SPACE: 30, | ||
| CLOSED_CONNECTION: 5, | ||
| INVALID_METHOD: 6, | ||
| INVALID_URL: 7, | ||
| INVALID_CONSTANT: 8, | ||
| INVALID_VERSION: 9, | ||
| INVALID_HEADER_TOKEN: 10, | ||
| INVALID_CONTENT_LENGTH: 11, | ||
| INVALID_CHUNK_SIZE: 12, | ||
| INVALID_STATUS: 13, | ||
| INVALID_EOF_STATE: 14, | ||
| INVALID_TRANSFER_ENCODING: 15, | ||
| CB_MESSAGE_BEGIN: 16, | ||
| CB_HEADERS_COMPLETE: 17, | ||
| CB_MESSAGE_COMPLETE: 18, | ||
| CB_CHUNK_HEADER: 19, | ||
| CB_CHUNK_COMPLETE: 20, | ||
| PAUSED: 21, | ||
| PAUSED_UPGRADE: 22, | ||
| PAUSED_H2_UPGRADE: 23, | ||
| USER: 24, | ||
| CB_URL_COMPLETE: 26, | ||
| CB_STATUS_COMPLETE: 27, | ||
| CB_METHOD_COMPLETE: 32, | ||
| CB_VERSION_COMPLETE: 33, | ||
| CB_HEADER_FIELD_COMPLETE: 28, | ||
| CB_HEADER_VALUE_COMPLETE: 29, | ||
| CB_CHUNK_EXTENSION_NAME_COMPLETE: 34, | ||
| CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35, | ||
| CB_RESET: 31, | ||
| CB_PROTOCOL_COMPLETE: 38 | ||
| }; | ||
| exports.TYPE = { | ||
| BOTH: 0, | ||
| REQUEST: 1, | ||
| RESPONSE: 2 | ||
| }; | ||
| exports.FLAGS = { | ||
| CONNECTION_KEEP_ALIVE: 1, | ||
| CONNECTION_CLOSE: 2, | ||
| CONNECTION_UPGRADE: 4, | ||
| CHUNKED: 8, | ||
| UPGRADE: 16, | ||
| CONTENT_LENGTH: 32, | ||
| SKIPBODY: 64, | ||
| TRAILING: 128, | ||
| TRANSFER_ENCODING: 512 | ||
| }; | ||
| exports.LENIENT_FLAGS = { | ||
| HEADERS: 1, | ||
| CHUNKED_LENGTH: 2, | ||
| KEEP_ALIVE: 4, | ||
| TRANSFER_ENCODING: 8, | ||
| VERSION: 16, | ||
| DATA_AFTER_CLOSE: 32, | ||
| OPTIONAL_LF_AFTER_CR: 64, | ||
| OPTIONAL_CRLF_AFTER_CHUNK: 128, | ||
| OPTIONAL_CR_BEFORE_LF: 256, | ||
| SPACES_AFTER_CHUNK_SIZE: 512, | ||
| HEADER_VALUE_RELAXED: 1024 | ||
| }; | ||
| exports.STATUSES = { | ||
| CONTINUE: 100, | ||
| SWITCHING_PROTOCOLS: 101, | ||
| PROCESSING: 102, | ||
| EARLY_HINTS: 103, | ||
| RESPONSE_IS_STALE: 110, | ||
| REVALIDATION_FAILED: 111, | ||
| DISCONNECTED_OPERATION: 112, | ||
| HEURISTIC_EXPIRATION: 113, | ||
| MISCELLANEOUS_WARNING: 199, | ||
| OK: 200, | ||
| CREATED: 201, | ||
| ACCEPTED: 202, | ||
| NON_AUTHORITATIVE_INFORMATION: 203, | ||
| NO_CONTENT: 204, | ||
| RESET_CONTENT: 205, | ||
| PARTIAL_CONTENT: 206, | ||
| MULTI_STATUS: 207, | ||
| ALREADY_REPORTED: 208, | ||
| TRANSFORMATION_APPLIED: 214, | ||
| IM_USED: 226, | ||
| MISCELLANEOUS_PERSISTENT_WARNING: 299, | ||
| MULTIPLE_CHOICES: 300, | ||
| MOVED_PERMANENTLY: 301, | ||
| FOUND: 302, | ||
| SEE_OTHER: 303, | ||
| NOT_MODIFIED: 304, | ||
| USE_PROXY: 305, | ||
| SWITCH_PROXY: 306, | ||
| TEMPORARY_REDIRECT: 307, | ||
| PERMANENT_REDIRECT: 308, | ||
| BAD_REQUEST: 400, | ||
| UNAUTHORIZED: 401, | ||
| PAYMENT_REQUIRED: 402, | ||
| FORBIDDEN: 403, | ||
| NOT_FOUND: 404, | ||
| METHOD_NOT_ALLOWED: 405, | ||
| NOT_ACCEPTABLE: 406, | ||
| PROXY_AUTHENTICATION_REQUIRED: 407, | ||
| REQUEST_TIMEOUT: 408, | ||
| CONFLICT: 409, | ||
| GONE: 410, | ||
| LENGTH_REQUIRED: 411, | ||
| PRECONDITION_FAILED: 412, | ||
| PAYLOAD_TOO_LARGE: 413, | ||
| URI_TOO_LONG: 414, | ||
| UNSUPPORTED_MEDIA_TYPE: 415, | ||
| RANGE_NOT_SATISFIABLE: 416, | ||
| EXPECTATION_FAILED: 417, | ||
| IM_A_TEAPOT: 418, | ||
| PAGE_EXPIRED: 419, | ||
| ENHANCE_YOUR_CALM: 420, | ||
| MISDIRECTED_REQUEST: 421, | ||
| UNPROCESSABLE_ENTITY: 422, | ||
| LOCKED: 423, | ||
| FAILED_DEPENDENCY: 424, | ||
| TOO_EARLY: 425, | ||
| UPGRADE_REQUIRED: 426, | ||
| PRECONDITION_REQUIRED: 428, | ||
| TOO_MANY_REQUESTS: 429, | ||
| REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL: 430, | ||
| REQUEST_HEADER_FIELDS_TOO_LARGE: 431, | ||
| LOGIN_TIMEOUT: 440, | ||
| NO_RESPONSE: 444, | ||
| RETRY_WITH: 449, | ||
| BLOCKED_BY_PARENTAL_CONTROL: 450, | ||
| UNAVAILABLE_FOR_LEGAL_REASONS: 451, | ||
| CLIENT_CLOSED_LOAD_BALANCED_REQUEST: 460, | ||
| INVALID_X_FORWARDED_FOR: 463, | ||
| REQUEST_HEADER_TOO_LARGE: 494, | ||
| SSL_CERTIFICATE_ERROR: 495, | ||
| SSL_CERTIFICATE_REQUIRED: 496, | ||
| HTTP_REQUEST_SENT_TO_HTTPS_PORT: 497, | ||
| INVALID_TOKEN: 498, | ||
| CLIENT_CLOSED_REQUEST: 499, | ||
| INTERNAL_SERVER_ERROR: 500, | ||
| NOT_IMPLEMENTED: 501, | ||
| BAD_GATEWAY: 502, | ||
| SERVICE_UNAVAILABLE: 503, | ||
| GATEWAY_TIMEOUT: 504, | ||
| HTTP_VERSION_NOT_SUPPORTED: 505, | ||
| VARIANT_ALSO_NEGOTIATES: 506, | ||
| INSUFFICIENT_STORAGE: 507, | ||
| LOOP_DETECTED: 508, | ||
| BANDWIDTH_LIMIT_EXCEEDED: 509, | ||
| NOT_EXTENDED: 510, | ||
| NETWORK_AUTHENTICATION_REQUIRED: 511, | ||
| WEB_SERVER_UNKNOWN_ERROR: 520, | ||
| WEB_SERVER_IS_DOWN: 521, | ||
| CONNECTION_TIMEOUT: 522, | ||
| ORIGIN_IS_UNREACHABLE: 523, | ||
| TIMEOUT_OCCURED: 524, | ||
| SSL_HANDSHAKE_FAILED: 525, | ||
| INVALID_SSL_CERTIFICATE: 526, | ||
| RAILGUN_ERROR: 527, | ||
| SITE_IS_OVERLOADED: 529, | ||
| SITE_IS_FROZEN: 530, | ||
| IDENTITY_PROVIDER_AUTHENTICATION_ERROR: 561, | ||
| NETWORK_READ_TIMEOUT: 598, | ||
| NETWORK_CONNECT_TIMEOUT: 599 | ||
| }; | ||
| exports.FINISH = { | ||
| SAFE: 0, | ||
| SAFE_WITH_CB: 1, | ||
| UNSAFE: 2 | ||
| }; | ||
| exports.HEADER_STATE = { | ||
| GENERAL: 0, | ||
| CONNECTION: 1, | ||
| CONTENT_LENGTH: 2, | ||
| TRANSFER_ENCODING: 3, | ||
| UPGRADE: 4, | ||
| CONNECTION_KEEP_ALIVE: 5, | ||
| CONNECTION_CLOSE: 6, | ||
| CONNECTION_UPGRADE: 7, | ||
| TRANSFER_ENCODING_CHUNKED: 8 | ||
| }; | ||
| exports.METHODS_HTTP1_HEAD = { HEAD: 2 }; | ||
| /** | ||
| * HTTP methods as defined by RFC-9110 and other specifications. | ||
| * @see https://httpwg.org/specs/rfc9110.html#method.definitions | ||
| */ | ||
| exports.METHODS_BASIC_HTTP = { | ||
| DELETE: 0, | ||
| GET: 1, | ||
| ...exports.METHODS_HTTP1_HEAD, | ||
| POST: 3, | ||
| PUT: 4, | ||
| CONNECT: 5, | ||
| OPTIONS: 6, | ||
| TRACE: 7, | ||
| /** | ||
| * @see https://www.rfc-editor.org/rfc/rfc5789.html | ||
| */ | ||
| PATCH: 28, | ||
| LINK: 31, | ||
| UNLINK: 32 | ||
| }; | ||
| exports.METHODS_WEBDAV = { | ||
| COPY: 8, | ||
| LOCK: 9, | ||
| MKCOL: 10, | ||
| MOVE: 11, | ||
| PROPFIND: 12, | ||
| PROPPATCH: 13, | ||
| SEARCH: 14, | ||
| UNLOCK: 15, | ||
| BIND: 16, | ||
| REBIND: 17, | ||
| UNBIND: 18, | ||
| ACL: 19 | ||
| }; | ||
| exports.METHODS_SUBVERSION = { | ||
| REPORT: 20, | ||
| MKACTIVITY: 21, | ||
| CHECKOUT: 22, | ||
| MERGE: 23 | ||
| }; | ||
| exports.METHODS_UPNP = { | ||
| "M-SEARCH": 24, | ||
| NOTIFY: 25, | ||
| SUBSCRIBE: 26, | ||
| UNSUBSCRIBE: 27 | ||
| }; | ||
| exports.METHODS_CALDAV = { MKCALENDAR: 30 }; | ||
| exports.METHODS_NON_STANDARD = { | ||
| /** | ||
| * Not defined in any RFC but commonly used | ||
| */ | ||
| PURGE: 29, | ||
| QUERY: 46 | ||
| }; | ||
| exports.METHODS_ICECAST = { SOURCE: 33 }; | ||
| exports.METHODS_AIRPLAY = { | ||
| GET: 1, | ||
| POST: 3 | ||
| }; | ||
| exports.METHODS_RAOP = { FLUSH: 45 }; | ||
| exports.METHODS_RTSP = { | ||
| OPTIONS: exports.METHODS_BASIC_HTTP.OPTIONS, | ||
| DESCRIBE: 35, | ||
| ANNOUNCE: 36, | ||
| SETUP: 37, | ||
| PLAY: 38, | ||
| PAUSE: 39, | ||
| TEARDOWN: 40, | ||
| GET_PARAMETER: 41, | ||
| SET_PARAMETER: 42, | ||
| REDIRECT: 43, | ||
| RECORD: 44, | ||
| ...exports.METHODS_AIRPLAY, | ||
| ...exports.METHODS_RAOP | ||
| }; | ||
| exports.METHODS_HTTP1 = { | ||
| ...exports.METHODS_BASIC_HTTP, | ||
| ...exports.METHODS_WEBDAV, | ||
| ...exports.METHODS_SUBVERSION, | ||
| ...exports.METHODS_UPNP, | ||
| ...exports.METHODS_CALDAV, | ||
| ...exports.METHODS_NON_STANDARD, | ||
| ...exports.METHODS_ICECAST | ||
| }; | ||
| exports.METHODS_HTTP2 = { | ||
| /** | ||
| * RFC-9113, section 11.6 | ||
| * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface | ||
| */ | ||
| PRI: 34 }; | ||
| exports.METHODS_HTTP = { | ||
| ...exports.METHODS_HTTP1, | ||
| ...exports.METHODS_HTTP2 | ||
| }; | ||
| exports.METHODS = { | ||
| ...exports.METHODS_HTTP1, | ||
| ...exports.METHODS_HTTP2, | ||
| ...exports.METHODS_RTSP | ||
| }; | ||
| exports.ALPHA = [ | ||
| "A", | ||
| "a", | ||
| "B", | ||
| "b", | ||
| "C", | ||
| "c", | ||
| "D", | ||
| "d", | ||
| "E", | ||
| "e", | ||
| "F", | ||
| "f", | ||
| "G", | ||
| "g", | ||
| "H", | ||
| "h", | ||
| "I", | ||
| "i", | ||
| "J", | ||
| "j", | ||
| "K", | ||
| "k", | ||
| "L", | ||
| "l", | ||
| "M", | ||
| "m", | ||
| "N", | ||
| "n", | ||
| "O", | ||
| "o", | ||
| "P", | ||
| "p", | ||
| "Q", | ||
| "q", | ||
| "R", | ||
| "r", | ||
| "S", | ||
| "s", | ||
| "T", | ||
| "t", | ||
| "U", | ||
| "u", | ||
| "V", | ||
| "v", | ||
| "W", | ||
| "w", | ||
| "X", | ||
| "x", | ||
| "Y", | ||
| "y", | ||
| "Z", | ||
| "z" | ||
| ]; | ||
| exports.NUM_MAP = { | ||
| 0: 0, | ||
| 1: 1, | ||
| 2: 2, | ||
| 3: 3, | ||
| 4: 4, | ||
| 5: 5, | ||
| 6: 6, | ||
| 7: 7, | ||
| 8: 8, | ||
| 9: 9 | ||
| }; | ||
| exports.HEX_MAP = { | ||
| 0: 0, | ||
| 1: 1, | ||
| 2: 2, | ||
| 3: 3, | ||
| 4: 4, | ||
| 5: 5, | ||
| 6: 6, | ||
| 7: 7, | ||
| 8: 8, | ||
| 9: 9, | ||
| A: 10, | ||
| B: 11, | ||
| C: 12, | ||
| D: 13, | ||
| E: 14, | ||
| F: 15, | ||
| a: 10, | ||
| b: 11, | ||
| c: 12, | ||
| d: 13, | ||
| e: 14, | ||
| f: 15 | ||
| }; | ||
| exports.DIGIT = [ | ||
| "0", | ||
| "1", | ||
| "2", | ||
| "3", | ||
| "4", | ||
| "5", | ||
| "6", | ||
| "7", | ||
| "8", | ||
| "9" | ||
| ]; | ||
| exports.ALPHANUM = [...exports.ALPHA, ...exports.DIGIT]; | ||
| exports.MARK = [ | ||
| "-", | ||
| "_", | ||
| ".", | ||
| "!", | ||
| "~", | ||
| "*", | ||
| "'", | ||
| "(", | ||
| ")" | ||
| ]; | ||
| exports.USERINFO_CHARS = [ | ||
| ...exports.ALPHANUM, | ||
| ...exports.MARK, | ||
| "%", | ||
| ";", | ||
| ":", | ||
| "&", | ||
| "=", | ||
| "+", | ||
| "$", | ||
| "," | ||
| ]; | ||
| exports.URL_CHAR = [ | ||
| "!", | ||
| "\"", | ||
| "$", | ||
| "%", | ||
| "&", | ||
| "'", | ||
| "(", | ||
| ")", | ||
| "*", | ||
| "+", | ||
| ",", | ||
| "-", | ||
| ".", | ||
| "/", | ||
| ":", | ||
| ";", | ||
| "<", | ||
| "=", | ||
| ">", | ||
| "@", | ||
| "[", | ||
| "\\", | ||
| "]", | ||
| "^", | ||
| "_", | ||
| "`", | ||
| "{", | ||
| "|", | ||
| "}", | ||
| "~", | ||
| ...exports.ALPHANUM | ||
| ]; | ||
| exports.HEX = [ | ||
| ...exports.DIGIT, | ||
| "a", | ||
| "b", | ||
| "c", | ||
| "d", | ||
| "e", | ||
| "f", | ||
| "A", | ||
| "B", | ||
| "C", | ||
| "D", | ||
| "E", | ||
| "F" | ||
| ]; | ||
| exports.TOKEN = [ | ||
| "!", | ||
| "#", | ||
| "$", | ||
| "%", | ||
| "&", | ||
| "'", | ||
| "*", | ||
| "+", | ||
| "-", | ||
| ".", | ||
| "^", | ||
| "_", | ||
| "`", | ||
| "|", | ||
| "~", | ||
| ...exports.ALPHANUM | ||
| ]; | ||
| exports.HTAB = [" "]; | ||
| exports.SP = [" "]; | ||
| const VCHAR = [ | ||
| 33, | ||
| 34, | ||
| 35, | ||
| 36, | ||
| 37, | ||
| 38, | ||
| 39, | ||
| 40, | ||
| 41, | ||
| 42, | ||
| 43, | ||
| 44, | ||
| 45, | ||
| 46, | ||
| 47, | ||
| 48, | ||
| 49, | ||
| 50, | ||
| 51, | ||
| 52, | ||
| 53, | ||
| 54, | ||
| 55, | ||
| 56, | ||
| 57, | ||
| 58, | ||
| 59, | ||
| 60, | ||
| 61, | ||
| 62, | ||
| 63, | ||
| 64, | ||
| 65, | ||
| 66, | ||
| 67, | ||
| 68, | ||
| 69, | ||
| 70, | ||
| 71, | ||
| 72, | ||
| 73, | ||
| 74, | ||
| 75, | ||
| 76, | ||
| 77, | ||
| 78, | ||
| 79, | ||
| 80, | ||
| 81, | ||
| 82, | ||
| 83, | ||
| 84, | ||
| 85, | ||
| 86, | ||
| 87, | ||
| 88, | ||
| 89, | ||
| 90, | ||
| 91, | ||
| 92, | ||
| 93, | ||
| 94, | ||
| 95, | ||
| 96, | ||
| 97, | ||
| 98, | ||
| 99, | ||
| 100, | ||
| 101, | ||
| 102, | ||
| 103, | ||
| 104, | ||
| 105, | ||
| 106, | ||
| 107, | ||
| 108, | ||
| 109, | ||
| 110, | ||
| 111, | ||
| 112, | ||
| 113, | ||
| 114, | ||
| 115, | ||
| 116, | ||
| 117, | ||
| 118, | ||
| 119, | ||
| 120, | ||
| 121, | ||
| 122, | ||
| 123, | ||
| 124, | ||
| 125, | ||
| 126 | ||
| ]; | ||
| const OBS_TEXT = [ | ||
| 128, | ||
| 129, | ||
| 130, | ||
| 131, | ||
| 132, | ||
| 133, | ||
| 134, | ||
| 135, | ||
| 136, | ||
| 137, | ||
| 138, | ||
| 139, | ||
| 140, | ||
| 141, | ||
| 142, | ||
| 143, | ||
| 144, | ||
| 145, | ||
| 146, | ||
| 147, | ||
| 148, | ||
| 149, | ||
| 150, | ||
| 151, | ||
| 152, | ||
| 153, | ||
| 154, | ||
| 155, | ||
| 156, | ||
| 157, | ||
| 158, | ||
| 159, | ||
| 160, | ||
| 161, | ||
| 162, | ||
| 163, | ||
| 164, | ||
| 165, | ||
| 166, | ||
| 167, | ||
| 168, | ||
| 169, | ||
| 170, | ||
| 171, | ||
| 172, | ||
| 173, | ||
| 174, | ||
| 175, | ||
| 176, | ||
| 177, | ||
| 178, | ||
| 179, | ||
| 180, | ||
| 181, | ||
| 182, | ||
| 183, | ||
| 184, | ||
| 185, | ||
| 186, | ||
| 187, | ||
| 188, | ||
| 189, | ||
| 190, | ||
| 191, | ||
| 192, | ||
| 193, | ||
| 194, | ||
| 195, | ||
| 196, | ||
| 197, | ||
| 198, | ||
| 199, | ||
| 200, | ||
| 201, | ||
| 202, | ||
| 203, | ||
| 204, | ||
| 205, | ||
| 206, | ||
| 207, | ||
| 208, | ||
| 209, | ||
| 210, | ||
| 211, | ||
| 212, | ||
| 213, | ||
| 214, | ||
| 215, | ||
| 216, | ||
| 217, | ||
| 218, | ||
| 219, | ||
| 220, | ||
| 221, | ||
| 222, | ||
| 223, | ||
| 224, | ||
| 225, | ||
| 226, | ||
| 227, | ||
| 228, | ||
| 229, | ||
| 230, | ||
| 231, | ||
| 232, | ||
| 233, | ||
| 234, | ||
| 235, | ||
| 236, | ||
| 237, | ||
| 238, | ||
| 239, | ||
| 240, | ||
| 241, | ||
| 242, | ||
| 243, | ||
| 244, | ||
| 245, | ||
| 246, | ||
| 247, | ||
| 248, | ||
| 249, | ||
| 250, | ||
| 251, | ||
| 252, | ||
| 253, | ||
| 254, | ||
| 255 | ||
| ]; | ||
| exports.HTAB_SP_VCHAR_OBS_TEXT = [ | ||
| ...exports.HTAB, | ||
| ...exports.SP, | ||
| ...VCHAR, | ||
| ...OBS_TEXT | ||
| ]; | ||
| exports.HEADER_CHARS = exports.HTAB_SP_VCHAR_OBS_TEXT; | ||
| exports.RELAXED_HEADER_CHARS = [...[ | ||
| 1, | ||
| 2, | ||
| 3, | ||
| 4, | ||
| 5, | ||
| 6, | ||
| 7, | ||
| 8, | ||
| 11, | ||
| 12, | ||
| 14, | ||
| 15, | ||
| 16, | ||
| 17, | ||
| 18, | ||
| 19, | ||
| 20, | ||
| 21, | ||
| 22, | ||
| 23, | ||
| 24, | ||
| 25, | ||
| 26, | ||
| 27, | ||
| 28, | ||
| 29, | ||
| 30, | ||
| 31, | ||
| 127 | ||
| ], ...exports.HEADER_CHARS]; | ||
| exports.CONNECTION_TOKEN_CHARS = [ | ||
| ...exports.HTAB, | ||
| ...exports.SP, | ||
| 33, | ||
| 34, | ||
| 35, | ||
| 36, | ||
| 37, | ||
| 38, | ||
| 39, | ||
| 40, | ||
| 41, | ||
| 42, | ||
| 43, | ||
| 45, | ||
| 46, | ||
| 47, | ||
| 48, | ||
| 49, | ||
| 50, | ||
| 51, | ||
| 52, | ||
| 53, | ||
| 54, | ||
| 55, | ||
| 56, | ||
| 57, | ||
| 58, | ||
| 59, | ||
| 60, | ||
| 61, | ||
| 62, | ||
| 63, | ||
| 64, | ||
| 65, | ||
| 66, | ||
| 67, | ||
| 68, | ||
| 69, | ||
| 70, | ||
| 71, | ||
| 72, | ||
| 73, | ||
| 74, | ||
| 75, | ||
| 76, | ||
| 77, | ||
| 78, | ||
| 79, | ||
| 80, | ||
| 81, | ||
| 82, | ||
| 83, | ||
| 84, | ||
| 85, | ||
| 86, | ||
| 87, | ||
| 88, | ||
| 89, | ||
| 90, | ||
| 91, | ||
| 92, | ||
| 93, | ||
| 94, | ||
| 95, | ||
| 96, | ||
| 97, | ||
| 98, | ||
| 99, | ||
| 100, | ||
| 101, | ||
| 102, | ||
| 103, | ||
| 104, | ||
| 105, | ||
| 106, | ||
| 107, | ||
| 108, | ||
| 109, | ||
| 110, | ||
| 111, | ||
| 112, | ||
| 113, | ||
| 114, | ||
| 115, | ||
| 116, | ||
| 117, | ||
| 118, | ||
| 119, | ||
| 120, | ||
| 121, | ||
| 122, | ||
| 123, | ||
| 124, | ||
| 125, | ||
| 126, | ||
| ...OBS_TEXT | ||
| ]; | ||
| exports.QDTEXT = [ | ||
| ...exports.HTAB, | ||
| ...exports.SP, | ||
| 33, | ||
| 35, | ||
| 36, | ||
| 37, | ||
| 38, | ||
| 39, | ||
| 40, | ||
| 41, | ||
| 42, | ||
| 43, | ||
| 44, | ||
| 45, | ||
| 46, | ||
| 47, | ||
| 48, | ||
| 49, | ||
| 50, | ||
| 51, | ||
| 52, | ||
| 53, | ||
| 54, | ||
| 55, | ||
| 56, | ||
| 57, | ||
| 58, | ||
| 59, | ||
| 60, | ||
| 61, | ||
| 62, | ||
| 63, | ||
| 64, | ||
| 65, | ||
| 66, | ||
| 67, | ||
| 68, | ||
| 69, | ||
| 70, | ||
| 71, | ||
| 72, | ||
| 73, | ||
| 74, | ||
| 75, | ||
| 76, | ||
| 77, | ||
| 78, | ||
| 79, | ||
| 80, | ||
| 81, | ||
| 82, | ||
| 83, | ||
| 84, | ||
| 85, | ||
| 86, | ||
| 87, | ||
| 88, | ||
| 89, | ||
| 90, | ||
| 91, | ||
| 93, | ||
| 94, | ||
| 95, | ||
| 96, | ||
| 97, | ||
| 98, | ||
| 99, | ||
| 100, | ||
| 101, | ||
| 102, | ||
| 103, | ||
| 104, | ||
| 105, | ||
| 106, | ||
| 107, | ||
| 108, | ||
| 109, | ||
| 110, | ||
| 111, | ||
| 112, | ||
| 113, | ||
| 114, | ||
| 115, | ||
| 116, | ||
| 117, | ||
| 118, | ||
| 119, | ||
| 120, | ||
| 121, | ||
| 122, | ||
| 123, | ||
| 124, | ||
| 125, | ||
| 126, | ||
| ...OBS_TEXT | ||
| ]; | ||
| exports.MAJOR = exports.NUM_MAP; | ||
| exports.MINOR = exports.MAJOR; | ||
| exports.SPECIAL_HEADERS = { | ||
| "connection": exports.HEADER_STATE.CONNECTION, | ||
| "content-length": exports.HEADER_STATE.CONTENT_LENGTH, | ||
| "proxy-connection": exports.HEADER_STATE.CONNECTION, | ||
| "transfer-encoding": exports.HEADER_STATE.TRANSFER_ENCODING, | ||
| "upgrade": exports.HEADER_STATE.UPGRADE | ||
| }; | ||
| exports.default = { | ||
| ERROR: exports.ERROR, | ||
| TYPE: exports.TYPE, | ||
| FLAGS: exports.FLAGS, | ||
| LENIENT_FLAGS: exports.LENIENT_FLAGS, | ||
| STATUSES: exports.STATUSES, | ||
| FINISH: exports.FINISH, | ||
| HEADER_STATE: exports.HEADER_STATE, | ||
| ALPHA: exports.ALPHA, | ||
| NUM_MAP: exports.NUM_MAP, | ||
| HEX_MAP: exports.HEX_MAP, | ||
| DIGIT: exports.DIGIT, | ||
| ALPHANUM: exports.ALPHANUM, | ||
| MARK: exports.MARK, | ||
| USERINFO_CHARS: exports.USERINFO_CHARS, | ||
| URL_CHAR: exports.URL_CHAR, | ||
| HEX: exports.HEX, | ||
| TOKEN: exports.TOKEN, | ||
| HEADER_CHARS: exports.HEADER_CHARS, | ||
| RELAXED_HEADER_CHARS: exports.RELAXED_HEADER_CHARS, | ||
| CONNECTION_TOKEN_CHARS: exports.CONNECTION_TOKEN_CHARS, | ||
| QDTEXT: exports.QDTEXT, | ||
| HTAB_SP_VCHAR_OBS_TEXT: exports.HTAB_SP_VCHAR_OBS_TEXT, | ||
| MAJOR: exports.MAJOR, | ||
| MINOR: exports.MINOR, | ||
| SPECIAL_HEADERS: exports.SPECIAL_HEADERS, | ||
| METHODS: exports.METHODS, | ||
| METHODS_HTTP: exports.METHODS_HTTP, | ||
| METHODS_HTTP1_HEAD: exports.METHODS_HTTP1_HEAD, | ||
| METHODS_HTTP1: exports.METHODS_HTTP1, | ||
| METHODS_HTTP2: exports.METHODS_HTTP2, | ||
| METHODS_ICECAST: exports.METHODS_ICECAST, | ||
| METHODS_RTSP: exports.METHODS_RTSP | ||
| }; | ||
| })))(), 1); | ||
| const KIND_REQUEST = import_constants.TYPE.REQUEST; | ||
| import_constants.TYPE.RESPONSE; | ||
| const kPointer = Symbol("kPtr"); | ||
| const kUrl = Symbol("kUrl"); | ||
| const kStatusMessage = Symbol("kStatusMessage"); | ||
| const kHeadersFields = Symbol("kHeadersFields"); | ||
| const kHeadersValues = Symbol("kHeadersValues"); | ||
| const kLastHeaderCallback = Symbol("kLastHeaderCallback"); | ||
| const kCallbacks = Symbol("kCallbacks"); | ||
| const kType = Symbol("kType"); | ||
| const HEADER_CB_NONE = 0; | ||
| const HEADER_CB_FIELD = 1; | ||
| const HEADER_CB_VALUE = 2; | ||
| const parsersMap = /* @__PURE__ */ new Map(); | ||
| const methodNames = Object.fromEntries(Object.entries(import_constants.METHODS).map(([name, num]) => [num, name])); | ||
| function readStringFrom(pointer, length) { | ||
| return Buffer.from(llhttp_memory.buffer, pointer, length).toString("latin1"); | ||
| } | ||
| /** | ||
| * @note Reference the base URL through a variable. Bundlers (e.g. Vite) | ||
| * statically rewrite the `new URL('...', import.meta.url)` pattern into | ||
| * an asset URL resolved against the served origin, which breaks reading | ||
| * the WASM binary from the file system in DOM-like test environments. | ||
| */ | ||
| const wasmBaseUrl = import.meta.url; | ||
| const llhttpModule = new WebAssembly.Module(fs.readFileSync(new URL("./llhttp/llhttp.wasm", wasmBaseUrl))); | ||
| const llhttpInstance = new WebAssembly.Instance(llhttpModule, { env: { | ||
| wasm_on_message_begin(parserPointer) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| parser[kUrl] = ""; | ||
| parser[kStatusMessage] = ""; | ||
| parser[kHeadersFields] = []; | ||
| parser[kHeadersValues] = []; | ||
| parser[kLastHeaderCallback] = HEADER_CB_NONE; | ||
| return parser[kCallbacks].onMessageBegin?.() ?? 0; | ||
| }, | ||
| wasm_on_url(parserPointer, at, length) { | ||
| parsersMap.get(parserPointer)[kUrl] = readStringFrom(at, length); | ||
| return 0; | ||
| }, | ||
| wasm_on_status(parserPointer, at, length) { | ||
| parsersMap.get(parserPointer)[kStatusMessage] = readStringFrom(at, length); | ||
| return 0; | ||
| }, | ||
| wasm_on_header_field(parserPointer, at, length) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| const chunk = readStringFrom(at, length); | ||
| const fields = parser[kHeadersFields]; | ||
| if (parser[kLastHeaderCallback] === HEADER_CB_FIELD) fields[fields.length - 1] += chunk; | ||
| else { | ||
| fields.push(chunk); | ||
| parser[kLastHeaderCallback] = HEADER_CB_FIELD; | ||
| } | ||
| return 0; | ||
| }, | ||
| wasm_on_header_value(parserPointer, at, length) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| const chunk = readStringFrom(at, length); | ||
| const values = parser[kHeadersValues]; | ||
| if (parser[kLastHeaderCallback] === HEADER_CB_VALUE) values[values.length - 1] += chunk; | ||
| else { | ||
| values.push(chunk); | ||
| parser[kLastHeaderCallback] = HEADER_CB_VALUE; | ||
| } | ||
| return 0; | ||
| }, | ||
| wasm_on_headers_complete(parserPointer, statusCode, rawUpgrade, rawShouldKeepAlive) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| const versionMajor = llhttp_get_version_major(parserPointer); | ||
| const versionMinor = llhttp_get_version_minor(parserPointer); | ||
| const rawHeaders = []; | ||
| const upgrade = rawUpgrade === 1; | ||
| const shouldKeepAlive = rawShouldKeepAlive === 1; | ||
| for (let c = 0; c < parser[kHeadersFields].length; c++) rawHeaders.push(parser[kHeadersFields][c], parser[kHeadersValues][c]); | ||
| if (parser[kType] === KIND_REQUEST) { | ||
| const method = methodNames[llhttp_get_method(parserPointer)]; | ||
| const url = parser[kUrl]; | ||
| return parser[kCallbacks].onHeadersComplete?.({ | ||
| versionMajor, | ||
| versionMinor, | ||
| rawHeaders, | ||
| method, | ||
| url, | ||
| upgrade, | ||
| shouldKeepAlive | ||
| }) ?? 0; | ||
| } else { | ||
| const statusCode = llhttp_get_status_code(parserPointer); | ||
| const statusMessage = parser[kStatusMessage]; | ||
| return parser[kCallbacks].onHeadersComplete?.({ | ||
| versionMajor, | ||
| versionMinor, | ||
| rawHeaders, | ||
| statusCode, | ||
| statusMessage, | ||
| upgrade, | ||
| shouldKeepAlive | ||
| }) ?? 0; | ||
| } | ||
| }, | ||
| wasm_on_body(parserPointer, at, length) { | ||
| const parser = parsersMap.get(parserPointer); | ||
| const body = Buffer.from(new Uint8Array(llhttp_memory.buffer, at, length)); | ||
| return parser[kCallbacks].onBody?.(body) ?? 0; | ||
| }, | ||
| wasm_on_message_complete(parserPointer) { | ||
| return parsersMap.get(parserPointer)[kCallbacks].onMessageComplete?.() ?? 0; | ||
| } | ||
| } }); | ||
| const llhttp_memory = llhttpInstance.exports.memory; | ||
| const llhttp_alloc = llhttpInstance.exports.llhttp_alloc; | ||
| const llhttp_malloc = llhttpInstance.exports.malloc; | ||
| const llhttp_execute = llhttpInstance.exports.llhttp_execute; | ||
| llhttpInstance.exports.llhttp_get_type; | ||
| llhttpInstance.exports.llhttp_get_upgrade; | ||
| llhttpInstance.exports.llhttp_should_keep_alive; | ||
| const llhttp_get_method = llhttpInstance.exports.llhttp_get_method; | ||
| const llhttp_get_status_code = llhttpInstance.exports.llhttp_get_status_code; | ||
| const llhttp_get_version_minor = llhttpInstance.exports.llhttp_get_http_minor; | ||
| const llhttp_get_version_major = llhttpInstance.exports.llhttp_get_http_major; | ||
| const llhttp_get_error_reason = llhttpInstance.exports.llhttp_get_error_reason; | ||
| const llhttp_get_error_pos = llhttpInstance.exports.llhttp_get_error_pos; | ||
| const llhttp_free = llhttpInstance.exports.free; | ||
| const initialize = llhttpInstance.exports._initialize; | ||
| initialize(); | ||
| var HttpParser = class { | ||
| constructor(type, callbacks) { | ||
| this[kUrl] = ""; | ||
| this[kStatusMessage] = ""; | ||
| this[kHeadersFields] = []; | ||
| this[kHeadersValues] = []; | ||
| this[kLastHeaderCallback] = HEADER_CB_NONE; | ||
| this[kType] = type; | ||
| this[kCallbacks] = callbacks; | ||
| const parserPointer = llhttp_alloc(type); | ||
| if (parserPointer === 0) throw new Error("Failed to allocate llhttp parser"); | ||
| this[kPointer] = parserPointer; | ||
| parsersMap.set(this[kPointer], this); | ||
| } | ||
| destroy() { | ||
| if (this[kPointer] === 0) return; | ||
| parsersMap.delete(this[kPointer]); | ||
| llhttp_free(this[kPointer]); | ||
| this[kPointer] = 0; | ||
| } | ||
| execute(data) { | ||
| const pointer = llhttp_malloc(data.byteLength); | ||
| if (pointer === 0) throw new Error("Failed to allocate llhttp input buffer"); | ||
| let ret; | ||
| try { | ||
| new Uint8Array(llhttp_memory.buffer).set(data, pointer); | ||
| ret = llhttp_execute(this[kPointer], pointer, data.byteLength); | ||
| } catch (error) { | ||
| llhttp_free(pointer); | ||
| throw error; | ||
| } | ||
| if (ret === import_constants.ERROR.PAUSED_UPGRADE) { | ||
| const consumed = llhttp_get_error_pos(this[kPointer]) - pointer; | ||
| llhttp_free(pointer); | ||
| return data.subarray(consumed); | ||
| } | ||
| llhttp_free(pointer); | ||
| this.#checkError(ret); | ||
| return null; | ||
| } | ||
| #checkError(errorCode) { | ||
| if (errorCode === import_constants.ERROR.OK) return; | ||
| const errorPointer = llhttp_get_error_reason(this[kPointer]); | ||
| const length = new Uint8Array(llhttp_memory.buffer).indexOf(0, errorPointer) - errorPointer; | ||
| throw new Error(readStringFrom(errorPointer, length)); | ||
| } | ||
| }; | ||
| //#endregion | ||
| //#region src/interceptors/http/http-parser.ts | ||
| var HttpRequestParser = class extends HttpParser { | ||
| #requestBodyStream; | ||
| constructor(options) { | ||
| super(1, { | ||
| onHeadersComplete: ({ rawHeaders, method, url: path }) => { | ||
| /** | ||
| * @note When the socket is reused, "connectionOptions" will point | ||
| * to the "net.connect()" call options that established the connection, | ||
| * which may differ from the description of the current request (e.g. method). | ||
| * Rely on the HTTPParser supplying us with the correct "rawMethod" number. | ||
| */ | ||
| const finalMethod = (method || options.connectionOptions.method || "GET").toUpperCase(); | ||
| const url = new URL(path || "", options.connectionOptions.url); | ||
| const headers = FetchResponse.parseRawHeaders([...rawHeaders]); | ||
| if (url.username || url.password) { | ||
| if (!headers.has("authorization")) { | ||
| const credentials = Buffer.from(`${url.username}:${url.password}`).toString("base64"); | ||
| headers.set("authorization", `Basic ${credentials}`); | ||
| } | ||
| url.username = ""; | ||
| url.password = ""; | ||
| } | ||
| this.#requestBodyStream = new Readable({ | ||
| /** | ||
| * @note Provide the `read()` method so a `Readable` could be | ||
| * used as the actual request body (the stream calls "read()"). | ||
| */ | ||
| read: () => {} }); | ||
| /** | ||
| * @note Expose an abort controller for the parsed request so the | ||
| * consumer can abort it (e.g. when the client destroys the | ||
| * connection before the request is handled). | ||
| */ | ||
| const abortController = new AbortController(); | ||
| const request = new FetchRequest(url, { | ||
| method: finalMethod, | ||
| headers, | ||
| credentials: "same-origin", | ||
| body: Readable.toWeb(this.#requestBodyStream), | ||
| signal: abortController.signal | ||
| }); | ||
| options.onRequest(request, abortController); | ||
| }, | ||
| onBody: (chunk) => { | ||
| invariant(this.#requestBodyStream, "Failed to write to a request stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub."); | ||
| this.#requestBodyStream.push(chunk); | ||
| }, | ||
| onMessageComplete: () => { | ||
| this.#requestBodyStream?.push(null); | ||
| } | ||
| }); | ||
| } | ||
| free() { | ||
| this.destroy(); | ||
| this.#requestBodyStream?.destroy(); | ||
| this.#requestBodyStream = void 0; | ||
| } | ||
| }; | ||
| var HttpResponseParser = class extends HttpParser { | ||
| #responseBodyStream; | ||
| constructor(options) { | ||
| super(2, { | ||
| onHeadersComplete: ({ rawHeaders, statusCode: status, statusMessage: statusText }) => { | ||
| const headers = FetchResponse.parseRawHeaders([...rawHeaders]); | ||
| const response = new FetchResponse(FetchResponse.isResponseWithBody(status) ? Readable.toWeb(this.#responseBodyStream = new Readable({ read() {} })) : null, { | ||
| status, | ||
| statusText, | ||
| headers | ||
| }); | ||
| options.onResponse(response); | ||
| }, | ||
| onBody: (chunk) => { | ||
| invariant(this.#responseBodyStream, "Failed to read from a response stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub."); | ||
| this.#responseBodyStream.push(chunk); | ||
| }, | ||
| onMessageComplete: () => { | ||
| this.#responseBodyStream?.push(null); | ||
| } | ||
| }); | ||
| } | ||
| free() { | ||
| this.destroy(); | ||
| this.#responseBodyStream = null; | ||
| } | ||
| }; | ||
| //#endregion | ||
| //#region src/utils/is-node-like-error.ts | ||
| function isNodeLikeError(error) { | ||
| if (error == null) return false; | ||
| if (!(error instanceof Error)) return false; | ||
| return "code" in error && "errno" in error; | ||
| } | ||
| //#endregion | ||
| //#region src/utils/handle-request.ts | ||
| async function handleRequest(options) { | ||
| if (options.logger?.isEnabled("default")) formatRequest(options.request).then((message) => { | ||
| options.logger?.info("[%s] %s", options.requestId, message); | ||
| }); | ||
| const handleResponse = async (response) => { | ||
| if (response instanceof Error) { | ||
| await options.controller.errorWith(response); | ||
| return true; | ||
| } | ||
| if (isResponseError(response)) { | ||
| await options.controller.respondWith(response); | ||
| return true; | ||
| } | ||
| /** | ||
| * Handle normal responses or response-like objects. | ||
| * @note This must come before the arbitrary object check | ||
| * since Response instances are, in fact, objects. | ||
| */ | ||
| if (isResponseLike(response)) { | ||
| await options.controller.respondWith(response); | ||
| return true; | ||
| } | ||
| if (isObject(response)) { | ||
| await options.controller.errorWith(response); | ||
| return true; | ||
| } | ||
| return false; | ||
| }; | ||
| const handleResponseError = async (error) => { | ||
| if (error instanceof InterceptorError) throw resultError; | ||
| if (isNodeLikeError(error)) { | ||
| await options.controller.errorWith(error); | ||
| return true; | ||
| } | ||
| if (error instanceof Response) return await handleResponse(error); | ||
| return false; | ||
| }; | ||
| const requestAbortPromise = Promise.withResolvers(); | ||
| let requestAbortReason; | ||
| let isRequestAborted = false; | ||
| const onAbort = () => { | ||
| isRequestAborted = true; | ||
| requestAbortReason = options.request.signal?.reason; | ||
| requestAbortPromise.reject(requestAbortReason); | ||
| }; | ||
| /** | ||
| * @note `signal` is not always defined in React Native. | ||
| */ | ||
| if (options.request.signal) { | ||
| if (options.request.signal.aborted) { | ||
| await options.controller.errorWith(options.request.signal.reason); | ||
| return; | ||
| } | ||
| options.request.signal.addEventListener("abort", onAbort, { once: true }); | ||
| } | ||
| const [resultError] = await until(async () => { | ||
| const requestEvent = new HttpRequestEvent({ | ||
| initiator: options.initiator, | ||
| requestId: options.requestId, | ||
| request: options.request, | ||
| controller: options.controller | ||
| }); | ||
| const requestListenersPromise = options.emitter.emitAsPromise(requestEvent); | ||
| await Promise.race([ | ||
| requestAbortPromise.promise, | ||
| requestListenersPromise, | ||
| options.controller.handled | ||
| ]); | ||
| /** | ||
| * @note If the "request" listener has replaced the request instance, | ||
| * propagate that mutation back to the underlying insterceptor. | ||
| * This happens with XMLHttpRequest that replaces request instances | ||
| * to correctly reflect the "withCredentials" option on the Fetch API request. | ||
| */ | ||
| if (requestEvent.request !== options.request) options.request = requestEvent.request; | ||
| }); | ||
| options.request.signal?.removeEventListener("abort", onAbort); | ||
| if (isRequestAborted) { | ||
| await options.controller.errorWith(requestAbortReason); | ||
| return; | ||
| } | ||
| if (resultError) { | ||
| if (await handleResponseError(resultError)) return; | ||
| if (options.emitter.listenerCount("unhandledException") > 0) { | ||
| const unhandledExceptionController = new RequestController(options.request, { | ||
| /** | ||
| * @note Intentionally empty passthrough handle. | ||
| * This controller is created within another controller and we only need | ||
| * to know if `unhandledException` listeners handled the request. | ||
| */ | ||
| passthrough() {}, | ||
| async respondWith(response) { | ||
| await handleResponse(response); | ||
| }, | ||
| async errorWith(reason) { | ||
| /** | ||
| * @note Handle the result of the unhandled controller | ||
| * in the same way as the original request controller. | ||
| * The exception here is that thrown errors within the | ||
| * "unhandledException" event do NOT result in another | ||
| * emit of the same event. They are forwarded as-is. | ||
| */ | ||
| await options.controller.errorWith(reason); | ||
| } | ||
| }); | ||
| await options.emitter.emitAsPromise(new UnhandledHttpException({ | ||
| initiator: options.initiator, | ||
| error: resultError, | ||
| request: options.request, | ||
| requestId: options.requestId, | ||
| controller: unhandledExceptionController | ||
| })); | ||
| if (unhandledExceptionController.readyState !== RequestController.PENDING) return; | ||
| } | ||
| await options.controller.respondWith(createServerErrorResponse(resultError)); | ||
| return; | ||
| } | ||
| if (options.controller.readyState === RequestController.PENDING) return await options.controller.passthrough(); | ||
| return options.controller.handled; | ||
| } | ||
| //#endregion | ||
| //#region src/interceptors/http/source.ts | ||
| const httpLogger = createLogger("http-request"); | ||
| /** | ||
| * Interceptor for HTTP requests in Node.js. | ||
| * Routes socket connections through an HTTP parser. | ||
| */ | ||
| var NodeHttpRequestSource = class extends Interceptor { | ||
| static { | ||
| this.symbol = Symbol.for("node-http-request-source"); | ||
| } | ||
| predicate() { | ||
| return true; | ||
| } | ||
| setup() { | ||
| const socketInterceptor = Interceptor.singleton(SocketInterceptor); | ||
| socketInterceptor.apply(this); | ||
| this.subscriptions.push(() => { | ||
| socketInterceptor.dispose(this); | ||
| }); | ||
| /** | ||
| * @note Record the raw values provided to Headers set/append | ||
| * in order to support "IncomingMessage.prototype.rawHeaders". | ||
| * This is meant for the headers in mocked responses. | ||
| */ | ||
| this.subscriptions.push(recordRawFetchHeaders()); | ||
| const controller = new AbortController(); | ||
| this.subscriptions.push(() => controller.abort()); | ||
| socketInterceptor.on("connection", ({ connectionOptions, socket, controller: socketController }) => { | ||
| let isHttpConnection; | ||
| let requestParser; | ||
| let tunnelUrl; | ||
| let abortPendingRequest; | ||
| /** | ||
| * @note Capture the request context of the connection itself. | ||
| * The socket is created synchronously within the request async | ||
| * context (e.g. inside the patched `http.request()`), but the | ||
| * first data may reach the socket from a foreign context (e.g. | ||
| * a form-data stream piped into the request), where sampling | ||
| * the request context yields nothing. | ||
| */ | ||
| const connectionRequestContext = requestContext.getStore(); | ||
| /** | ||
| * @note The client destroys the socket synchronously (e.g. Undici | ||
| * on request abort) but the socket teardown events ("error", | ||
| * "close") are emitted asynchronously, after the consumer has | ||
| * already observed the rejected request promise. Hook into the | ||
| * destroy itself so the pending request is aborted before any | ||
| * of its listeners can resume. | ||
| */ | ||
| const rawSocket = socketController[kRawSocket]; | ||
| const realSocketDestroy = rawSocket._destroy.bind(rawSocket); | ||
| rawSocket._destroy = (error, callback) => { | ||
| abortPendingRequest?.(); | ||
| realSocketDestroy(error, callback); | ||
| }; | ||
| /** | ||
| * @note Only inspect the first sent packet to determine the protocol. | ||
| * A single socket cannot be used for different protocols. | ||
| */ | ||
| socket.on("data", (chunk) => { | ||
| if (isHttpConnection === false) return; | ||
| /** | ||
| * @note A mocked "CONNECT" request has established a tunnel. | ||
| * The data that follows belongs to a new exchange addressed to | ||
| * the tunnel target. The parser stopped at the tunnel boundary | ||
| * (HTTP upgrade semantics), so tear it down and detect the | ||
| * tunneled protocol anew, like on a fresh connection. | ||
| */ | ||
| if (tunnelUrl && requestParser) { | ||
| requestParser.free(); | ||
| requestParser = void 0; | ||
| isHttpConnection = void 0; | ||
| /** | ||
| * @note Retarget the connection to the tunnel authority. | ||
| * The exchanges that follow belong to the tunnel target, | ||
| * so an unclaimed exchange (HTTP or not) must pass through | ||
| * to that target — not to the proxy, which never actually | ||
| * established this tunnel — like a real established tunnel | ||
| * relays its traffic. | ||
| */ | ||
| socketController.reset({ | ||
| host: tunnelUrl.hostname, | ||
| port: Number(tunnelUrl.port) || 80, | ||
| path: null | ||
| }); | ||
| } | ||
| if (requestParser) { | ||
| requestParser.execute(toBuffer(chunk)); | ||
| return; | ||
| } | ||
| const httpMessage = chunk.toString(); | ||
| const httpMethod = httpMessage.split(" ")[0] || ""; | ||
| if (!METHODS.includes(httpMethod.toUpperCase())) { | ||
| isHttpConnection = false; | ||
| socketController.decline(); | ||
| return; | ||
| } | ||
| isHttpConnection = true; | ||
| const baseUrl = tunnelUrl ?? connectionOptionsToUrl(connectionOptions, socket); | ||
| httpLogger.verbose("handling http message %o", { | ||
| httpMessage, | ||
| httpMethod, | ||
| baseUrl | ||
| }); | ||
| const requestContextValue = requestContext.getStore() ?? connectionRequestContext; | ||
| const initiator = requestContextValue?.initiator || socket; | ||
| requestParser = new HttpRequestParser({ | ||
| connectionOptions: { | ||
| method: httpMethod, | ||
| url: baseUrl | ||
| }, | ||
| onRequest: async (parsedRequest, requestAbortController) => { | ||
| const request = requestContextValue?.transformRequest?.(parsedRequest) ?? parsedRequest; | ||
| /** | ||
| * @note A subsequent request arriving on a kept-alive socket | ||
| * that has already been handled (passed through or mocked). | ||
| * Clients like Undici reuse sockets without emitting the | ||
| * "free" event, so reset the controller here, at the HTTP | ||
| * message boundary, to handle the new request from the | ||
| * pending state again. | ||
| */ | ||
| if (socketController["readyState"] !== SocketController.PENDING) socketController.reset(); | ||
| const requestId = createRequestId(); | ||
| const requestLogger = requestContextValue?.logger ?? httpLogger; | ||
| httpLogger.verbose("received a parsed HTTP request %o", { | ||
| method: request.method, | ||
| url: request.url | ||
| }); | ||
| const requestController = new RequestController(request, { | ||
| respondWith: async (rawResponse) => { | ||
| httpLogger.verbose("respondWith() %o", { | ||
| status: rawResponse.status, | ||
| statusText: rawResponse.statusText, | ||
| hasBody: rawResponse.body != null | ||
| }); | ||
| /** | ||
| * @note The client may destroy the socket (e.g. on request | ||
| * abort) moments before a response arrives. A destroyed | ||
| * socket cannot be claimed and has no one reading it. | ||
| */ | ||
| if (socket.destroyed) return; | ||
| socketController.claim(); | ||
| const response = FetchResponse.from(rawResponse, { url: request.url }); | ||
| /** | ||
| * @note A successful mocked response to a "CONNECT" | ||
| * request establishes a tunnel to the requested authority | ||
| * (e.g. "127.0.0.1:80"). The exchange that follows on this | ||
| * socket is addressed to that authority, not to the proxy. | ||
| */ | ||
| if (request.method === "CONNECT" && response.ok) tunnelUrl = new URL(`http://${request.url}`); | ||
| /** | ||
| * @note Clone the response before "respondWith" because it will | ||
| * consume its body. This way, we can have a readable response copy | ||
| * for the "response" event below. | ||
| */ | ||
| const responseClone = isResponseError(response) ? null : response.clone(); | ||
| const respond = () => { | ||
| return this.respondWith({ | ||
| socket: socketController[kRawSocket], | ||
| request: context.request, | ||
| response | ||
| }); | ||
| }; | ||
| if (responseClone) await this.emitter.emitAsPromise(new HttpResponseEvent({ | ||
| initiator, | ||
| requestId, | ||
| request: context.request, | ||
| response: responseClone, | ||
| responseType: "mock" | ||
| })); | ||
| if (socket.connecting) socket.once("connect", respond); | ||
| else | ||
| /** | ||
| * @note Reused sockets stay connected between requests and will not | ||
| * emit "connect" anymore. If that's the case, respond immediately. | ||
| */ | ||
| await respond(); | ||
| }, | ||
| errorWith: (reason) => { | ||
| if (reason instanceof Error) socket.destroy(reason); | ||
| }, | ||
| passthrough: () => { | ||
| const realSocket = socketController.passthrough(this.#modifyHttpHeaders(context.request)); | ||
| if (this.emitter.listenerCount("response") > 0) { | ||
| httpLogger.verbose("found \"response\" listener, corking socket reads"); | ||
| /** | ||
| * Suspend the delivery of the original response to the client | ||
| * until the "response" event listeners settle. This guarantees | ||
| * that the request promise (e.g. `await fetch()`) does not | ||
| * resolve before the listeners are done. The real socket keeps | ||
| * emitting data for the response parser meanwhile. | ||
| */ | ||
| socketController.corkReads(); | ||
| const responseParser = new HttpResponseParser({ onResponse: async (response) => { | ||
| httpLogger.verbose("HTTP response parser parsed: %d %s", response.status, response.statusText); | ||
| if (isResponseError(response)) { | ||
| httpLogger.verbose("response is an error response, uncorking socket reads..."); | ||
| socketController.uncorkReads(); | ||
| return; | ||
| } | ||
| FetchResponse.setUrl(request.url, response); | ||
| try { | ||
| httpLogger.verbose("emitting \"response\" event"); | ||
| await this.emitter.emitAsPromise(new HttpResponseEvent({ | ||
| initiator, | ||
| requestId, | ||
| request: context.request, | ||
| response, | ||
| responseType: "original" | ||
| })); | ||
| } finally { | ||
| httpLogger.verbose("uncorking socket reads"); | ||
| socketController.uncorkReads(); | ||
| /** | ||
| * @note Informational responses other than | ||
| * "101 Switching Protocols" are followed by a final | ||
| * response on the same connection. Keep gating that | ||
| * final response on the "response" event listeners. | ||
| */ | ||
| if (response.status < 200 && response.status !== 101) socketController.corkReads(); | ||
| } | ||
| } }); | ||
| realSocket.on("data", (chunk) => responseParser.execute(chunk)).on("close", () => responseParser.free()); | ||
| } | ||
| } | ||
| }, { | ||
| logger: requestLogger, | ||
| requestId | ||
| }); | ||
| invariant(socketController["readyState"] === SocketController.PENDING, "CANNOT HANDLE ALREADY HANDLED REQUEST", request.method, request.url, socketController["readyState"]); | ||
| /** | ||
| * @note Create a request resolution context. | ||
| * This is so modifications to the "request" in upstream interceptors | ||
| * are correctly picked up by the underlying HTTP interceptor. | ||
| */ | ||
| const context = { | ||
| initiator, | ||
| requestId, | ||
| request, | ||
| controller: requestController, | ||
| emitter: this.emitter, | ||
| logger: requestLogger | ||
| }; | ||
| /** | ||
| * @note The client destroying the socket while the request | ||
| * is still pending means the request was aborted (e.g. via | ||
| * `AbortController`). Abort the parsed request so its | ||
| * handling settles and late interactions with the request | ||
| * controller become controlled errors. | ||
| */ | ||
| abortPendingRequest = () => { | ||
| if (requestController.readyState === RequestController.PENDING) requestAbortController.abort(); | ||
| }; | ||
| try { | ||
| await handleRequest(context); | ||
| } finally { | ||
| abortPendingRequest = void 0; | ||
| } | ||
| } | ||
| }); | ||
| requestParser.execute(toBuffer(chunk)); | ||
| }); | ||
| socket.on("close", () => requestParser?.free()); | ||
| }, { signal: controller.signal }); | ||
| } | ||
| async respondWith(args) { | ||
| const { socket, request, response } = args; | ||
| if (socket.destroyed) return; | ||
| if (isResponseError(response)) { | ||
| /** | ||
| * @note Reference the error response on the socket error so the | ||
| * client-side interceptors (e.g. fetch) can surface it to the | ||
| * consumer as the reason behind the failed request. Keep the | ||
| * reference non-enumerable so the error remains observably | ||
| * identical for the clients that expose it as-is. | ||
| */ | ||
| socket.destroy(Object.defineProperty(/* @__PURE__ */ new TypeError("Network error"), kErrorResponse, { | ||
| value: response, | ||
| enumerable: false | ||
| })); | ||
| return; | ||
| } | ||
| invariant(!socket.connecting, "Failed to mock a response for \"%s %s\": socket has not connected", request.method, request.url); | ||
| /** | ||
| * Use native server response handling in Node.js. | ||
| * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/_http_server.js#L202 | ||
| */ | ||
| const incomingMessage = new IncomingMessage(socket); | ||
| /** | ||
| * @note Describe the request method so the response body is | ||
| * handled appropriately (e.g. "HEAD" responses must not write | ||
| * a body). The HTTP version is deliberately left unset: with it, | ||
| * `ServerResponse` frames bodies of unknown length as chunked, | ||
| * polluting the mocked response headers with "Transfer-Encoding" | ||
| * the mock never specified. | ||
| */ | ||
| incomingMessage.method = request.method; | ||
| const serverResponse = new ServerResponse(incomingMessage); | ||
| const responseSocket = new net.Socket(); | ||
| responseSocket._writeGeneric = (writev, data, encoding, callback) => { | ||
| unwrapPendingData(data, (chunk, encoding) => { | ||
| socket.push(toBuffer(chunk), encoding); | ||
| }); | ||
| callback?.(); | ||
| }; | ||
| responseSocket._destroy = (error, callback) => { | ||
| /** | ||
| * Only destroy the socket on stream errors. | ||
| * On a clean end, the socket is already signaled via `socket.push(null)` | ||
| * in the main response flow. Destroying it here prematurely would prevent | ||
| * the client from processing the response (e.g. calling `response.destroy()`). | ||
| * @see https://github.com/mswjs/interceptors/issues/738 | ||
| */ | ||
| if (error) socket.destroy(); | ||
| callback(null); | ||
| }; | ||
| responseSocket.on("drain", () => serverResponse.emit("drain")); | ||
| serverResponse.assignSocket(responseSocket); | ||
| serverResponse.removeHeader("connection"); | ||
| serverResponse.removeHeader("date"); | ||
| const rawResponseHeaders = getRawFetchHeaders(response.headers); | ||
| serverResponse.writeHead(response.status, response.statusText || STATUS_CODES[response.status], rawResponseHeaders); | ||
| /** | ||
| * @note Override the socket's `_destroy` before writing the response body. | ||
| * The underlying TCP handle (from `socket.connect()`) makes `_destroy` async | ||
| * (`_handle.close()` callback), which delays the 'error' event. Since the real | ||
| * TCP connection is irrelevant for mocked responses, take the synchronous path | ||
| * so that user-initiated `response.destroy(error)` emits the error promptly. | ||
| * This must happen before `serverResponse.end()` because the HTTP parser may | ||
| * fire the 'response' event synchronously during `socket.push()`. | ||
| */ | ||
| socket._destroy = function(error, callback) { | ||
| if (error) | ||
| /** | ||
| * Emit the error event as a microtask instead of relying on the default | ||
| * `process.nextTick(emitErrorNT)` from `callback(error)`. This is necessary | ||
| * because `respondWith` runs inside a microtask (from `await reader.read()`). | ||
| * A resolved promise continuation (from toWebResponse) is queued as | ||
| * another microtask during the same phase. Since microtasks are drained before | ||
| * nextTick, the test's `await` would resolve before the error event fires. | ||
| * Using `queueMicrotask` ensures the error event is emitted within the current | ||
| * microtask phase, before other queued microtasks. | ||
| */ | ||
| queueMicrotask(() => this.emit("error", error)); | ||
| callback(null); | ||
| /** | ||
| * @note `net.Socket` is constructed with `emitClose: false`, so Node's | ||
| * stream destroy machinery does not emit `'close'` automatically; the | ||
| * stock `net.Socket._destroy` only emits it via `_handle.close()`. | ||
| * Since this override replaces `_destroy`, emit `'close'` here so the | ||
| * mocked socket completes its lifecycle (otherwise consumers waiting | ||
| * on `'close'`, like `http.ClientRequest`, hang). | ||
| */ | ||
| process.nextTick(() => this.emit("close", error != null)); | ||
| }; | ||
| if (response.body) { | ||
| const reader = response.body.getReader(); | ||
| try { | ||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) { | ||
| serverResponse.end(); | ||
| break; | ||
| } | ||
| if (!serverResponse.write(value)) await new Promise((resolve) => { | ||
| serverResponse.once("drain", resolve); | ||
| }); | ||
| } | ||
| } catch { | ||
| /** | ||
| * @note Delay the socket destruction to allow the event loop | ||
| * to flush already-pushed response data (headers + body chunks) | ||
| * through the HTTP parser. Without this, the socket is destroyed | ||
| * on the same tick as `socket.push(data)` and the client never | ||
| * reads the response. | ||
| */ | ||
| await new Promise((resolve) => process.nextTick(resolve)); | ||
| socket.destroy(); | ||
| return; | ||
| } | ||
| } else serverResponse.end(); | ||
| /** | ||
| * @note Self-delimiting responses (chunked, explicit "Content-Length", | ||
| * or bodiless by definition) must NOT signal the end-of-stream. | ||
| * The client parser completes them from their framing alone, and | ||
| * ending the socket would kill the kept-alive connection that | ||
| * agents pool and reuse for subsequent requests. | ||
| */ | ||
| const isSelfDelimitingResponse = request.method === "HEAD" || response.headers.has("content-length") || response.headers.has("transfer-encoding") || !FetchResponse.isResponseWithBody(response.status); | ||
| if (request.method === "CONNECT" && !response.ok || request.method !== "CONNECT" && !isSelfDelimitingResponse) { | ||
| /** | ||
| * @note Defer the end-of-stream signal so the HTTP parser has a chance | ||
| * to process already-pushed response data and fire the 'response' event | ||
| * before the socket is ended. Without this, the parser marks the response | ||
| * as "complete" before the client can interact with it (e.g. `response.destroy()`). | ||
| */ | ||
| await new Promise((resolve) => process.nextTick(resolve)); | ||
| socket.push(null); | ||
| } | ||
| } | ||
| #modifyHttpHeaders(request) { | ||
| const transformRequestMessage = (httpMessage, encoding) => { | ||
| /** | ||
| * @note Socket can write a buffer (e.g. uploaded file) even before | ||
| * it writes the HTTP message. Bypass those cases. | ||
| */ | ||
| if (encoding === "buffer") return httpMessage; | ||
| const parts = httpMessage.toString(encoding).split("\r\n"); | ||
| const headersEndIndex = parts.findIndex((field) => field === ""); | ||
| const httpMessageHeaderPairs = parts.slice(1, headersEndIndex); | ||
| const httpMessageRawHeaders = httpMessageHeaderPairs.map((line) => { | ||
| const separatorIndex = line.indexOf(": "); | ||
| return [line.slice(0, separatorIndex), line.slice(separatorIndex + 2)]; | ||
| }); | ||
| const requestRawHeaders = getRawFetchHeaders(request.headers); | ||
| if (httpMessageRawHeaders.length === requestRawHeaders.length && httpMessageRawHeaders.every((tuple, index) => { | ||
| const requestTuple = requestRawHeaders[index]; | ||
| return tuple[0] === requestTuple[0] && tuple[1] === requestTuple[1]; | ||
| })) return httpMessage; | ||
| const httpMessageHeaders = FetchResponse.parseRawHeaders(httpMessageHeaderPairs.flatMap((header) => header.split(": "))); | ||
| const visitedHeaders = /* @__PURE__ */ new Set(); | ||
| for (const [headerName] of requestRawHeaders) { | ||
| const normalizedHeaderName = headerName.toLowerCase(); | ||
| if (visitedHeaders.has(normalizedHeaderName)) continue; | ||
| visitedHeaders.add(normalizedHeaderName); | ||
| /** | ||
| * @note Forbidden Fetch headers (e.g. Host, Origin, Connection) | ||
| * are stripped from `request.headers` but remain in the raw | ||
| * headers list. Skip them so the original values from the | ||
| * outgoing HTTP message are preserved. | ||
| */ | ||
| const headerValue = request.headers.get(headerName); | ||
| if (headerValue === null) continue; | ||
| httpMessageHeaders.set(headerName, headerValue); | ||
| } | ||
| visitedHeaders.clear(); | ||
| const httpMessageHeadersString = Array.from(httpMessageHeaders).map(([name, value]) => `${name}: ${value}`).join("\r\n"); | ||
| parts.splice(1, headersEndIndex - 1, httpMessageHeadersString); | ||
| return parts.join("\r\n"); | ||
| }; | ||
| return (pendingData, encoding, callback) => { | ||
| if (Array.isArray(pendingData)) pendingData[0].chunk = transformRequestMessage(pendingData[0].chunk, pendingData[0].encoding); | ||
| else pendingData = transformRequestMessage(pendingData, encoding); | ||
| callback(pendingData); | ||
| }; | ||
| } | ||
| }; | ||
| //#endregion | ||
| export { requestContext as a, forwardHttpEvents as i, handleRequest as n, runInRequestContext as o, HttpResponseEvent as r, NodeHttpRequestSource as t }; | ||
| //# sourceMappingURL=source-BUVce4Q1.js.map |
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1539273
0.44%21562
0.47%