Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions cli/scripts/build-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand Down Expand Up @@ -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);
}

Expand Down
58 changes: 58 additions & 0 deletions cli/scripts/standaloneSidecars.js
Original file line number Diff line number Diff line change
@@ -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 };
43 changes: 8 additions & 35 deletions custom-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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") {
Expand Down
46 changes: 46 additions & 0 deletions docs/ports/omniroute-6908.md
Original file line number Diff line number Diff line change
@@ -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
```
65 changes: 65 additions & 0 deletions head-response-guard.cjs
Original file line number Diff line number Diff line change
@@ -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 };
4 changes: 4 additions & 0 deletions scripts/build-app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/mitm-runtime-lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
Expand Down
Loading
Loading