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
5 changes: 5 additions & 0 deletions .agents/skills/webjs/references/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` defaults to Node. Add `--runtime bun` for a Bun-flavored app (or run `bun create webjs <name>`, which auto-detects Bun from the invoking package manager):
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/app/docs/deployment/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ server.all('*', async (req, res) =&gt; {
server.listen(8080);</pre>

<h3>Bun</h3>
<p>Running a WebJs app with <code>bun --bun run start</code> already uses <code>Bun.serve</code> natively: <code>startServer</code> detects Bun and selects a <code>Bun.serve</code> listener shell (skipping the node:http compatibility bridge for ~1.9x more requests/sec on the listening path), with near-complete feature parity (SSR, <code>route.ts</code>, SSE live-reload, WebSocket upgrade, brotli/gzip compression, timeouts, proxy-IP). The one node-only exception is 103 Early Hints, which <code>Bun.serve</code> cannot send (no informational-response API). So you only need the snippet below to <em>embed</em> WebJs inside your own <code>Bun.serve</code> alongside other routes:</p>
<p>Running a WebJs app with <code>bun --bun run start</code> already uses <code>Bun.serve</code> natively: <code>startServer</code> detects Bun and selects a <code>Bun.serve</code> listener shell (skipping the node:http compatibility bridge for ~1.9x more requests/sec on the listening path), with near-complete feature parity (SSR, <code>route.ts</code>, SSE live-reload, WebSocket upgrade, brotli/gzip compression, timeouts, proxy-IP, and the <code>X-Forwarded-Proto</code> / <code>X-Forwarded-Host</code> 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 <code>Bun.serve</code> cannot send (no informational-response API). So you only need the snippet below to <em>embed</em> WebJs inside your own <code>Bun.serve</code> alongside other routes:</p>
<p><strong>Embedding moves the proxy-header responsibility to you.</strong> The parity list above describes <code>startServer</code>, which owns the listener. <code>createRequestHandler</code> takes the <code>Request</code> you hand it and derives every absolute URL from its <code>url</code>, so behind a TLS-terminating proxy you must rebuild that <code>Request</code> with the original scheme and host yourself, or <code>ctx.url</code> stays on the internal <code>http://</code> 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 <code>startServer</code> and let the framework handle it.</p>
<pre>import { createRequestHandler } from '@webjsdev/server';

const app = await createRequestHandler({ appDir: process.cwd(), dev: false });
Expand Down
3 changes: 2 additions & 1 deletion packages/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading