From 420a8fffb700925630fa9e1a0b99bd1fb4f69306 Mon Sep 17 00:00:00 2001 From: CortexOS Date: Thu, 16 Jul 2026 00:42:06 +0000 Subject: [PATCH] port(omniroute): #6908 - ship head-response-guard.cjs in the standalone CLI bundle --- cli/scripts/build-cli.js | 18 +- cli/scripts/standaloneSidecars.js | 58 +++++++ custom-server.js | 43 +---- docs/ports/omniroute-6908.md | 46 +++++ head-response-guard.cjs | 65 +++++++ scripts/build-app.mjs | 4 + tests/unit/mitm-runtime-lifecycle.test.js | 2 +- tests/unit/standalone-sidecars-6908.test.js | 182 ++++++++++++++++++++ 8 files changed, 375 insertions(+), 43 deletions(-) create mode 100644 cli/scripts/standaloneSidecars.js create mode 100644 docs/ports/omniroute-6908.md create mode 100644 head-response-guard.cjs create mode 100644 tests/unit/standalone-sidecars-6908.test.js diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js index 91d20b06c..eebe8ecb6 100644 --- a/cli/scripts/build-cli.js +++ b/cli/scripts/build-cli.js @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const { execSync } = require("child_process"); const { resolveCliAppDir } = require("./cliBuildPaths"); +const { copyRequiredStandaloneSidecars } = require("./standaloneSidecars"); const cliDir = path.resolve(__dirname, ".."); const appDir = path.resolve(cliDir, ".."); @@ -170,13 +171,16 @@ if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules } console.log("✅ Copied standalone build\n"); -// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). -const customServerSrc = path.join(appDir, "custom-server.js"); -if (fs.existsSync(customServerSrc)) { - fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); - console.log("✅ Copied custom-server.js\n"); -} else { - console.error("❌ custom-server.js is required for socket ownership and anti-spoofing checks"); +// Step 3a: Copy custom server + required root sidecars (custom-server.js +// injects real socket IP / strips spoofable XFF; head-response-guard.cjs is +// its #6608 HEAD guard). These live outside Next's standalone trace, so a +// missing copy crashes the shipped CLI at boot (OmniRoute #6908). +console.log("3️⃣ a Copying custom server + sidecars..."); +try { + copyRequiredStandaloneSidecars(appDir, cliAppDir); + console.log("✅ Copied custom-server.js + head-response-guard.cjs\n"); +} catch (error) { + console.error(`❌ ${error.message}`); process.exit(1); } diff --git a/cli/scripts/standaloneSidecars.js b/cli/scripts/standaloneSidecars.js new file mode 100644 index 000000000..041daa3c4 --- /dev/null +++ b/cli/scripts/standaloneSidecars.js @@ -0,0 +1,58 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +/** + * Standalone sidecars (OmniRoute #6908). + * + * `custom-server.js` sits OUTSIDE the Next.js standalone tracing root, so the + * files it `require()`s at the app root are never traced into + * `.next/standalone` by Next. The CLI build copies `custom-server.js` into the + * bundle by hand (build-cli.js step 3a) — and must copy each of its root-level + * sidecars too, or the shipped CLI crashes at boot with MODULE_NOT_FOUND (the + * exact failure OmniRoute #6908 fixed upstream for `head-response-guard.cjs`). + * + * Keep this list in sync with the root-level `./…` requires at the top of + * `custom-server.js`. `src/mitm`, `src/shared`, and `open-sse` are covered by + * their own copy steps in build-cli.js; only app-root files belong here. + */ +const STANDALONE_SIDECARS = [ + "custom-server.js", + "head-response-guard.cjs", +]; + +/** + * Copy every standalone sidecar from the app root into the CLI app bundle. + * Fails hard when a sidecar is missing — a silent skip ships a bundle that + * crashes on first boot. + * + * @param {string} appDir Absolute path to the repo app root. + * @param {string} cliAppDir Absolute path to the staged CLI app bundle dir. + * @param {{ copyFileSync?: Function, mkdirSync?: Function, existsSync?: Function }} [fsImpl] + * Injectable fs subset for tests. + * @returns {string[]} Absolute destination paths written, in order. + */ +function copyRequiredStandaloneSidecars(appDir, cliAppDir, fsImpl = fs) { + // Preflight ALL sources before copying anything: a half-staged bundle that + // has custom-server.js but not its guard is exactly the boot-crashing + // artifact OmniRoute #6908 shipped — fail before writing a single file. + for (const name of STANDALONE_SIDECARS) { + const src = path.join(appDir, name); + if (!fsImpl.existsSync(src)) { + throw new Error( + `Required standalone sidecar ${src} is missing — the CLI bundle would crash at boot (MODULE_NOT_FOUND)` + ); + } + } + const written = []; + for (const name of STANDALONE_SIDECARS) { + const dest = path.join(cliAppDir, name); + fsImpl.mkdirSync(path.dirname(dest), { recursive: true }); + fsImpl.copyFileSync(path.join(appDir, name), dest); + written.push(dest); + } + return written; +} + +module.exports = { STANDALONE_SIDECARS, copyRequiredStandaloneSidecars }; diff --git a/custom-server.js b/custom-server.js index e72c42965..7b431f0c2 100644 --- a/custom-server.js +++ b/custom-server.js @@ -19,6 +19,8 @@ const { } = require("./src/shared/utils/wsHandshake"); const { createRealtimeSession, publicSession: publicRealtimeSession } = require("./open-sse/handlers/realtimeCore"); const { MAX_REALTIME_FRAME_BYTES } = require("./src/shared/utils/realtimeConfig"); +// Sidecar copied into the CLI bundle by cli/scripts/standaloneSidecars.js. +const { applyHeadResponseGuard } = require("./head-response-guard.cjs"); const MITM_CONTROL_PATH = "/api/cli-tools/antigravity-mitm"; const STANDALONE_ROOT_ENV = "DURINDOOR_STANDALONE_ROOT"; @@ -100,41 +102,12 @@ function installRequestWrapper({ httpModule = http, secret, verifyPeerOwner } = } }; const dispatch = () => { - // #6608 global HEAD body-suppression: Next 16 auto-derives HEAD from - // GET for App Router routes and streams the full body, so SDK health - // probes (and any HEAD request) hang ~6s then receive a non-empty body - // — violating RFC 9110 §9.3.2 (HEAD carries the headers a GET would, - // with zero body). Applied here inside installRequestWrapper so it - // covers BOTH production standalone (`run()` → installRequestWrapper) - // and the dev/start entry (`scripts/next-owner-server.cjs` → - // createOwnerAwareHandler), and every route — including ones without an - // explicit `HEAD` export. `res` is per-request, so the patched - // `write`/`end` stay in place for the whole response lifetime (no - // restore — a deferred stream write after the handler promise resolves - // must still be dropped; there is no cross-request leak). Status + - // headers set by the handler are preserved exactly; only body bytes are - // dropped. - const isHead = String(req.method || "GET").toUpperCase() === "HEAD"; - if (isHead) { - const origEnd = res.end; - res.write = function (_chunk, _enc, cb) { - // Accept (chunk, cb) and (chunk, enc, cb) arities; signal success. - const callback = typeof _enc === "function" ? _enc : cb; - if (typeof callback === "function") callback(); - return true; - }; - res.end = function (_chunk, _enc, cb) { - // Normalize (cb), (chunk, cb), (chunk, enc, cb) into a single - // callback and hand it to the REAL end so Node invokes it once, - // after the stream fully closes — never swallow it (a swallowed - // callback can hang async handlers awaiting `end`). Body chunks - // are dropped; status + headers set by the handler are kept. - const callback = typeof _chunk === "function" - ? _chunk - : (typeof _enc === "function" ? _enc : cb); - return origEnd.call(res, callback); - }; - } + // #6608 global HEAD body-suppression, extracted to + // ./head-response-guard.cjs (see its module doc for the RFC 9110 + // §9.3.2 rationale). Called inside installRequestWrapper so it covers + // BOTH the production standalone and dev/start entries and every + // route, with per-request `res` patches kept for the response life. + applyHeadResponseGuard(req, res); try { const result = handler(req, res); if (result && typeof result.catch === "function") { diff --git a/docs/ports/omniroute-6908.md b/docs/ports/omniroute-6908.md new file mode 100644 index 000000000..ba81697a8 --- /dev/null +++ b/docs/ports/omniroute-6908.md @@ -0,0 +1,46 @@ +# Port log: OmniRoute PR #6908 + +- **Source:** https://github.com/diegosouzapw/OmniRoute/pull/6908 +- **Port branch:** `port/omniroute-6908` + +## Behavior ported + +Upstream's standalone CLI bundle crashed at boot with `MODULE_NOT_FOUND`: the server entry imported `head-response-guard.cjs`, but the packaging step had no entry copying it into the bundle. Upstream shipped the sidecar via an extra-module manifest entry and added a regression test deriving required sidecars from the entry's own relative imports. + +DurinDoor has the same hazard shape: `custom-server.js` carries the #6608 HEAD body-suppression inline and lives OUTSIDE Next.js's standalone trace, so nothing guaranteed a sidecar would travel with it. + +## DurinDoor adaptation + +- The inline #6608 HEAD guard was extracted with identical runtime semantics (body bytes dropped, status/headers preserved, real `end` invoked with exactly one callback) into the root sidecar `head-response-guard.cjs`; `custom-server.js` now `require()`s it. Behavior of the guard itself is unchanged — no `Connection: close` is added. +- `cli/scripts/standaloneSidecars.js` (new) owns `STANDALONE_SIDECARS` + `copyRequiredStandaloneSidecars(appDir, cliAppDir)`, which copies each root sidecar into the CLI bundle and FAILS HARD when one is missing — a silent skip ships a boot-crashing bundle. +- `cli/scripts/build-cli.js` step 3a now copies `custom-server.js` + `head-response-guard.cjs` through that helper (same hard-fail contract as before). +- `scripts/build-app.mjs` copies `head-response-guard.cjs` into `.next/standalone` next to `custom-server.js` so root `npm run build` + `npm start` keeps booting. +- The test derives EVERY root-level relative `require("./…")` in `custom-server.js` (excluding generated `server.js` and the `src/` + `open-sse/` trees, which have their own copy steps) and asserts each is in `STANDALONE_SIDECARS` — the upstream-style guard against the NEXT omitted sidecar. + +## Files (8) + +- `head-response-guard.cjs` (new — extracted #6608 guard) +- `custom-server.js` (require sidecar; inline guard removed) +- `cli/scripts/standaloneSidecars.js` (new — sidecar manifest + copy) +- `cli/scripts/build-cli.js` (step 3a uses the helper) +- `scripts/build-app.mjs` (copy guard into standalone) +- `tests/unit/standalone-sidecars-6908.test.js` (new) +- `tests/unit/mitm-runtime-lifecycle.test.js` (assertion follows the helper call) +- `docs/ports/omniroute-6908.md` + +## Verification + +```text +cd tests && node node_modules/vitest/vitest.mjs run unit/standalone-sidecars-6908.test.js unit/mitm-runtime-lifecycle.test.js unit/api-hardening.test.js +Test Files 3 passed (3) +Tests 71 passed (71) +``` + +Red-proof: removing `head-response-guard.cjs` from `STANDALONE_SIDECARS` fails 3 tests in `standalone-sidecars-6908.test.js` (copy completeness + derived-require guard); restored helper → 71/71 green again. + +Artifact proof (real `npm pack --dry-run --json` against a temp `cli/` whose `app/` was staged by `copyRequiredStandaloneSidecars`): + +```text +app entries: ["app/custom-server.js","app/head-response-guard.cjs"] +TARBALL-OK guard bytes=2722 +``` diff --git a/head-response-guard.cjs b/head-response-guard.cjs new file mode 100644 index 000000000..1cef6d9d7 --- /dev/null +++ b/head-response-guard.cjs @@ -0,0 +1,65 @@ +"use strict"; + +/** + * HEAD response guard (#6608; OmniRoute #6908 ports its packaging). + * + * RFC 9110 §9.3.2 requires a HEAD response to carry the same status/headers a + * GET would, with ZERO body. Next 16 auto-derives HEAD from GET for App Router + * routes and streams the full body, so SDK health probes (and any HEAD + * request) hang ~6s then receive a non-empty body. Applied inside + * custom-server.js's request wrapper so it covers BOTH the production + * standalone entry and the dev/start entry, and every route — including ones + * without an explicit `HEAD` export. + * + * `res` is per-request, so the patched `write`/`end` stay in place for the + * whole response lifetime (no restore — a deferred stream write after the + * handler promise resolves must still be dropped; there is no cross-request + * leak). Status + headers set by the handler are preserved exactly; only body + * bytes are dropped. + * + * This module is a standalone sidecar: `custom-server.js` requires it at + * `./head-response-guard.cjs`, and the CLI build + * (`cli/scripts/standaloneSidecars.js`) copies it next to `custom-server.js` + * in the shipped `cli/app` bundle — a missing copy crashes the standalone + * server at boot with MODULE_NOT_FOUND. + */ + +/** + * @param {import("http").IncomingMessage} req + * @returns {boolean} true when the request method is HEAD (case-insensitive). + */ +function isHeadRequest(req) { + return String(req?.method || "GET").toUpperCase() === "HEAD"; +} + +/** + * Mutate `res` in place so body writes are discarded while the real `end` + * still runs (status + headers flush, connection lifecycle stays intact). + * No-op for non-HEAD requests. + * + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse} res + */ +function applyHeadResponseGuard(req, res) { + if (!isHeadRequest(req)) return; + const origEnd = res.end; + res.write = function (_chunk, _enc, cb) { + // Accept (chunk, cb) and (chunk, enc, cb) arities; signal success. + const callback = typeof _enc === "function" ? _enc : cb; + if (typeof callback === "function") callback(); + return true; + }; + res.end = function (_chunk, _enc, cb) { + // Normalize (cb), (chunk, cb), (chunk, enc, cb) into a single + // callback and hand it to the REAL end so Node invokes it once, + // after the stream fully closes — never swallow it (a swallowed + // callback can hang async handlers awaiting `end`). Body chunks + // are dropped; status + headers set by the handler are kept. + const callback = typeof _chunk === "function" + ? _chunk + : (typeof _enc === "function" ? _enc : cb); + return origEnd.call(res, callback); + }; +} + +module.exports = { applyHeadResponseGuard, isHeadRequest }; diff --git a/scripts/build-app.mjs b/scripts/build-app.mjs index 40bbc17a1..8d8d5c82a 100644 --- a/scripts/build-app.mjs +++ b/scripts/build-app.mjs @@ -28,6 +28,10 @@ try { .find((candidate) => fs.existsSync(path.join(candidate, "server.js"))); if (!standaloneDir) throw new Error(`Standalone server not found under ${standaloneRoot}`); fs.copyFileSync(path.join(process.cwd(), "custom-server.js"), path.join(standaloneDir, "custom-server.js")); + // custom-server.js requires ./head-response-guard.cjs (OmniRoute #6908): + // a root-level sidecar outside Next's NFT trace, so it must be copied by + // hand or the standalone server crashes at boot with MODULE_NOT_FOUND. + fs.copyFileSync(path.join(process.cwd(), "head-response-guard.cjs"), path.join(standaloneDir, "head-response-guard.cjs")); fs.cpSync(path.join(process.cwd(), "src", "mitm"), path.join(standaloneDir, "src", "mitm"), { recursive: true, }); diff --git a/tests/unit/mitm-runtime-lifecycle.test.js b/tests/unit/mitm-runtime-lifecycle.test.js index e99be197d..0c9f1c64e 100644 --- a/tests/unit/mitm-runtime-lifecycle.test.js +++ b/tests/unit/mitm-runtime-lifecycle.test.js @@ -82,7 +82,7 @@ describe("MITM runtime lifecycle packaging", () => { const cli = fs.readFileSync(path.join(repoRoot, "cli/cli.js"), "utf8"); expect(buildMitm).toContain("cleanPlainFiles: false"); - expect(buildCli).toContain("custom-server.js is required"); + expect(buildCli).toContain("copyRequiredStandaloneSidecars(appDir, cliAppDir)"); expect(cli).toContain("owner-aware custom-server.js is missing"); expect(cli).not.toContain(": path.join(standaloneDir, \"server.js\")"); expect(cli).toContain('stdio: "ignore"'); diff --git a/tests/unit/standalone-sidecars-6908.test.js b/tests/unit/standalone-sidecars-6908.test.js new file mode 100644 index 000000000..9cbe33838 --- /dev/null +++ b/tests/unit/standalone-sidecars-6908.test.js @@ -0,0 +1,182 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; +import { createRequire } from "module"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { + STANDALONE_SIDECARS, + copyRequiredStandaloneSidecars, +} = require("../../cli/scripts/standaloneSidecars.js"); +const { applyHeadResponseGuard, isHeadRequest } = require("../../head-response-guard.cjs"); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "../.."); + +/** + * OmniRoute #6908: upstream shipped a standalone bundle whose entry imported + * `head-response-guard.cjs` without the packaging step copying it, so every + * release dist crashed at boot with MODULE_NOT_FOUND. durindoor's equivalent + * is `custom-server.js` requiring `./head-response-guard.cjs` (#6608) while + * living OUTSIDE Next's standalone trace — both the root standalone build + * (`scripts/build-app.mjs`) and the CLI bundle build + * (`cli/scripts/build-cli.js` step 3a) must copy the sidecar, and the CLI + * npm package must actually ship it under `app/`. + */ +describe("copyRequiredStandaloneSidecars", () => { + let tmpRoot; + let appDir; + let cliAppDir; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "standalone-sidecars-")); + appDir = path.join(tmpRoot, "app-src"); + cliAppDir = path.join(tmpRoot, "cli-app"); + fs.mkdirSync(appDir, { recursive: true }); + fs.writeFileSync(path.join(appDir, "custom-server.js"), "// custom server\n"); + fs.writeFileSync(path.join(appDir, "head-response-guard.cjs"), "// head guard\n"); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("copies every required sidecar into the bundle with exact contents", () => { + const written = copyRequiredStandaloneSidecars(appDir, cliAppDir); + expect(written).toHaveLength(STANDALONE_SIDECARS.length); + for (const name of STANDALONE_SIDECARS) { + const dest = path.join(cliAppDir, name); + expect(fs.existsSync(dest), `${name} must land in the CLI bundle`).toBe(true); + expect(fs.readFileSync(dest, "utf8")).toBe(fs.readFileSync(path.join(appDir, name), "utf8")); + } + }); + + it("fails hard before writing anything when a sidecar is missing", () => { + fs.rmSync(path.join(appDir, "head-response-guard.cjs")); + expect(() => copyRequiredStandaloneSidecars(appDir, cliAppDir)).toThrow(/MODULE_NOT_FOUND/); + // Preflight-then-copy: NO partial bundle is staged, so a failed build can + // never leave a custom-server-without-guard artifact behind. + expect(fs.existsSync(path.join(cliAppDir, "head-response-guard.cjs"))).toBe(false); + expect(fs.existsSync(path.join(cliAppDir, "custom-server.js"))).toBe(false); + }); + + it("lists head-response-guard.cjs as a required sidecar", () => { + expect(STANDALONE_SIDECARS).toContain("head-response-guard.cjs"); + expect(STANDALONE_SIDECARS).toContain("custom-server.js"); + }); +}); + +describe("standalone artifact packaging (repo manifest + build wiring)", () => { + it("cli package.json `files` ships the app/ dir that receives the sidecars", () => { + const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "cli", "package.json"), "utf8")); + // The sidecars land in / = cli/app/; the manifest + // must publish `app` or the tarball omits them (npm files list). + expect(pkg.files).toContain("app"); + // Guard rail against someone switching to per-file entries and dropping + // the sidecar from the tarball. + if (pkg.files.some((f) => f.startsWith("app/"))) { + expect(pkg.files).toContain("app/head-response-guard.cjs"); + } + }); + + it("build-cli.js step 3a copies sidecars via the shared helper (never a silent skip)", () => { + const src = fs.readFileSync(path.join(REPO_ROOT, "cli", "scripts", "build-cli.js"), "utf8"); + expect(src).toMatch(/require\(["']\.\/standaloneSidecars["']\)/); + expect(src).toMatch(/copyRequiredStandaloneSidecars\(appDir,\s*cliAppDir\)/); + }); + + it("root standalone build copies head-response-guard.cjs next to custom-server.js", () => { + const src = fs.readFileSync(path.join(REPO_ROOT, "scripts", "build-app.mjs"), "utf8"); + expect(src).toMatch(/custom-server\.js["'].*standaloneDir/s); + expect(src).toContain("head-response-guard.cjs"); + }); + + it("custom-server.js requires the sidecar at its bundle-relative path", () => { + const src = fs.readFileSync(path.join(REPO_ROOT, "custom-server.js"), "utf8"); + expect(src).toMatch(/require\(["']\.\/head-response-guard\.cjs["']\)/); + }); + + // Upstream #6908 regression guard, derived from source like the original: + // EVERY root-level relative require in custom-server.js must be shipped by + // the sidecar sync — otherwise the next added sidecar recreates the exact + // boot crash (MODULE_NOT_FOUND) this port fixes. Requires into `src/` and + // `open-sse/` are excluded: those trees are copied wholesale by their own + // build steps. + it("every root-level relative require of custom-server.js is a shipped sidecar", () => { + const src = fs.readFileSync(path.join(REPO_ROOT, "custom-server.js"), "utf8"); + const rootRequires = [...src.matchAll(/require\(["']\.\/([^"']+)["']\)/g)] + .map((m) => m[1]) + // `server.js` is generated by the Next standalone build (copied in + // wholesale by build step 3), and src/ + open-sse/ trees are covered by + // their own copy steps — only true repo-root sidecars belong in + // STANDALONE_SIDECARS. + .filter((p) => p !== "server.js" && !p.startsWith("src/") && !p.startsWith("open-sse/")); + expect(rootRequires.length).toBeGreaterThan(0); + for (const rel of rootRequires) { + expect( + STANDALONE_SIDECARS, + `custom-server.js requires ./${rel} but STANDALONE_SIDECARS does not ship it — the CLI bundle would crash at boot (MODULE_NOT_FOUND)` + ).toContain(rel); + } + }); +}); + +describe("head-response-guard.cjs (extracted #6608 behavior, unchanged)", () => { + const makeRes = () => { + const calls = { ended: 0, endArgs: null }; + return { + calls, + write: () => true, + end(...args) { + calls.ended += 1; + calls.endArgs = args; + return this; + }, + }; + }; + + it("detects HEAD case-insensitively and defaults others to non-HEAD", () => { + expect(isHeadRequest({ method: "HEAD" })).toBe(true); + expect(isHeadRequest({ method: "head" })).toBe(true); + expect(isHeadRequest({ method: "GET" })).toBe(false); + expect(isHeadRequest({})).toBe(false); + }); + + it("leaves non-HEAD responses untouched", () => { + const res = makeRes(); + const origWrite = res.write; + const origEnd = res.end; + applyHeadResponseGuard({ method: "POST" }, res); + expect(res.write).toBe(origWrite); + expect(res.end).toBe(origEnd); + }); + + it("drops body bytes but still calls the real end with a single callback", () => { + const res = makeRes(); + applyHeadResponseGuard({ method: "HEAD" }, res); + let writeCb = 0; + expect(res.write(Buffer.from("body"), () => { writeCb += 1; })).toBe(true); + expect(writeCb).toBe(1); + let endCb = 0; + res.end("ignored-chunk", () => { endCb += 1; }); + // Real end received exactly ONE function arg (the callback) — no chunk. + expect(res.calls.ended).toBe(1); + expect(res.calls.endArgs).toHaveLength(1); + expect(typeof res.calls.endArgs[0]).toBe("function"); + res.calls.endArgs[0](); + expect(endCb).toBe(1); + }); + + it("supports the (cb)-only end arity without swallowing the callback", () => { + const res = makeRes(); + applyHeadResponseGuard({ method: "HEAD" }, res); + let endCb = 0; + res.end(() => { endCb += 1; }); + expect(res.calls.ended).toBe(1); + expect(typeof res.calls.endArgs[0]).toBe("function"); + res.calls.endArgs[0](); + expect(endCb).toBe(1); + }); +});