{"version":3,"sources":["../../src/utils/tee-response.ts"],"sourcesContent":["import type { IncomingMessage } from 'http';\nimport type { PassThrough } from 'stream';\n\nconst COPIED_FIELDS = [\n  'statusCode', 'statusMessage', 'headers', 'rawHeaders',\n  'httpVersion', 'httpVersionMajor', 'httpVersionMinor',\n  'method', 'url', 'socket', 'trailers', 'rawTrailers', 'complete', 'aborted',\n] as const satisfies readonly (keyof IncomingMessage)[];\n\n/** Duck-typed stand-in for `IncomingMessage` returned by {@link createResponseTee}. */\nexport type ResponseTee = PassThrough & Pick<IncomingMessage, (typeof COPIED_FIELDS)[number]> & {\n  setTimeout?: (msecs: number, callback?: () => void) => ResponseTee;\n};\n\nfunction copyIncomingMessageMetadata(res: IncomingMessage, target: ResponseTee): void {\n  for (const field of COPIED_FIELDS) {\n    (target as any)[field] = res[field];\n  }\n  // PassThrough has no setTimeout of its own (that's an IncomingMessage-specific method) —\n  // shim it so host code calling `res.setTimeout(...)` still works. Wrapped rather than bound:\n  // IncomingMessage#setTimeout returns `this` for chaining, and binding to `res` would leak the\n  // real response back out of a chained call (`res.setTimeout(n).on('data', ...)`), bypassing\n  // the tee entirely and reopening the race this module exists to close.\n  if (typeof res.setTimeout === 'function') {\n    target.setTimeout = (msecs: number, callback?: () => void) => {\n      res.setTimeout(msecs, callback);\n      return target;\n    };\n  }\n}\n\nfunction hostIsReading(hostStream: ResponseTee): boolean {\n  return hostStream.readableFlowing !== null || hostStream.listenerCount('readable') > 0;\n}\n\n/**\n * Tees an intercepted http/https response into an independent stream for the host callback.\n *\n * A raw Node Readable can only have one \"true\" consumer — whoever attaches a 'data' listener\n * first switches it into flowing mode and wins the race. Handing the host the raw `res` (while\n * the interceptor also attaches its own capture listeners) would silently starve a host callback\n * that consumes the response asynchronously (e.g. after an `await`) or via a deferred `.pipe()`.\n * The returned stream is untouched until the host reads it, so it buffers correctly regardless\n * of when that happens — the race is eliminated by construction.\n *\n * The interceptor's own capture listeners (`res.on('data'/'end', ...)`) are attached separately\n * by the caller and are unaffected by this — both sets of listeners receive every chunk.\n *\n * Callers should only invoke this when there is actually a host callback to hand the result to\n * (see `hostIsReading` below for why an unread tee must never apply backpressure) — constructing\n * one that nobody will ever read is wasted work at best.\n *\n * `instanceof http.IncomingMessage` is `false` for the returned tee — it's a real `PassThrough`\n * with IncomingMessage-shaped metadata copied on, not a real instance of it.\n */\nexport function createResponseTee(res: IncomingMessage, PassThroughCtor: new () => PassThrough): ResponseTee {\n  const hostStream = new PassThroughCtor() as ResponseTee;\n\n  // Prevents an uncaught 'error' throw if `res` errors before the host has attached its own\n  // 'error' listener (e.g. during an awaited gap) — degrades to a silent stall instead of\n  // crashing the process. A host that *has* attached its own listener by the time the error is\n  // forwarded (below) still receives it — multiple listeners on the same event both fire.\n  hostStream.on('error', () => { /* see comment above */ });\n\n  copyIncomingMessageMetadata(res, hostStream);\n\n  res.on('data', (chunk: any) => {\n    // The host may have destroyed its stream (e.g. to abandon a download early) since the last\n    // chunk — writing to an already-destroyed Writable throws/emits a spurious error instead of\n    // silently no-op'ing.\n    if (hostStream.destroyed) { return; }\n\n    const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);\n    // Respect backpressure — but only once the host has demonstrably started reading. `res` is\n    // the interceptor's own single source of truth (its own capture listener is on this same\n    // `res`, not on the tee), so pausing it when nobody is draining the tee would silently stall\n    // the interceptor's own logging too — a callback that never touches the response body (or\n    // isn't provided at all) must not be able to deadlock the request.\n    if (!hostStream.write(buf) && hostIsReading(hostStream)) {\n      res.pause();\n      hostStream.once('drain', () => res.resume());\n    }\n  });\n\n  res.on('end', () => {\n    // Refresh fields that are only accurate once the response has fully arrived (`complete`\n    // reads `false` until 'end'; `rawTrailers` is reassigned, not mutated, by Node's http parser).\n    copyIncomingMessageMetadata(res, hostStream);\n    hostStream.end();\n  });\n\n  res.on('error', (err: Error) => {\n    hostStream.destroy(err);\n  });\n\n  // 'aborted'/'timeout' fire on `res` itself, not through 'data'/'end'/'error' — without\n  // forwarding them, a host relying on either (e.g. `res.on('aborted', cleanup)` to detect a\n  // truncated response) would silently stop working once handed the tee instead of the real `res`.\n  res.on('aborted', () => {\n    hostStream.aborted = true;\n    hostStream.emit('aborted');\n  });\n\n  res.on('timeout', () => {\n    hostStream.emit('timeout');\n  });\n\n  // If the host abandons/destroys its stream before the response finished arriving, don't leave\n  // the real response (and its socket) dangling — this restores the abort behavior hosts get by\n  // calling `.destroy()` on a real `res`, which the tee would otherwise silently swallow.\n  // `hostStream` also auto-destroys (emitting 'close') after a *normal* `.end()`, so this fires\n  // on every response, not just aborted ones — that's fine: `IncomingMessage.prototype._destroy`\n  // only actually tears down the socket when the response didn't finish normally (`aborted`),\n  // so calling `.destroy()` here on an already-fully-consumed `res` is a safe no-op.\n  hostStream.on('close', () => {\n    if (!res.destroyed) {\n      res.destroy();\n    }\n  });\n\n  return hostStream;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAGA,MAAM,gBAAgB;AAAA,EACpB;AAAA,EAAc;AAAA,EAAiB;AAAA,EAAW;AAAA,EAC1C;AAAA,EAAe;AAAA,EAAoB;AAAA,EACnC;AAAA,EAAU;AAAA,EAAO;AAAA,EAAU;AAAA,EAAY;AAAA,EAAe;AAAA,EAAY;AACpE;AAOA,SAAS,4BAA4B,KAAsB,QAA2B;AACpF,aAAW,SAAS,eAAe;AACjC,IAAC,OAAe,KAAK,IAAI,IAAI,KAAK;AAAA,EACpC;AAMA,MAAI,OAAO,IAAI,eAAe,YAAY;AACxC,WAAO,aAAa,CAAC,OAAe,aAA0B;AAC5D,UAAI,WAAW,OAAO,QAAQ;AAC9B,aAAO;AAAA,IACT;AAAA,EACF;AACF;AAEA,SAAS,cAAc,YAAkC;AACvD,SAAO,WAAW,oBAAoB,QAAQ,WAAW,cAAc,UAAU,IAAI;AACvF;AAsBO,SAAS,kBAAkB,KAAsB,iBAAqD;AAC3G,QAAM,aAAa,IAAI,gBAAgB;AAMvC,aAAW,GAAG,SAAS,MAAM;AAAA,EAA0B,CAAC;AAExD,8BAA4B,KAAK,UAAU;AAE3C,MAAI,GAAG,QAAQ,CAAC,UAAe;AAI7B,QAAI,WAAW,WAAW;AAAE;AAAA,IAAQ;AAEpC,UAAM,MAAM,OAAO,SAAS,KAAK,IAAI,QAAQ,OAAO,KAAK,KAAK;AAM9D,QAAI,CAAC,WAAW,MAAM,GAAG,KAAK,cAAc,UAAU,GAAG;AACvD,UAAI,MAAM;AACV,iBAAW,KAAK,SAAS,MAAM,IAAI,OAAO,CAAC;AAAA,IAC7C;AAAA,EACF,CAAC;AAED,MAAI,GAAG,OAAO,MAAM;AAGlB,gCAA4B,KAAK,UAAU;AAC3C,eAAW,IAAI;AAAA,EACjB,CAAC;AAED,MAAI,GAAG,SAAS,CAAC,QAAe;AAC9B,eAAW,QAAQ,GAAG;AAAA,EACxB,CAAC;AAKD,MAAI,GAAG,WAAW,MAAM;AACtB,eAAW,UAAU;AACrB,eAAW,KAAK,SAAS;AAAA,EAC3B,CAAC;AAED,MAAI,GAAG,WAAW,MAAM;AACtB,eAAW,KAAK,SAAS;AAAA,EAC3B,CAAC;AASD,aAAW,GAAG,SAAS,MAAM;AAC3B,QAAI,CAAC,IAAI,WAAW;AAClB,UAAI,QAAQ;AAAA,IACd;AAAA,EACF,CAAC;AAED,SAAO;AACT;","names":[]}