diff --git a/.agents/skills/webjs/references/runtime.md b/.agents/skills/webjs/references/runtime.md index e8911d707..20899a165 100644 --- a/.agents/skills/webjs/references/runtime.md +++ b/.agents/skills/webjs/references/runtime.md @@ -35,9 +35,14 @@ Three seams pick a runtime-specific implementation, all inside the framework, no | Hot reload | `node --watch` | `bun --hot` | | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter | | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) | +| Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same | The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes. +Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client, only `http` and `https` are accepted as a scheme, and a malformed host is ignored rather than failing the request. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site. + +Two limits worth knowing. `WEBJS_NO_TRUST_PROXY=1` stops the URL rewrite (and the HSTS scheme check) from trusting the headers when the container is directly exposed, but it is not a global switch: the CSRF host check still reads `X-Forwarded-Host` regardless. And this rewrite belongs to `startServer`. An app embedded through `createRequestHandler` gets the `Request` its host adapter built, so that adapter owns the correction, the same boundary that already applies to the trusted client IP. + ## Scaffolding a Bun app `webjs create ` defaults to Node. Add `--runtime bun` for a Bun-flavored app (or run `bun create webjs `, which auto-detects Bun from the invoking package manager): diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 580993ca2..5c86ce4b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,14 @@ jobs: # on the Bun SSR path (buffered and streamed) as on Node. - name: webjs binding-prefix dispatch on Bun run: bun test/bun/binding-prefixes.mjs + # Reverse-proxy forwarded headers on Bun (#1090): the node shell corrects + # the request url via urlFromRequest before building its Request, but the + # Bun shell handed Bun.serve's request straight through, so behind a + # TLS-terminating proxy every absolute URL an app derived came out http:// + # (webjs.dev served an http:// og:image). This asserts a page's ctx.url AND + # a route handler's raw req.url both carry the forwarded scheme + host. + - name: Forwarded proto/host on Bun + run: bun test/bun/forwarded-proto.mjs # Light-DOM slot SSR projection on Bun (#1021): slot substitution # (injectDSD / substituteSlotsInRender) is on the SSR hot path, so the # projection must be byte-consistent across runtimes: authored children diff --git a/docs/app/docs/deployment/page.ts b/docs/app/docs/deployment/page.ts index b4bd7c25b..db0f57f49 100644 --- a/docs/app/docs/deployment/page.ts +++ b/docs/app/docs/deployment/page.ts @@ -249,7 +249,8 @@ server.all('*', async (req, res) => { server.listen(8080);

Bun

-

Running a WebJs app with bun --bun run start already uses Bun.serve natively: startServer detects Bun and selects a Bun.serve listener shell (skipping the node:http compatibility bridge for ~1.9x more requests/sec on the listening path), with near-complete feature parity (SSR, route.ts, SSE live-reload, WebSocket upgrade, brotli/gzip compression, timeouts, proxy-IP). The one node-only exception is 103 Early Hints, which Bun.serve cannot send (no informational-response API). So you only need the snippet below to embed WebJs inside your own Bun.serve alongside other routes:

+

Running a WebJs app with bun --bun run start already uses Bun.serve natively: startServer detects Bun and selects a Bun.serve listener shell (skipping the node:http compatibility bridge for ~1.9x more requests/sec on the listening path), with near-complete feature parity (SSR, route.ts, SSE live-reload, WebSocket upgrade, brotli/gzip compression, timeouts, proxy-IP, and the X-Forwarded-Proto / X-Forwarded-Host URL correction so absolute URLs carry the original scheme and host behind a TLS-terminating proxy). The one node-only exception is 103 Early Hints, which Bun.serve cannot send (no informational-response API). So you only need the snippet below to embed WebJs inside your own Bun.serve alongside other routes:

+

Embedding moves the proxy-header responsibility to you. The parity list above describes startServer, which owns the listener. createRequestHandler takes the Request you hand it and derives every absolute URL from its url, so behind a TLS-terminating proxy you must rebuild that Request with the original scheme and host yourself, or ctx.url stays on the internal http:// hop and og:image tags, canonical links, and OAuth callbacks come out wrong. The same applies to the trusted client IP. If you do not need to share the port with other routes, prefer startServer and let the framework handle it.

import { createRequestHandler } from '@webjsdev/server';
 
 const app = await createRequestHandler({ appDir: process.cwd(), dev: false });
diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md
index af45acf80..10aed6968 100644
--- a/packages/server/AGENTS.md
+++ b/packages/server/AGENTS.md
@@ -56,7 +56,8 @@ with metadata, Suspense, streaming) for HTML, or `api.js` /
 | `csrf.js` | Origin / `Sec-Fetch-Site` CSRF protection for server-action endpoints (`verifyOrigin`), plus `readAllowedOrigins` (the `webjs.allowedOrigins` reader). No token cookie, so SSR HTML carries no `Set-Cookie` and a public-`Cache-Control` page is CDN-cacheable |
 | `websocket.js` | node:http WS upgrade handling: invokes the `WS` export from `route.ts` over the `ws` library. Shares the `route.ts` module resolution (`loadWsModule`) with the Bun WS path via `listener-core.js`. **IP-trust on the upgrade (#778):** `buildRequestFromUpgrade` strips any inbound (spoofable) `x-webjs-remote-ip` and stamps the real `req.socket.remoteAddress` via `setTrustedRemoteIp` (the authoritative WeakMap `clientIp` reads first), so a `WS` handler calling `clientIp(req)` gets the trusted peer, not a client-supplied header. The WS-seam analog of the #773 fetch-path fix (the upgrade check runs before the fetch handler's stamp, so it was never stamped before). |
 | `listener-core.js` | Runtime-neutral listener core (#511): the pieces both listener shells share so they cannot drift. `serverRuntime()` picks the shell (`'bun'` when `process.versions.bun` is set, else `'node'`); `SseHub` is the SSE registry + fanout (the connected-client Set, the keepalive timer, `reload()` / `devError()`, each shell supplying a thin client wrapper over its own transport); `isEventsPath` is the base-path-aware live-reload predicate; `isCompressible` is the shared compressible-media-type set; the **compression seam (#517, #756)** `negotiateEncoding` (prefers `br` > `gzip` > `deflate`) + `createCompressor` (a streaming `node:zlib` Transform, native on Bun, so BOTH shells get brotli) + `compressBufferSync` (the SYNC counterpart for an already-buffered body, byte-identical output) + `readBufferedOrStream` (peeks a body to classify single-bounded-chunk buffered vs genuinely streamed, so the Bun shell can sync-compress a buffered body and skip the stream bridge, #756; it RACES the classifying second read against a macrotask sentinel rather than awaiting it, so a genuinely streamed body, Suspense / streamed action, is handed back immediately and its response head + first byte are NOT withheld until the far-off second chunk) + `varyWithAcceptEncoding` are shared so the node and Bun shells compress identically; `loadWsModule` loads a `route.ts` for its `WS` export (shared by `websocket.js` and the Bun WS path); `installProcessHandlers` + `makeShutdown` are the neutral process-handler + graceful-shutdown wiring (`makeShutdown` takes a `closeServer()` thunk so node `server.close` and Bun `server.stop(true)` both fit). |
-| `listener-bun.js` | The `Bun.serve` listener shell (#511), dynamically imported by `dev.js` only when `serverRuntime()` is `'bun'` (so the `Bun.*` global is never referenced on Node). `startBunListener(ctx)` hands the app's `handle(req): Response` straight to `Bun.serve({ fetch })`, skipping the node:http `toWebRequest` / `sendWebResponse` bridge (~1.9x more req/s on the LISTENING PATH ONLY, a trivial-handler microbenchmark, NOT end-to-end: render dominates a real SSR page, see `scripts/bench-listener.mjs`, #756). **Hot-path overhead reductions (#756):** the remote IP is stamped OUT OF BAND via `setTrustedRemoteIp` (a WeakMap `clientIp` reads in preference to the header), eliminating the per-request `new Request(req, { headers })` clone; and a BUFFERED response (peeked via `readBufferedOrStream`) is compressed SYNCHRONOUSLY (`compressBufferSync`), skipping the per-response web -> node -> web stream bridge, which remains only for genuinely streamed bodies (the sync path and the streaming bridge share the same algo + params, so WITHIN a runtime a buffered body and a streamed one compress identically; across runtimes the exact gzip/deflate bytes can differ since Bun's bundled zlib is not Node's build, which is fine as each response is self-describing via `content-encoding`). Feature parity via the shared core: SSE over a streaming `Response`, WS upgrade via `server.upgrade` with a `BunWsAdapter` that re-exposes the node `ws`-library EventEmitter contract (`.on('message')` / `.send()` / `.readyState`) over Bun's `ServerWebSocket` (the handler request is IP-stamped from `server.requestIP(req)` via `setTrustedRemoteIp` and `upgradeRequest` drops any inbound `x-webjs-remote-ip`, so `clientIp` in a `WS` handler is spoof-proof, #778, mirroring the fetch path's `stampRemoteIp`), **brotli/gzip/deflate via `node:zlib`** (the shared `createCompressor` / `compressBufferSync`, native on Bun, so the Bun path serves brotli too, #517), the #237 timeout mapped to Bun's single `idleTimeout`, and proxy-IP via `server.requestIP`. 103 Early Hints are node-only (Bun.serve has no informational-response API). |
+| `listener-bun.js` | The `Bun.serve` listener shell (#511), dynamically imported by `dev.js` only when `serverRuntime()` is `'bun'` (so the `Bun.*` global is never referenced on Node). `startBunListener(ctx)` hands the app's `handle(req): Response` straight to `Bun.serve({ fetch })`, skipping the node:http `toWebRequest` / `sendWebResponse` bridge (~1.9x more req/s on the LISTENING PATH ONLY, a trivial-handler microbenchmark, NOT end-to-end: render dominates a real SSR page, see `scripts/bench-listener.mjs`, #756). **Hot-path overhead reductions (#756):** the remote IP is stamped OUT OF BAND via `setTrustedRemoteIp` (a WeakMap `clientIp` reads in preference to the header), eliminating the per-request `new Request(req, { headers })` clone; and a BUFFERED response (peeked via `readBufferedOrStream`) is compressed SYNCHRONOUSLY (`compressBufferSync`), skipping the per-response web -> node -> web stream bridge, which remains only for genuinely streamed bodies (the sync path and the streaming bridge share the same algo + params, so WITHIN a runtime a buffered body and a streamed one compress identically; across runtimes the exact gzip/deflate bytes can differ since Bun's bundled zlib is not Node's build, which is fine as each response is self-describing via `content-encoding`). Feature parity via the shared core: SSE over a streaming `Response`, WS upgrade via `server.upgrade` with a `BunWsAdapter` that re-exposes the node `ws`-library EventEmitter contract (`.on('message')` / `.send()` / `.readyState`) over Bun's `ServerWebSocket` (the handler request is IP-stamped from `server.requestIP(req)` via `setTrustedRemoteIp` and `upgradeRequest` drops any inbound `x-webjs-remote-ip`, so `clientIp` in a `WS` handler is spoof-proof, #778, mirroring the fetch path's `stampRemoteIp`), **brotli/gzip/deflate via `node:zlib`** (the shared `createCompressor` / `compressBufferSync`, native on Bun, so the Bun path serves brotli too, #517), the #237 timeout mapped to Bun's single `idleTimeout`, and proxy-IP via `server.requestIP`. **Reverse-proxy URL correction (#1090):** `Bun.serve` hands over a `Request` whose url is the internal `http://container` view, so `forwardedRequest` rebuilds it from `applyForwarded` (`forwarded.js`) when `X-Forwarded-Proto` / `X-Forwarded-Host` change the origin, the Bun analog of the node shell building its `Request` from `urlFromRequest`. The rebuild is skipped (same URL instance returned) when nothing changes, so an unproxied app keeps the #756 no-clone hot path; the trusted IP rides across via `propagateTrustedRemoteIp`, and `bunUpgrade` applies the same correction to the WS handler request. Without it every absolute URL an app derived came out `http://` behind a TLS-terminating proxy. 103 Early Hints are node-only (Bun.serve has no informational-response API). |
+| `forwarded.js` | Reverse-proxy URL resolution (`X-Forwarded-Proto` / `X-Forwarded-Host`), honoring the `WEBJS_NO_TRUST_PROXY=1` opt-out and taking the first value of a comma-separated chain (the value closest to the client). Two entry points over a shared `readForwarded` (trust switch, allowed schemes, comma-chain rule) + `resolveOrigin` (host layering), so the HEADER and ORIGIN decisions cannot drift (what each shell RECEIVES still differs: node gets an origin-form target it treats as a path, Bun gets a url its own parser resolved, so an absolute-form request line routes differently between them; the origin is decided identically for both, and a client authority that disagrees with `Host` is discarded): `urlFromRequest(req)` for a node `IncomingMessage` (used by `startNodeListener` + `websocket.js`) and `applyForwarded(url, headers)` for a web `Request`'s `Headers` (used by the Bun shell, #1090). `applyForwarded` returns the SAME url instance when nothing changes, which is the caller's signal to skip rebuilding the request. Without this, a TLS-terminating proxy leaves `ctx.url.origin` as `http://internal-host`, breaking og:image tags, OAuth callback URLs, canonical tags, and any app code building an absolute URL. |
 | `listener-types.js` | Types only: the `ListenerContext` typedef `startServer` passes to whichever shell it selects (the node:http path in `dev.js`, the Bun path in `listener-bun.js`). |
 | `broadcast.js` | `broadcast(topic, msg)` for fan-out messaging |
 | `context.js` | AsyncLocalStorage per-request context (`getRequest`, `withRequest`, `headers`, `cookies`). The per-user readers `headers()` / `cookies()` (plus `getSession()` in `session.js` and `readSession()` behind `auth()` in `auth.js`) call `markDynamicAccess()`, so the HTML cache's commit step reads `dynamicAccessed()` and refuses to cache a per-user page that wrongly set `revalidate` (#241). Also exposes the per-request correlation id via `requestId()` (set by the handler with `setRequestId`, #239) and wires the server-side `cspNonce()` provider: returns the per-request nonce `setCspNonce` stored (minted when CSP is on, #233), else falls back to parsing an inbound `Content-Security-Policy` request header |
diff --git a/packages/server/src/forwarded.js b/packages/server/src/forwarded.js
index cf09a1720..c33cfa0eb 100644
--- a/packages/server/src/forwarded.js
+++ b/packages/server/src/forwarded.js
@@ -32,23 +32,178 @@
  * @returns {URL}
  */
 export function urlFromRequest(req) {
-  const trust = process.env.WEBJS_NO_TRUST_PROXY !== '1';
-  let host = null;
-  let proto = null;
-  if (trust) {
-    host = firstHeaderValue(req.headers['x-forwarded-host']);
-    proto = firstHeaderValue(req.headers['x-forwarded-proto']);
+  const { host, proto } = readForwarded((n) => /** @type {any} */ (req.headers)[n]);
+  const rawHost = /** @type {string|undefined} */ (req.headers.host) || '';
+  const u = resolveOrigin(proto || 'http', host, rawHost);
+  // Assign the request target's parts rather than RESOLVING it against `u`.
+  // Resolving is unsafe: a request line may carry `//evil.com/x`, a
+  // scheme-relative reference, so `new URL('//evil.com/x', 'https://real-host')`
+  // resolves to `https://evil.com/x`, handing over the origin AND silently
+  // rewriting the path to `/x` so a different route matches. A request target
+  // is a path, never a full reference, so it is assigned as one.
+  const m = /^([^?#]*)(\?[^#]*)?(#.*)?$/.exec(req.url || '/');
+  u.pathname = (m && m[1]) || '/';
+  u.search = (m && m[2]) || '';
+  u.hash = (m && m[3]) || '';
+  return u;
+}
+
+/**
+ * Build the origin both entry points agree on, as an origin-only URL.
+ *
+ * Sharing this is what keeps the ORIGIN decision identical across the two
+ * shells rather than a coincidence of two similar-looking expressions. The
+ * layering matters: start from a known-good placeholder, apply the raw `Host`,
+ * then let the forwarded host override it. Each assignment goes through
+ * `setHost`, which ignores a value it cannot parse, so a junk header falls back
+ * to the previous layer rather than 500ing the request or collapsing to
+ * localhost.
+ *
+ * @param {string} proto  http / https. `normalizeProto` guarantees it for a
+ *   forwarded value; the callers' fallbacks pass the url's own scheme, which is
+ *   http or https for any request a listener shell can receive.
+ * @param {string | null} forwardedHost
+ * @param {string} rawHost  the `Host` header
+ * @returns {URL} an origin-only URL
+ */
+function resolveOrigin(proto, forwardedHost, rawHost) {
+  const u = new URL(`${proto}://localhost`);
+  if (rawHost) setHost(u, rawHost);
+  if (forwardedHost) setHost(u, forwardedHost);
+  return u;
+}
+
+/**
+ * Assign an authority, clearing any port the previous layer left behind.
+ *
+ * The `host` setter only updates the port when the new value CARRIES one, so
+ * layering `X-Forwarded-Host: docs.webjs.dev` over `Host: container:3000`
+ * otherwise yields `docs.webjs.dev:3000`: the public hostname wearing the
+ * internal port. Clearing first makes each layer a full replacement.
+ *
+ * @param {URL} u
+ * @param {string} value
+ */
+function setHost(u, value) {
+  let probe;
+  try {
+    probe = new URL(`${u.protocol}//${value}`);
+  } catch {
+    // Not a parseable authority (`a b`, `[`, a port over 65535). Leave the
+    // previous layer in place rather than throwing, so a junk header is never a
+    // 500 on the fetch path or a failed WS handshake.
+    return;
   }
-  const finalHost = host || /** @type {string|undefined} */ (req.headers.host) || 'localhost';
-  const finalProto = proto || 'http';
-  return new URL(req.url || '/', `${finalProto}://${finalHost}`);
+  u.hostname = probe.hostname;
+  // Assign the port unconditionally, including the empty string, so a value
+  // carrying no port clears one the previous layer left behind.
+  u.port = probe.port;
+}
+
+/**
+ * Apply the forwarded headers to an ALREADY-PARSED url, for a shell whose
+ * request is a web `Request` (Bun) rather than a node `IncomingMessage`.
+ *
+ * `urlFromRequest` above cannot be reused directly: it reads `req.headers` as a
+ * plain node object, while a web `Request` exposes a `Headers` instance whose
+ * values come from `.get()`. Reusing it against a `Request` silently reads
+ * `undefined` for every header, so it LOOKS wired up while changing nothing.
+ * Both entry points funnel through `readForwarded` (the trust switch, the
+ * allowed schemes, the comma-chain rule) and `resolveOrigin` (the host
+ * layering), so the HEADER and ORIGIN decisions cannot drift. What each shell
+ * RECEIVES still differs: node gets an origin-form request target it treats as
+ * a path, Bun gets a url its own parser already resolved, so an absolute-form
+ * request line routes differently between them. The security-relevant part, the
+ * origin, is decided here for both.
+ *
+ * Returns the SAME URL instance when nothing changes, so the caller can use
+ * identity to skip rebuilding a request on the hot path. That fast path also
+ * requires the url's own authority to already AGREE with the `Host` header:
+ * `Bun.serve` reports an absolute-form request line (`GET http://evil/x`) as
+ * the request's url, so returning early on "no forwarded headers" would let an
+ * unproxied app hand a client full control of `ctx.url.origin`. When they
+ * disagree the origin is rebuilt from `Host` and the client's authority is
+ * discarded. A normal request has `url.host === Host`, so the optimization is
+ * intact for every real request.
+ *
+ * The origin is rebuilt through the shared `resolveOrigin`, and the path parts
+ * are COPIED onto it rather than re-parsed against it. Re-parsing is unsafe:
+ * `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
+ * reference, so `new URL('//evil.com/x', 'https://real-host')` resolves to
+ * `https://evil.com/x`. An attacker needed only the `X-Forwarded-Proto` every
+ * TLS-terminating proxy already sets to take over the origin AND silently
+ * change which route matched.
+ *
+ * The host FALLBACK is the `Host` header, not `url.host`, to match
+ * `urlFromRequest` exactly: node builds from the raw header string, so with
+ * `Host: webjs.dev:80` + `X-Forwarded-Proto: https` it judges the `:80` against
+ * https and keeps it. Falling back to the already-normalized `url.host` dropped
+ * it (port 80 is http's default), which made the two shells disagree on the
+ * exact proto-only shape this helper exists for.
+ *
+ * @param {URL} url  the url as the local listener saw it
+ * @param {Headers} headers  the web `Request` headers
+ * @returns {URL} the corrected url, or `url` itself when unchanged
+ */
+export function applyForwarded(url, headers) {
+  const { host, proto } = readForwarded((n) => headers.get(n));
+  const rawHost = headers.get('host') || url.host;
+  if (!host && !proto && url.host === rawHost) return url;
+  // `url.protocol` carries its trailing colon; the forwarded header does not.
+  const origin = resolveOrigin(proto || url.protocol.slice(0, -1), host, rawHost);
+  if (origin.host === url.host && origin.protocol === url.protocol) return url;
+  // Copy the path across verbatim. Never re-parse it against the new origin:
+  // `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
+  // reference, so resolving would resolve the authority out of the PATH and
+  // hand an attacker the origin (plus a different matched route) using only the
+  // `X-Forwarded-Proto` every TLS-terminating proxy already sets.
+  origin.pathname = url.pathname;
+  origin.search = url.search;
+  origin.hash = url.hash;
+  return origin;
+}
+
+/**
+ * Read the forwarded host / proto through a header getter, honoring the
+ * `WEBJS_NO_TRUST_PROXY=1` opt-out. The one place the trust decision and the
+ * comma-chain rule live, shared by the node and Bun entry points above.
+ *
+ * @param {(name: string) => string | string[] | undefined | null} getHeader
+ * @returns {{ host: string | null, proto: string | null }}
+ */
+function readForwarded(getHeader) {
+  if (process.env.WEBJS_NO_TRUST_PROXY === '1') return { host: null, proto: null };
+  return {
+    host: firstHeaderValue(getHeader('x-forwarded-host')),
+    proto: normalizeProto(firstHeaderValue(getHeader('x-forwarded-proto'))),
+  };
+}
+
+/**
+ * Accept only `http` / `https` as a forwarded scheme (case-insensitively; the
+ * URL normal form is lowercase, and comparing a raw `HTTPS` against
+ * `url.protocol` otherwise misses the no-op case).
+ *
+ * Anything else is dropped rather than honored. A non-special scheme is the
+ * damaging shape: `X-Forwarded-Proto: javascript` produced
+ * `javascript://host/path`, whose `origin` is the literal string `null`, so
+ * every absolute URL the app derived became `null/...`. A proxy in front of an
+ * HTTP server only ever forwards http or https, so an allowlist costs nothing.
+ *
+ * @param {string | null} p
+ * @returns {string | null}
+ */
+function normalizeProto(p) {
+  if (!p) return null;
+  const v = p.toLowerCase();
+  return v === 'http' || v === 'https' ? v : null;
 }
 
 /**
  * Pick the first comma-separated value from a header that may be a
  * string, an array of strings, or undefined.
  *
- * @param {string | string[] | undefined} h
+ * @param {string | string[] | undefined | null} h
  * @returns {string | null}
  */
 function firstHeaderValue(h) {
diff --git a/packages/server/src/listener-bun.js b/packages/server/src/listener-bun.js
index 5e4c45e42..0cff03c0c 100644
--- a/packages/server/src/listener-bun.js
+++ b/packages/server/src/listener-bun.js
@@ -45,7 +45,8 @@ import { EventEmitter } from 'node:events';
 import { Readable, pipeline } from 'node:stream';
 import { matchApi } from './router.js';
 import { registerClient } from './broadcast.js';
-import { setTrustedRemoteIp } from './rate-limit.js';
+import { setTrustedRemoteIp, propagateTrustedRemoteIp } from './rate-limit.js';
+import { applyForwarded } from './forwarded.js';
 import {
   isCompressible,
   isEventsPath,
@@ -104,7 +105,7 @@ export function startBunListener(ctx) {
         // head-start during SSR compute, not the preloads themselves.
 
         stampRemoteIp(req, srv);
-        const resp = await app.handle(req);
+        const resp = await app.handle(forwardedRequest(req, url));
         return compress ? await maybeCompress(resp, req) : resp;
       } catch (e) {
         logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
@@ -237,7 +238,11 @@ async function bunUpgrade(req, srv, ctx) {
   }
 
   const wrapper = new BunWsAdapter();
-  const handlerReq = upgradeRequest(req, url);
+  // Behind a proxy the handshake arrives as plain http on the internal hop, so
+  // the handler request carries the ORIGINAL scheme + host too (the node WS path
+  // gets this from `urlFromRequest` in `buildRequestFromUpgrade`). Routing above
+  // keyed on `url.pathname`, which the origin swap leaves untouched.
+  const handlerReq = upgradeRequest(req, applyForwarded(url, req.headers));
   // Stamp the framework-trusted socket IP on the handler request so a `WS`
   // handler's `clientIp(req)` returns the real peer, not a spoofed inbound
   // `x-webjs-remote-ip` (#778). `srv.requestIP` must be queried with Bun's
@@ -317,6 +322,78 @@ function bunSseResponse(req, hub, app) {
   });
 }
 
+/**
+ * Rebuild the request behind a TLS-terminating reverse proxy so `req.url`
+ * carries the ORIGINAL scheme + host (`X-Forwarded-Proto` / `X-Forwarded-Host`).
+ *
+ * Why the request itself and not just a threaded url: everything downstream
+ * (the framework's own `new URL(req.url)` reads AND a user's `route.ts` doing
+ * `new URL(req.url).origin`) derives from `req.url`, so correcting only a side
+ * channel would fix the framework's absolute URLs and leave app code broken.
+ * The node shell gets this for free, since `toWebRequest` builds its `Request`
+ * FROM the already-corrected `urlFromRequest(req)`; Bun hands us a `Request`
+ * whose url reflects the internal `http://container` view, so the correction
+ * has to be applied here for the two shells to agree.
+ *
+ * Hot path: `applyForwarded` returns the SAME url instance when nothing
+ * changes, so an app with no proxy in front (local dev, direct exposure,
+ * `WEBJS_NO_TRUST_PROXY=1`) does ZERO extra work and keeps the #756 no-clone
+ * behaviour. Behind a proxy this costs one `Request` construction per request,
+ * which is still strictly less than the node shell, which constructs one
+ * unconditionally. The trusted IP is carried across the rebuild the same way
+ * the base-path rewrite in `dev.js` does it (#773), so `clientIp` stays
+ * authoritative and the inbound spoofable header is never consulted.
+ *
+ * @param {Request} req
+ * @param {URL} url  the url as `Bun.serve` saw it
+ * @returns {Request} `req` itself when unchanged, else the corrected request
+ */
+function forwardedRequest(req, url) {
+  const fwd = applyForwarded(url, req.headers);
+  if (fwd === url) return req;
+  // SECURITY (#778): drop any client-supplied `x-webjs-remote-ip` before it
+  // rides onto the rebuilt request. `trustedRemoteIp` reads the WeakMap first,
+  // so the header cannot win today, but every other rebuild site strips it
+  // (`dev.js`'s base-path rewrite, `upgradeRequest` below) and leaving it
+  // attached would re-arm the spoof the moment any later code path rebuilds
+  // this request without carrying the WeakMap across.
+  const headers = new Headers(req.headers);
+  headers.delete('x-webjs-remote-ip');
+  const next = new Request(fwd, { ...requestInitFrom(req), headers });
+  propagateTrustedRemoteIp(req, next);
+  return next;
+}
+
+/**
+ * The parts of a `Request` that must survive the rebuild, spelled out because
+ * the header strip above needs its own `Headers` and passing the `Request` as
+ * the init would ignore them.
+ *
+ * `duplex: 'half'` is required by the spec whenever a body is present, and
+ * `mode: 'navigate'` cannot be passed to the constructor, so it is dropped.
+ *
+ * @param {Request} req
+ */
+function requestInitFrom(req) {
+  /** @type {any} */
+  const init = {
+    method: req.method,
+    signal: req.signal,
+    credentials: req.credentials,
+    cache: req.cache,
+    redirect: req.redirect,
+    referrer: req.referrer,
+    referrerPolicy: req.referrerPolicy,
+    integrity: req.integrity,
+  };
+  if (req.mode && req.mode !== 'navigate') init.mode = req.mode;
+  if (req.method !== 'GET' && req.method !== 'HEAD') {
+    init.body = req.body;
+    init.duplex = 'half';
+  }
+  return init;
+}
+
 /**
  * Stamp the framework-trusted remote IP from Bun's `server.requestIP`,
  * out-of-band via `setTrustedRemoteIp` (#756). The previous implementation
diff --git a/packages/server/test/forwarded/forwarded.test.js b/packages/server/test/forwarded/forwarded.test.js
index f411bf5ea..c971993b3 100644
--- a/packages/server/test/forwarded/forwarded.test.js
+++ b/packages/server/test/forwarded/forwarded.test.js
@@ -12,7 +12,7 @@
  */
 import { test } from 'node:test';
 import assert from 'node:assert/strict';
-import { urlFromRequest } from '../../src/forwarded.js';
+import { urlFromRequest, applyForwarded } from '../../src/forwarded.js';
 
 function makeReq(url, headers = {}) {
   return { url, headers };
@@ -120,3 +120,200 @@ test('urlFromRequest: preserves query string + hash through proxy', () => {
   }));
   assert.equal(u.href, 'https://docs.webjs.dev/search?q=hello&page=2#results');
 });
+
+/**
+ * applyForwarded: the web-`Request` counterpart of urlFromRequest, used by the
+ * Bun listener shell (#1090). The node shell builds its `Request` from an
+ * already-corrected url, so the two entry points must agree for an identical
+ * request or the same app behaves differently on Node and Bun.
+ */
+
+function webHeaders(h = {}) {
+  return new Headers(h);
+}
+
+test('applyForwarded: proxy proto + host rewrite the origin', () => {
+  const url = new URL('http://container:3000/about');
+  const out = applyForwarded(url, webHeaders({
+    'x-forwarded-proto': 'https',
+    'x-forwarded-host': 'webjs.dev',
+  }));
+  assert.equal(out.href, 'https://webjs.dev/about');
+});
+
+test('applyForwarded: proto alone upgrades the scheme, keeping the host', () => {
+  // Railway's shape: the Host header already carries the public domain, only
+  // the scheme is internal. This is the exact case that shipped an http://
+  // og:image on webjs.dev.
+  const out = applyForwarded(new URL('http://webjs.dev/'), webHeaders({ 'x-forwarded-proto': 'https' }));
+  assert.equal(out.href, 'https://webjs.dev/');
+});
+
+test('applyForwarded: no proxy headers returns the SAME instance (hot-path no-op)', () => {
+  const url = new URL('http://localhost:5001/');
+  const out = applyForwarded(url, webHeaders({}));
+  // Identity, not just equality: the Bun shell keys its skip-the-rebuild
+  // decision on this, so an app with no proxy does zero extra work.
+  assert.equal(out, url);
+});
+
+test('applyForwarded: headers that agree with the url return the SAME instance', () => {
+  const url = new URL('https://webjs.dev/x');
+  const out = applyForwarded(url, webHeaders({
+    'x-forwarded-proto': 'https',
+    'x-forwarded-host': 'webjs.dev',
+  }));
+  assert.equal(out, url);
+});
+
+test('applyForwarded: comma-separated chain takes the value closest to the client', () => {
+  // CDN then load balancer then container: Cloudflare in front of Railway is
+  // exactly this shape.
+  const out = applyForwarded(new URL('http://container/'), webHeaders({
+    'x-forwarded-proto': 'https,http',
+    'x-forwarded-host': 'webjs.dev, internal.railway',
+  }));
+  assert.equal(out.href, 'https://webjs.dev/');
+});
+
+test('applyForwarded: preserves path, query and hash across the origin swap', () => {
+  const out = applyForwarded(new URL('http://container/search?q=hello&page=2#results'), webHeaders({
+    'x-forwarded-proto': 'https',
+    'x-forwarded-host': 'docs.webjs.dev',
+  }));
+  assert.equal(out.href, 'https://docs.webjs.dev/search?q=hello&page=2#results');
+});
+
+test('applyForwarded: WEBJS_NO_TRUST_PROXY=1 ignores the headers', () => {
+  const prev = process.env.WEBJS_NO_TRUST_PROXY;
+  process.env.WEBJS_NO_TRUST_PROXY = '1';
+  try {
+    const url = new URL('http://real-host:3000/x');
+    const out = applyForwarded(url, webHeaders({
+      'x-forwarded-proto': 'https',
+      'x-forwarded-host': 'attacker.example.com',
+    }));
+    assert.equal(out, url);
+  } finally {
+    if (prev !== undefined) process.env.WEBJS_NO_TRUST_PROXY = prev;
+    else delete process.env.WEBJS_NO_TRUST_PROXY;
+  }
+});
+
+test('applyForwarded and urlFromRequest agree for the same request', () => {
+  // The parity assertion: whatever the node shell computes from an
+  // IncomingMessage, the Bun shell must compute from the web Request.
+  const headers = { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'webjs.dev' };
+  const node = urlFromRequest(makeReq('/a/b?c=1', { host: 'container:3000', ...headers }));
+  const bun = applyForwarded(new URL('http://container:3000/a/b?c=1'), webHeaders(headers));
+  assert.equal(bun.href, node.href);
+});
+
+/**
+ * Hostile / malformed forwarded headers. Everything here is reachable by a
+ * client whose headers the edge proxy forwards rather than overwrites, and the
+ * node and Bun entry points must agree on every one of them.
+ */
+
+test('a //-prefixed path cannot become the authority (both entry points)', () => {
+  // `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
+  // reference. Resolving it against a new base yields `https://evil.com/x`:
+  // the origin is taken over AND the path collapses to `/x`, so a DIFFERENT
+  // route matches. Only `X-Forwarded-Proto` is needed, which every
+  // TLS-terminating proxy sets.
+  const want = 'https://container:3000//evil.com/x?q=1';
+  const node = urlFromRequest(makeReq('//evil.com/x?q=1', {
+    host: 'container:3000',
+    'x-forwarded-proto': 'https',
+  }));
+  const bun = applyForwarded(new URL('http://container:3000//evil.com/x?q=1'), webHeaders({
+    host: 'container:3000',
+    'x-forwarded-proto': 'https',
+  }));
+  assert.equal(node.href, want, 'node keeps the real host and the full path');
+  assert.equal(bun.href, want, 'bun keeps the real host and the full path');
+  assert.equal(bun.host, 'container:3000');
+  assert.equal(bun.pathname, '//evil.com/x');
+});
+
+test('a malformed forwarded host is ignored, never thrown', () => {
+  // An unparseable authority used to raise `Invalid URL`, which the fetch path
+  // turned into a 500 and the WS upgrade path into a failed handshake.
+  for (const bad of ['a b', '[', 'ho st']) {
+    const bun = applyForwarded(new URL('http://real/p'), webHeaders({ host: 'real', 'x-forwarded-host': bad }));
+    assert.equal(bun.href, 'http://real/p', `bun ignores ${JSON.stringify(bad)}`);
+    const node = urlFromRequest(makeReq('/p', { host: 'real', 'x-forwarded-host': bad }));
+    assert.equal(node.href, 'http://real/p', `node ignores ${JSON.stringify(bad)}`);
+  }
+});
+
+test('an out-of-range port makes the whole authority unparseable, so it is ignored', () => {
+  // Rejected wholesale rather than salvaging the hostname: a malformed
+  // authority is not honored at all, which is easier to reason about than a
+  // partially-applied one. Both entry points must agree on that.
+  const node = urlFromRequest(makeReq('/p', { host: 'c', 'x-forwarded-host': 'webjs.dev:99999' }));
+  const bun = applyForwarded(new URL('http://c/p'), webHeaders({ host: 'c', 'x-forwarded-host': 'webjs.dev:99999' }));
+  assert.equal(node.href, 'http://c/p');
+  assert.equal(bun.href, node.href);
+});
+
+test('a forwarded host without a port clears the internal port (no hostname/port mixing)', () => {
+  // The `host` setter only updates the port when the new value carries one, so
+  // layering the public hostname over `Host: container:3000` could otherwise
+  // produce `docs.webjs.dev:3000`: the public name wearing the internal port.
+  const headers = { host: 'container:3000', 'x-forwarded-host': 'docs.webjs.dev', 'x-forwarded-proto': 'https' };
+  const node = urlFromRequest(makeReq('/a', headers));
+  const bun = applyForwarded(new URL('http://container:3000/a'), webHeaders(headers));
+  assert.equal(node.href, 'https://docs.webjs.dev/a');
+  assert.equal(bun.href, node.href);
+});
+
+test('only http and https are accepted as a forwarded scheme', () => {
+  // A non-special scheme collapses `origin` to the literal string "null", so
+  // every absolute URL the app derives becomes "null/...".
+  for (const bad of ['javascript', 'file', 'data', 'ftp']) {
+    const bun = applyForwarded(new URL('http://webjs.dev/p'), webHeaders({ host: 'webjs.dev', 'x-forwarded-proto': bad }));
+    assert.equal(bun.href, 'http://webjs.dev/p', `bun rejects ${bad}`);
+    assert.notEqual(bun.origin, 'null');
+    const node = urlFromRequest(makeReq('/p', { host: 'webjs.dev', 'x-forwarded-proto': bad }));
+    assert.equal(node.href, 'http://webjs.dev/p', `node rejects ${bad}`);
+  }
+});
+
+test('a forwarded scheme is matched case-insensitively', () => {
+  // Assert the UPGRADE, not the no-op: an identity check cannot tell
+  // "recognised as https" from "rejected as an unknown scheme", since both
+  // return the url unchanged. Driving it from an http url makes the two
+  // outcomes observably different.
+  const upgraded = applyForwarded(new URL('http://webjs.dev/p'), webHeaders({ host: 'webjs.dev', 'x-forwarded-proto': 'HTTPS' }));
+  assert.equal(upgraded.href, 'https://webjs.dev/p', 'uppercase HTTPS still upgrades the scheme');
+  assert.equal(
+    urlFromRequest(makeReq('/p', { host: 'webjs.dev', 'x-forwarded-proto': 'HTTPS' })).href,
+    'https://webjs.dev/p',
+    'and the node entry point agrees',
+  );
+  // The no-op direction still holds on an already-https url.
+  const already = new URL('https://webjs.dev/p');
+  assert.equal(applyForwarded(already, webHeaders({ host: 'webjs.dev', 'x-forwarded-proto': 'HTTPS' })), already);
+});
+
+test('an absolute-form request line cannot supply the origin', () => {
+  // `Bun.serve` reports an absolute-form request line as the request's url, so
+  // a client sending `GET http://evil.example/path` would otherwise own
+  // ctx.url.origin on an unproxied app. The Host header decides the origin.
+  const bun = applyForwarded(new URL('http://evil.example/path'), webHeaders({ host: 'localhost:5001' }));
+  assert.equal(bun.origin, 'http://localhost:5001', 'the client authority is discarded');
+  assert.equal(bun.pathname, '/path');
+});
+
+test('proto-only forwarding agrees on the host fallback (the Railway shape)', () => {
+  // The branch the earlier parity test missed: with no x-forwarded-host, node
+  // falls back to the raw Host header. Reading the already-normalized url.host
+  // instead dropped an explicit :80 (http's default port) and the two shells
+  // disagreed on exactly the case this helper exists for.
+  const headers = { host: 'webjs.dev:80', 'x-forwarded-proto': 'https' };
+  const node = urlFromRequest(makeReq('/a', headers));
+  const bun = applyForwarded(new URL('http://webjs.dev:80/a'), webHeaders(headers));
+  assert.equal(bun.href, node.href, 'the two shells agree on the host fallback');
+  assert.equal(node.href, 'https://webjs.dev:80/a');
+});
diff --git a/test/bun/forwarded-proto.mjs b/test/bun/forwarded-proto.mjs
new file mode 100644
index 000000000..c0053d0e0
--- /dev/null
+++ b/test/bun/forwarded-proto.mjs
@@ -0,0 +1,194 @@
+/**
+ * Cross-runtime forwarded-header proof (#1090): boot a real WebJs app through
+ * `startServer` and assert, under WHICHEVER runtime runs it, that a request
+ * carrying `X-Forwarded-Proto` / `X-Forwarded-Host` is seen by the app with the
+ * ORIGINAL scheme + host:
+ *
+ *   node test/bun/forwarded-proto.mjs   # the node:http shell (urlFromRequest -> toWebRequest)
+ *   bun  test/bun/forwarded-proto.mjs   # the Bun.serve shell (applyForwarded -> forwardedRequest)
+ *
+ * The node shell always got this right, because `toWebRequest` builds its
+ * `Request` from an already-corrected url. The Bun shell handed `Bun.serve`'s
+ * request straight through, so `req.url` kept the internal `http://container`
+ * view and every absolute URL the app derived came out `http://`. That shipped:
+ * https://webjs.dev (Bun on Railway behind Cloudflare) served
+ * ``.
+ *
+ * Both surfaces are asserted, because they fail independently:
+ *   1. a PAGE's `ctx.url` (what `generateMetadata` builds og:image from), and
+ *   2. a `route.ts` handler's raw `req.url` (what app code reads directly).
+ * A fix that only threads a corrected url into the framework's own metadata
+ * path would pass (1) and still leave (2) broken.
+ *
+ * A plain assert script (not node:test), so the SAME file runs on both runtimes.
+ * Run from the repo root so the bare `@webjsdev/server` specifier resolves.
+ *
+ * The failure is reported by an explicit `process.exit(1)` rather than by letting
+ * the assertion propagate, because `startServer` installs an `uncaughtException`
+ * handler that begins a graceful shutdown and exits 0. On Bun a top-level
+ * assertion failure routes through that handler, so a broken proof would exit 0
+ * and CI's `bun test/bun/.mjs` step would go GREEN on a real regression
+ * (verified: node exits 1, Bun exits 0). Filed separately as #1092 for the other
+ * proof scripts, which all share this shape.
+ */
+import assert from 'node:assert/strict';
+import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { connect } from 'node:net';
+import { startServer } from '@webjsdev/server';
+
+/**
+ * Issue a GET with a VERBATIM request target, bypassing the fetch client.
+ *
+ * Both runtimes' `fetch` normalize the path before it reaches the wire (Bun
+ * collapses a leading `//`), which is exactly the shape being attacked here, so
+ * a raw socket is the only way to put it on the wire.
+ *
+ * @param {number} port
+ * @param {string} target
+ * @param {Record} [headers]
+ * @returns {Promise} the raw response text
+ */
+function rawGet(port, target, headers = {}) {
+  return new Promise((res, rej) => {
+    const sock = connect(port, '127.0.0.1', () => {
+      const extra = Object.entries(headers).map(([k, v]) => `${k}: ${v}\r\n`).join('');
+      sock.write(`GET ${target} HTTP/1.1\r\nHost: localhost:${port}\r\nConnection: close\r\n${extra}\r\n`);
+    });
+    let buf = '';
+    sock.setTimeout(8000, () => { sock.destroy(); rej(new Error(`raw GET ${target} timed out`)); });
+    sock.on('data', (d) => { buf += d; });
+    sock.on('end', () => res(buf));
+    sock.on('error', rej);
+  });
+}
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const CORE = pathToFileURL(resolve(__dirname, '../../packages/core/index.js')).toString();
+const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
+const quiet = { info() {}, warn() {}, error() {}, debug() {} };
+
+const dir = mkdtempSync(join(tmpdir(), 'wj-forwarded-'));
+const w = (rel, body) => { const abs = join(dir, rel); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, body); };
+
+let close;
+/** @type {unknown} */
+let failure = null;
+try {
+  writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'forwarded', type: 'module', webjs: {} }));
+  w('app/layout.ts', `import { html } from ${JSON.stringify(CORE)};\nexport default ({ children }: { children: unknown }) => html\`\${children}\`;`);
+  // The real shape: a page building an absolute asset URL from ctx.url, exactly
+  // how website/app/layout.ts derives its og:image.
+  w('app/page.ts', `import { html } from ${JSON.stringify(CORE)};\nexport function generateMetadata(ctx: { url: string }) {\n  const origin = new URL(ctx.url).origin;\n  return { title: 'fwd', openGraph: { image: origin + '/public/og.png' } };\n}\nexport default () => html\`
page
\`;`); + // A route handler reading the raw request url, the app-code surface. + w('app/api/whoami/route.ts', `export async function GET(req: Request) {\n return Response.json({ url: req.url, origin: new URL(req.url).origin, ip: req.headers.get('x-webjs-remote-ip') });\n}`); + w('app/api/echo/route.ts', `export async function POST(req: Request) {\n return Response.json({ method: req.method, body: await req.text(), ct: req.headers.get('content-type'), origin: new URL(req.url).origin });\n}`); + // The target of the scheme-relative-authority attack below. If the path is + // resolved rather than assigned, `//evil.com/x` collapses to `/x` and reaches + // THIS route; correct behaviour never matches it. + w('app/x/route.ts', `export async function GET(req: Request) {\n return Response.json({ reached: true, url: req.url });\n}`); + // A WebSocket endpoint, so the upgrade path's correction is asserted too. + w('app/live/route.ts', `export function WS(ws: any, req: Request) {\n ws.send(JSON.stringify({ url: req.url }));\n}`); + + let server; + ({ server, close } = await startServer({ appDir: dir, dev: true, port: 0, logger: quiet })); + const port = typeof server.port === 'number' ? server.port : server.address().port; + const base = `http://localhost:${port}`; + const proxied = { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'webjs.dev' }; + + // 1. The page's ctx.url origin, read back off the rendered og:image tag. + const page = await fetch(`${base}/`, { headers: proxied }); + assert.equal(page.status, 200, 'page is 200'); + const html = await page.text(); + const og = / { + const sock = new WebSocket(`ws://localhost:${port}/live`, { headers: proxied }); + const timer = setTimeout(() => { try { sock.close(); } catch {} rej(new Error('WS handshake timed out')); }, 8000); + sock.onmessage = (ev) => { clearTimeout(timer); try { sock.close(); } catch {} res(JSON.parse(String(ev.data)).url); }; + sock.onerror = () => { clearTimeout(timer); rej(new Error('WS connection failed')); }; + }); + assert.equal(new URL(wsUrl).origin, 'https://webjs.dev', `the WS handler request is origin-corrected on ${runtime}`); + + await close(); + close = null; + console.log(`OK forwarded proto/host passed on ${runtime} (page ctx.url + route req.url + WS all https)`); +} catch (e) { + failure = e; +} finally { + try { if (close) await close(); } catch {} + rmSync(dir, { recursive: true, force: true }); +} + +if (failure) { + console.error(`FAIL forwarded proto/host on ${runtime}`); + console.error(failure instanceof Error ? failure.stack || failure.message : String(failure)); + process.exit(1); +} diff --git a/test/bun/forwarded-proto.test.mjs b/test/bun/forwarded-proto.test.mjs new file mode 100644 index 000000000..d4f835411 --- /dev/null +++ b/test/bun/forwarded-proto.test.mjs @@ -0,0 +1,13 @@ +/** + * Run the cross-runtime forwarded-header proof (#1090) under WHICHEVER runtime + * executes the suite. The root `node --test` runner picks this up (so `npm test` + * exercises the node:http shell); CI runs `bun test/bun/forwarded-proto.mjs` for + * the Bun.serve shell, which is the one the bug was in. The proof is a plain + * assert script (`forwarded-proto.mjs`, not `*.test.mjs`) so the runner does not + * double-run it. + */ +import { test } from 'node:test'; + +test('forwarded proto/host reach the app on this runtime (#1090)', async () => { + await import('./forwarded-proto.mjs'); +});