@webjsdev/server 0.8.39 → 0.8.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,9 +3,15 @@
3
3
  * selected when the host runtime is Bun. Bun's server is `Request`/`Response`
4
4
  * native, so this path skips the node:http `toWebRequest` / `sendWebResponse`
5
5
  * bridge entirely and hands the app's `handle(req): Response` straight to
6
- * `Bun.serve({ fetch })`. A benchmark on the full listening path (node:http
7
- * compat on Bun vs `Bun.serve`) measured ~1.9x more req/s, which is what justifies
8
- * a second shell rather than running the node compat path on Bun.
6
+ * `Bun.serve({ fetch })`. A microbenchmark of the LISTENING PATH ONLY (a trivial
7
+ * handler: node:http compat on Bun vs `Bun.serve`) measured ~1.9x more req/s,
8
+ * which is what justifies a second shell rather than running the node compat path
9
+ * on Bun. That figure is the listening-path delta, NOT end-to-end throughput: for
10
+ * a real SSR page the render cost dominates and the listener delta is small (see
11
+ * the end-to-end benchmark in `scripts/bench-listener.mjs`, #756). The hot path
12
+ * is kept allocation-lean: the remote IP is stamped out of band (no per-request
13
+ * `Request` clone, #756) and a buffered response is compressed synchronously (no
14
+ * per-response stream bridge).
9
15
  *
10
16
  * Feature parity with the node shell, routed through the shared
11
17
  * `listener-core.js` so the two cannot drift:
@@ -39,6 +45,7 @@ import { EventEmitter } from 'node:events';
39
45
  import { Readable, pipeline } from 'node:stream';
40
46
  import { matchApi } from './router.js';
41
47
  import { registerClient } from './broadcast.js';
48
+ import { setTrustedRemoteIp } from './rate-limit.js';
42
49
  import {
43
50
  isCompressible,
44
51
  isEventsPath,
@@ -47,8 +54,10 @@ import {
47
54
  makeShutdown,
48
55
  negotiateEncoding,
49
56
  createCompressor,
57
+ compressBufferSync,
58
+ MAX_SYNC_COMPRESS_BYTES,
50
59
  varyWithAcceptEncoding,
51
- webStreamChunks,
60
+ readBufferedOrStream,
52
61
  } from './listener-core.js';
53
62
 
54
63
  /* global Bun */
@@ -93,9 +102,9 @@ export function startBunListener(ctx) {
93
102
  // hints still ship in the rendered <head>, so the only loss is the
94
103
  // head-start during SSR compute, not the preloads themselves.
95
104
 
96
- const webReq = stampRemoteIp(req, srv);
97
- const resp = await app.handle(webReq);
98
- return compress ? maybeCompress(resp, req) : resp;
105
+ stampRemoteIp(req, srv);
106
+ const resp = await app.handle(req);
107
+ return compress ? await maybeCompress(resp, req) : resp;
99
108
  } catch (e) {
100
109
  logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
101
110
  return new Response(
@@ -287,20 +296,21 @@ function bunSseResponse(req, hub, app) {
287
296
  }
288
297
 
289
298
  /**
290
- * Stamp the framework-trusted remote IP from Bun's `server.requestIP`, after
291
- * stripping any spoofed inbound `x-webjs-remote-ip` (the node `toWebRequest`
292
- * equivalent, which reads `req.socket.remoteAddress`). Re-wraps the request with
293
- * the adjusted headers, preserving method + body.
299
+ * Stamp the framework-trusted remote IP from Bun's `server.requestIP`,
300
+ * out-of-band via `setTrustedRemoteIp` (#756). The previous implementation
301
+ * cloned the whole `Request` (`new Request(req, { headers })`) on EVERY request
302
+ * just to set one header; the WeakMap channel avoids that hot-path reallocation.
303
+ * The stamp is authoritative (`clientIp` reads it and ignores the inbound
304
+ * `x-webjs-remote-ip` header), so a client cannot spoof it even though the
305
+ * unmodified request still carries any inbound copy. Always stamps (with `''`
306
+ * when no IP is available) so the inbound header is never trusted on Bun.
294
307
  * @param {Request} req
295
308
  * @param {any} srv
296
309
  */
297
310
  function stampRemoteIp(req, srv) {
298
- const headers = new Headers(req.headers);
299
- headers.delete('x-webjs-remote-ip');
300
311
  let ip;
301
312
  try { ip = srv.requestIP(req)?.address; } catch {}
302
- if (ip) headers.set('x-webjs-remote-ip', ip);
303
- return new Request(req, { headers });
313
+ setTrustedRemoteIp(req, ip || '');
304
314
  }
305
315
 
306
316
  /**
@@ -308,35 +318,70 @@ function stampRemoteIp(req, srv) {
308
318
  * negotiation + compressor factory the node shell uses (shared in
309
319
  * `listener-core.js`). `node:zlib` runs natively on Bun, so this gives **brotli
310
320
  * on Bun** (the web `CompressionStream` used before had no brotli) and full
311
- * compression parity with the node shell. The body is bridged web -> node ->
312
- * web (`Readable.from(webStreamChunks(...))` -> `compressor` -> `Readable.toWeb`,
313
- * driven by `pipeline`; NOT `Readable.fromWeb`, which does not propagate a
314
- * mid-stream source error through `pipeline` on Bun, the #509 hang). Skips an
315
- * already-encoded body and a non-compressible media type (`isCompressible`
316
- * already excludes `text/event-stream`).
321
+ * compression parity with the node shell.
322
+ *
323
+ * A BUFFERED body (the common SSR page / JSON / file response, detected by
324
+ * `readBufferedOrStream` peeking a single bounded chunk) is compressed
325
+ * SYNCHRONOUSLY (`compressBufferSync`), skipping the per-response
326
+ * web -> node -> web stream bridge entirely (#756). A genuinely STREAMED body
327
+ * (Suspense, an action / route stream) or an oversized one keeps the bridge
328
+ * (`Readable.from(peeked.stream)` -> `compressor` -> `Readable.toWeb`, driven by
329
+ * `pipeline`; NOT `Readable.fromWeb`, which does not propagate a mid-stream
330
+ * source error through `pipeline` on Bun, the #509 hang). The sync and streamed
331
+ * paths use the SAME algo + params, so WITHIN a runtime the buffered fast path
332
+ * and the streaming bridge produce identical bytes (a buffered body is not
333
+ * served differently from a streamed one). Across runtimes the exact bytes can
334
+ * differ for gzip / deflate (Bun's bundled zlib is not the same build as Node's,
335
+ * so the compressed output is not guaranteed identical, byte-for-byte; brotli
336
+ * does match), which is fine since each response is self-describing via
337
+ * `content-encoding` and a client just decodes it. Skips an already-encoded body
338
+ * and a non-compressible media type (`isCompressible` already excludes
339
+ * `text/event-stream`).
317
340
  * @param {Response} resp
318
341
  * @param {Request} req
319
342
  */
320
- function maybeCompress(resp, req) {
343
+ async function maybeCompress(resp, req) {
321
344
  if (!resp.body) return resp;
322
345
  if (resp.headers.has('content-encoding')) return resp;
323
346
  if (!isCompressible(resp.headers.get('content-type'))) return resp;
324
347
  const encoding = negotiateEncoding(req.headers.get('accept-encoding'));
325
- const compressor = createCompressor(encoding);
326
- if (!compressor) return resp;
348
+ if (!encoding) return resp;
327
349
 
328
350
  const headers = new Headers(resp.headers);
329
351
  headers.set('content-encoding', encoding);
330
- headers.delete('content-length');
331
352
  headers.set('vary', varyWithAcceptEncoding(headers.get('vary')));
332
- // Feed the web body into the compressor through the reader-loop generator (NOT
333
- // Readable.fromWeb, which does not propagate a mid-stream source error through
334
- // `pipeline` on Bun, the #509 hang) and drive it with `pipeline` so a source
335
- // error (or a client disconnect destroying the output) tears down the whole
336
- // chain instead of leaking/hanging the compressor. Backpressure is preserved:
337
- // a slow client stalls `toWeb`, which stalls the compressor, which pauses the
338
- // source.
339
- const source = Readable.from(webStreamChunks(resp.body));
353
+
354
+ // Buffered-body fast path (#756): peek the body to tell a single bounded
355
+ // buffered chunk (the common SSR page / JSON / file response) from a genuinely
356
+ // streamed body (Suspense, an action / route stream) WITHOUT buffering a real
357
+ // stream. A buffered body cannot error mid-stream, so compress it synchronously
358
+ // and skip the per-response web -> node -> web stream bridge entirely. The sync
359
+ // path and the streaming bridge share the SAME algo + params, so within this
360
+ // runtime a buffered body and a streamed one compress identically (the wire is
361
+ // self-describing via content-encoding either way). A streamed / oversized body
362
+ // falls through to the bridge below.
363
+ const peeked = await readBufferedOrStream(resp.body, MAX_SYNC_COMPRESS_BYTES);
364
+ if (peeked.buffered !== undefined) {
365
+ const out = compressBufferSync(encoding, peeked.buffered);
366
+ headers.set('content-length', String(out.length));
367
+ return new Response(out, { status: resp.status, statusText: resp.statusText, headers });
368
+ }
369
+
370
+ headers.delete('content-length');
371
+ // Allocate the streaming compressor ONLY on the streamed path: the buffered
372
+ // fast path above used `compressBufferSync` and never needs a Transform, so
373
+ // hoisting this would waste a native zlib/brotli handle on the common case.
374
+ // `encoding` is guaranteed br/gzip/deflate (negotiateEncoding), so
375
+ // `createCompressor` never returns null here.
376
+ const compressor = createCompressor(encoding);
377
+ // Feed the (peeked + drained) web body into the compressor through the reader
378
+ // loop (NOT Readable.fromWeb, which does not propagate a mid-stream source
379
+ // error through `pipeline` on Bun, the #509 hang) and drive it with `pipeline`
380
+ // so a source error (or a client disconnect destroying the output) tears down
381
+ // the whole chain instead of leaking/hanging the compressor. Backpressure is
382
+ // preserved: a slow client stalls `toWeb`, which stalls the compressor, which
383
+ // pauses the source.
384
+ const source = Readable.from(peeked.stream);
340
385
  pipeline(source, compressor, () => {});
341
386
  const body = Readable.toWeb(compressor);
342
387
  return new Response(body, { status: resp.status, statusText: resp.statusText, headers });
@@ -16,7 +16,11 @@
16
16
  * and its own `closeServer` thunk, and reuses everything else verbatim.
17
17
  */
18
18
  import { pathToFileURL } from 'node:url';
19
- import { createBrotliCompress, createGzip, createDeflate, constants as zlibConstants } from 'node:zlib';
19
+ import {
20
+ createBrotliCompress, createGzip, createDeflate,
21
+ brotliCompressSync, gzipSync, deflateSync,
22
+ constants as zlibConstants,
23
+ } from 'node:zlib';
20
24
  import { stripBasePath } from './base-path.js';
21
25
 
22
26
  /** The dev live-reload SSE path (matched after base-path stripping). */
@@ -96,6 +100,37 @@ export function createCompressor(encoding) {
96
100
  return null;
97
101
  }
98
102
 
103
+ /**
104
+ * The largest buffered body that takes the SYNCHRONOUS compression fast path
105
+ * (#756). Sync `node:zlib` blocks the event loop, whereas the streaming
106
+ * compressor offloads to the libuv threadpool, so the sync path is a win ONLY
107
+ * for small bodies (a typical SSR HTML page) where the per-response stream
108
+ * bridge dominates; a larger buffered body keeps the non-blocking stream path.
109
+ */
110
+ export const MAX_SYNC_COMPRESS_BYTES = 256 * 1024;
111
+
112
+ /**
113
+ * Compress an already-buffered body synchronously, byte-for-byte identical to
114
+ * the streaming `createCompressor` ON THE SAME RUNTIME (same algorithm + params:
115
+ * brotli quality 4, gzip / deflate level 6, over the same `node:zlib`), so a
116
+ * buffered fast path and a streamed slow path produce the same wire bytes within
117
+ * a runtime (#756). Across runtimes the exact gzip / deflate bytes can differ
118
+ * (Bun's bundled zlib is not Node's build; brotli matches), which is fine since
119
+ * each response is self-describing via `content-encoding`. For a buffered body
120
+ * (one that cannot error mid-stream) this skips the per-response
121
+ * web -> node -> web stream bridge entirely. Returns null for an unknown / empty
122
+ * encoding so the caller leaves the body uncompressed.
123
+ * @param {'br' | 'gzip' | 'deflate' | ''} encoding
124
+ * @param {Uint8Array} buf
125
+ * @returns {Buffer | null}
126
+ */
127
+ export function compressBufferSync(encoding, buf) {
128
+ if (encoding === 'br') return brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } });
129
+ if (encoding === 'gzip') return gzipSync(buf, { level: 6 });
130
+ if (encoding === 'deflate') return deflateSync(buf, { level: 6 });
131
+ return null;
132
+ }
133
+
99
134
  /**
100
135
  * Merge `Accept-Encoding` into an existing `Vary` header (or create it) without
101
136
  * duplicating, so compressing a response that already varies (on `Cookie`, an
@@ -135,6 +170,145 @@ export async function* webStreamChunks(web) {
135
170
  }
136
171
  }
137
172
 
173
+ /** An async generator that throws on first pull (a source that errored before
174
+ * any output), so the caller's `pipeline` tears down exactly as it would for a
175
+ * mid-stream error. @param {unknown} err */
176
+ async function* failingStream(err) { throw err; }
177
+
178
+ /** Yield a fixed set of already-read chunks with no further reads (the source is
179
+ * already exhausted). @param {Uint8Array[]} chunks */
180
+ async function* justChunks(chunks) { for (const c of chunks) yield c; }
181
+
182
+ /**
183
+ * Drain a reader after replaying already-peeked `prefix` chunks, mirroring
184
+ * `webStreamChunks`' error + early-cancel semantics (so the #509 hang fix is
185
+ * preserved for the reconstructed stream). If `pendingError` is set (the peek's
186
+ * second read rejected, a mid-stream source error), the prefix is replayed and
187
+ * then the error is rethrown.
188
+ * @param {Uint8Array[]} prefix
189
+ * @param {ReadableStreamDefaultReader} reader
190
+ * @param {unknown} pendingError
191
+ */
192
+ async function* drainAfter(prefix, reader, pendingError) {
193
+ let finished = false;
194
+ try {
195
+ for (const c of prefix) yield c;
196
+ if (pendingError) { finished = true; throw pendingError; }
197
+ for (;;) {
198
+ const { done, value } = await reader.read();
199
+ if (done) { finished = true; return; }
200
+ yield value;
201
+ }
202
+ } finally {
203
+ if (!finished) { try { await reader.cancel(); } catch {} }
204
+ try { reader.releaseLock(); } catch {}
205
+ }
206
+ }
207
+
208
+ /** Sentinel: the classifying second read has not settled within a macrotask, so
209
+ * the body is genuinely streaming and must not be withheld (#756). */
210
+ const STREAM_PENDING = Symbol('webjs.stream-pending');
211
+
212
+ /**
213
+ * Drain a reader for a STREAMED body whose classifying second read is still
214
+ * in flight (`secondP`, a settled-shape promise `{ r }` | `{ e }`). Yields the
215
+ * already-peeked `firstChunk`, then CONSUMES the in-flight second read (never
216
+ * issuing a duplicate `reader.read()`), then drains the rest. Same error +
217
+ * early-cancel teardown as `drainAfter` / `webStreamChunks` (the #509 fix).
218
+ * @param {Uint8Array} firstChunk
219
+ * @param {ReadableStreamDefaultReader} reader
220
+ * @param {Promise<{ r?: ReadableStreamReadResult<Uint8Array>, e?: unknown }>} secondP
221
+ */
222
+ async function* drainAfterPending(firstChunk, reader, secondP) {
223
+ let finished = false;
224
+ try {
225
+ yield firstChunk;
226
+ const settled = await secondP;
227
+ if (settled.e !== undefined) { finished = true; throw settled.e; }
228
+ if (settled.r.done) { finished = true; return; }
229
+ yield settled.r.value;
230
+ for (;;) {
231
+ const { done, value } = await reader.read();
232
+ if (done) { finished = true; return; }
233
+ yield value;
234
+ }
235
+ } finally {
236
+ if (!finished) { try { await reader.cancel(); } catch {} }
237
+ try { reader.releaseLock(); } catch {}
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Decide whether a web body is a single bounded buffered chunk (the common SSR
243
+ * page / JSON / file response) or a genuinely streamed body (Suspense, an action
244
+ * / route `ReadableStream`), WITHOUT buffering a real stream and WITHOUT
245
+ * withholding a streamed body's first byte (#756). It peeks the first chunk, then
246
+ * classifies on the SECOND read, but does NOT block on it: a buffered Response
247
+ * (built from a string / Uint8Array) has an already-closed underlying stream, so
248
+ * its second read settles on the next microtask; a streamed body's second chunk
249
+ * may be arbitrarily far in the future (a Suspense boundary resolving), and
250
+ * awaiting it would stall the status line + first byte until then. So the second
251
+ * read is RACED against a macrotask sentinel: if it settles first AND is `done`
252
+ * (single chunk) within `maxBytes`, the body is buffered and returned as bytes so
253
+ * the caller can sync-compress and skip the stream bridge; otherwise (still
254
+ * pending = streaming, or multi-chunk, oversized, empty, or a mid-stream error)
255
+ * it returns an async iterable that replays the peeked chunk(s) then drains the
256
+ * rest, with the same error + cancel semantics as `webStreamChunks`.
257
+ *
258
+ * @param {ReadableStream} web
259
+ * @param {number} maxBytes
260
+ * @returns {Promise<{ buffered: Uint8Array } | { stream: AsyncIterable<Uint8Array> }>}
261
+ */
262
+ export async function readBufferedOrStream(web, maxBytes) {
263
+ const reader = web.getReader();
264
+ let first;
265
+ try {
266
+ first = await reader.read();
267
+ } catch (e) {
268
+ // Source errored before any output: hand back a stream that rethrows it.
269
+ try { reader.releaseLock(); } catch {}
270
+ return { stream: failingStream(e) };
271
+ }
272
+ if (first.done) {
273
+ try { reader.releaseLock(); } catch {}
274
+ return { buffered: new Uint8Array(0) };
275
+ }
276
+ const firstChunk = first.value;
277
+
278
+ // Issue the classifying second read but DO NOT await it directly: race it
279
+ // against a macrotask sentinel so a streamed body (whose second chunk is far
280
+ // off) is handed back immediately as a stream instead of stalling first paint.
281
+ const secondP = reader.read().then((r) => ({ r }), (e) => ({ e }));
282
+ const settled = await Promise.race([
283
+ secondP,
284
+ new Promise((res) => { setImmediate(() => res(STREAM_PENDING)); }),
285
+ ]);
286
+ if (settled === STREAM_PENDING) {
287
+ // Still in flight after a macrotask: a genuinely streamed body. Emit the
288
+ // first chunk now and continue from the in-flight second read (consumed by
289
+ // the generator, never re-issued).
290
+ return { stream: drainAfterPending(firstChunk, reader, secondP) };
291
+ }
292
+ if (settled.e !== undefined) {
293
+ // The second read errored on a short body (a mid-stream source error):
294
+ // replay the first chunk then rethrow, matching webStreamChunks teardown.
295
+ return { stream: drainAfter([firstChunk], reader, settled.e) };
296
+ }
297
+ const second = settled.r;
298
+ if (second.done) {
299
+ try { reader.releaseLock(); } catch {}
300
+ if (firstChunk.byteLength <= maxBytes) return { buffered: firstChunk };
301
+ // One chunk but too large to block the event loop on a sync compress: stream
302
+ // it. The source is already exhausted (second read was `done`), so just
303
+ // replay the single chunk with no further reads.
304
+ return { stream: justChunks([firstChunk]) };
305
+ }
306
+ // Two quick chunks (a small multi-chunk buffered body): not a single buffer,
307
+ // so stream it (replay both, then drain the rest). The second read is already
308
+ // consumed, so drainAfter continues from the third chunk.
309
+ return { stream: drainAfter([firstChunk, second.value], reader, null) };
310
+ }
311
+
138
312
  /**
139
313
  * Load a `route.{js,ts}` module for its `WS` export, cache-busting in dev so a
140
314
  * code edit is picked up per connection. Shared by the node WebSocket subsystem
@@ -101,10 +101,87 @@ const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
101
101
  */
102
102
  const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
103
103
 
104
+ /**
105
+ * @type {RegExp} match a dynamic `import('…')` with a STRING-LITERAL specifier
106
+ * only (`import('./x.ts')`, `await import("#lib/y.ts")`). A computed specifier
107
+ * (`import(expr)`) cannot be captured and is intentionally left out: it stays a
108
+ * documented limitation (and `webjs check` warns on it), not a phantom edge.
109
+ *
110
+ * Unlike the static `IMPORT_RE` (which requires `\s+` after `import`), this
111
+ * matches `import` immediately or with whitespace before the `(`, so the two
112
+ * never overlap. `m.index` is the `import` keyword start, so the same
113
+ * redaction-mask guard (keyword-in-code-position) applies.
114
+ *
115
+ * The specifier may be followed by `)` OR `,` so the import-attributes form
116
+ * `import('./data.json', { with: { type: 'json' } })` (a JSON / CSS module
117
+ * import) is captured too. A computed specifier still falls out: in
118
+ * `import('./pages/' + name)` the char after the closing quote is `+`, neither
119
+ * `,` nor `)`, so it does not match.
120
+ */
121
+ const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*[,)]/g;
122
+
104
123
  /**
105
124
  * @typedef {Map<string, Set<string>>} ModuleGraph
106
- * A map of absolute file path → Set of absolute file paths it imports.
125
+ * A map of absolute file path → Set of absolute file paths it STATICALLY imports.
126
+ */
127
+
128
+ /**
129
+ * Dynamic-import edges (string-literal `import('…')` only), kept SEPARATE from
130
+ * the static graph and keyed by it in a WeakMap. The authorization gate
131
+ * (`reachableFromEntries`) unions these in so a lazily-imported app module is
132
+ * servable instead of 404ing, but the preload walk (`transitiveDeps`) and the
133
+ * elision analysis stay on the static graph ONLY: a dynamic import is lazy by
134
+ * author intent, so eagerly preloading its target would over-fetch on every
135
+ * page load, and feeding dynamic edges into elision could flip a verdict. The
136
+ * module is fetched at call time, now served correctly by the gate.
137
+ * @type {WeakMap<ModuleGraph, ModuleGraph>}
138
+ */
139
+ const DYNAMIC_EDGES = new WeakMap();
140
+
141
+ /**
142
+ * The dynamic-import edges discovered for a built graph (string-literal targets
143
+ * only), or an empty map if none. Exposed for tests/ops.
144
+ * @param {ModuleGraph} graph
145
+ * @returns {ModuleGraph}
146
+ */
147
+ export function dynamicEdges(graph) {
148
+ return DYNAMIC_EDGES.get(graph) || new Map();
149
+ }
150
+
151
+ /**
152
+ * Bare (npm vendor) import specifiers per file, kept SEPARATE from the static
153
+ * app graph (which only tracks relative + `#`-alias edges) and keyed by the
154
+ * graph in a WeakMap. Records the EXACT specifier as written (`dayjs`,
155
+ * `dayjs/plugin/utc`) so SSR can look it up in the vendor importmap and emit a
156
+ * `modulepreload` for the reached vendor URL (#754), flattening the vendor CDN
157
+ * waterfall. Excludes `node:` builtins and protocol specifiers.
158
+ * @type {WeakMap<ModuleGraph, Map<string, Set<string>>>}
159
+ */
160
+ const BARE_EDGES = new WeakMap();
161
+
162
+ /**
163
+ * The bare (npm vendor) import specifiers per file for a built graph, or an
164
+ * empty map if none. The values are specifiers (not resolved URLs); the caller
165
+ * maps them through the vendor importmap.
166
+ * @param {ModuleGraph} graph
167
+ * @returns {Map<string, Set<string>>}
107
168
  */
169
+ export function bareImports(graph) {
170
+ return BARE_EDGES.get(graph) || new Map();
171
+ }
172
+
173
+ /**
174
+ * True for a bare npm vendor specifier (`dayjs`, `@scope/pkg/sub`), excluding
175
+ * relative / absolute / `#`-alias paths and `node:` / protocol specifiers.
176
+ * Inlined here (not imported from vendor.js) to avoid a module cycle.
177
+ * @param {string} spec
178
+ * @returns {boolean}
179
+ */
180
+ function isVendorSpecifier(spec) {
181
+ if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('#') || spec.startsWith('__')) return false;
182
+ if (spec.startsWith('node:') || /^[a-z][a-z0-9+.-]*:/.test(spec)) return false;
183
+ return true;
184
+ }
108
185
 
109
186
  /**
110
187
  * Build the module graph for all source files under `appDir`.
@@ -118,10 +195,16 @@ export async function buildModuleGraph(appDir) {
118
195
  IMPORTS_CACHE.delete(appDir);
119
196
  /** @type {ModuleGraph} */
120
197
  const graph = new Map();
198
+ /** @type {ModuleGraph} dynamic-import edges, keyed to `graph` below */
199
+ const dynamic = new Map();
200
+ /** @type {Map<string, Set<string>>} bare vendor specifiers per file (#754) */
201
+ const bare = new Map();
121
202
  /** @type {Set<string>} every file walked this build (graph holds only files
122
203
  * with deps, so a separate set is needed to know what is still live). */
123
204
  const seen = new Set();
124
- await walk(appDir, appDir, graph, seen);
205
+ await walk(appDir, appDir, graph, seen, dynamic, bare);
206
+ if (dynamic.size) DYNAMIC_EDGES.set(graph, dynamic);
207
+ if (bare.size) BARE_EDGES.set(graph, bare);
125
208
  // Evict parse-cache entries for files no longer in the tree (a rebuild after
126
209
  // a rename or delete), so a long dev session does not accumulate dead
127
210
  // entries. Scoped to appDir so a multi-app process (tests, dogfood smoke)
@@ -226,6 +309,13 @@ const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
226
309
  * @returns {Set<string>}
227
310
  */
228
311
  export function reachableFromEntries(graph, entryFiles, appDir) {
312
+ // The gate unions in dynamic-import edges (#751) so a lazily-imported app
313
+ // module (`await import('./widget.ts')`) is servable instead of 404ing. A
314
+ // dynamically-imported module's OWN static imports are then walked normally
315
+ // (it joins the queue), so its subtree is servable too. The `.server.*`
316
+ // boundary still holds: a dynamic `import('./x.server.ts')` is admitted (a
317
+ // stub is served) but, like any server file, not traversed into.
318
+ const dynamic = DYNAMIC_EDGES.get(graph);
229
319
  /** @type {Set<string>} */
230
320
  const visited = new Set();
231
321
  /** @type {string[]} */
@@ -242,13 +332,17 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
242
332
  // time), but we don't add its imports because the browser never
243
333
  // sees them.
244
334
  if (SERVER_FILE_RE.test(file)) continue;
245
- const deps = graph.get(file);
246
- if (!deps) continue;
247
- for (const dep of deps) {
248
- if (visited.has(dep)) continue;
249
- if (!dep.startsWith(appDir)) continue;
250
- visited.add(dep);
251
- queue.push(dep);
335
+ const staticDeps = graph.get(file);
336
+ const dynDeps = dynamic && dynamic.get(file);
337
+ if (!staticDeps && !dynDeps) continue;
338
+ for (const set of [staticDeps, dynDeps]) {
339
+ if (!set) continue;
340
+ for (const dep of set) {
341
+ if (visited.has(dep)) continue;
342
+ if (!dep.startsWith(appDir)) continue;
343
+ visited.add(dep);
344
+ queue.push(dep);
345
+ }
252
346
  }
253
347
  }
254
348
  return visited;
@@ -260,7 +354,7 @@ export function reachableFromEntries(graph, entryFiles, appDir) {
260
354
  * @param {string} appDir
261
355
  * @param {ModuleGraph} graph
262
356
  */
263
- async function walk(dir, appDir, graph, seen) {
357
+ async function walk(dir, appDir, graph, seen, dynamic, bare) {
264
358
  let entries;
265
359
  try { entries = await readdir(dir, { withFileTypes: true }); }
266
360
  catch { return; }
@@ -278,9 +372,9 @@ async function walk(dir, appDir, graph, seen) {
278
372
  if (e.name.startsWith('.')) continue;
279
373
  const full = join(dir, e.name);
280
374
  if (e.isDirectory()) {
281
- await walk(full, appDir, graph, seen);
375
+ await walk(full, appDir, graph, seen, dynamic, bare);
282
376
  } else if (/\.(js|ts|mjs|mts)$/.test(e.name)) {
283
- await parseFile(full, appDir, graph, seen);
377
+ await parseFile(full, appDir, graph, seen, dynamic, bare);
284
378
  }
285
379
  }
286
380
  }
@@ -308,7 +402,7 @@ export function _parseCacheHas(file) { return PARSE_CACHE.has(file); }
308
402
  * @param {string} appDir
309
403
  * @param {ModuleGraph} graph
310
404
  */
311
- async function parseFile(file, appDir, graph, seen) {
405
+ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
312
406
  let mtimeMs, size;
313
407
  try { const st = await stat(file); mtimeMs = st.mtimeMs; size = st.size; }
314
408
  catch { return; }
@@ -316,6 +410,8 @@ async function parseFile(file, appDir, graph, seen) {
316
410
  const cached = PARSE_CACHE.get(file);
317
411
  if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
318
412
  if (cached.deps.size) graph.set(file, cached.deps);
413
+ if (cached.dynDeps && cached.dynDeps.size && dynamic) dynamic.set(file, cached.dynDeps);
414
+ if (cached.bareDeps && cached.bareDeps.size && bare) bare.set(file, cached.bareDeps);
319
415
  return;
320
416
  }
321
417
 
@@ -343,6 +439,8 @@ async function parseFile(file, appDir, graph, seen) {
343
439
  // code-example `import` string never becomes an edge.
344
440
  const masked = redactStringsAndTemplates(src, true);
345
441
  const deps = new Set();
442
+ /** @type {Set<string>} bare npm vendor specifiers imported by this file (#754) */
443
+ const bareDeps = new Set();
346
444
  for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
347
445
  for (const m of src.matchAll(re)) {
348
446
  // m.index is the keyword start (`\bimport` / `\bexport`). If that
@@ -350,17 +448,45 @@ async function parseFile(file, appDir, graph, seen) {
350
448
  // and is not a real import edge.
351
449
  if (masked[m.index] === ' ') continue;
352
450
  const spec = m[1];
451
+ // Guard a match whose `from '<spec>'` tail reaches INTO a blanked literal:
452
+ // EXPORT_FROM_RE's lazy `[^'";]+?` can span a template body to a `from`
453
+ // written inside example code (`export const t = html\`...import x from
454
+ // 'left-pad'\``), so the KEYWORD is real but the SPECIFIER is not. A real
455
+ // string keeps its delimiters in the mask (only the body blanks), while a
456
+ // template body blanks whole, so a blanked opening quote means the
457
+ // specifier is inside a literal and the match is spurious.
458
+ const quoteAt = m.index + m[0].length - spec.length - 2;
459
+ if (masked[quoteAt] === ' ') continue;
353
460
  // Only resolve relative imports + `#`-style subpath aliases (#555)
354
461
  // within the project. A bare npm specifier (dayjs) has no alias match
355
- // and is skipped; an aliased `#lib/x.server.ts` IS followed so the
356
- // graph / auth gate / elision see the real path through the alias.
357
- if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
462
+ // and is skipped FROM THE GRAPH, but recorded as a vendor edge so SSR
463
+ // can emit a modulepreload for the reached vendor URL (#754); an aliased
464
+ // `#lib/x.server.ts` IS followed so the graph / auth gate / elision see
465
+ // the real path through the alias.
466
+ if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) {
467
+ if (isVendorSpecifier(spec)) bareDeps.add(spec);
468
+ continue;
469
+ }
358
470
  const resolved = resolveImport(spec, file, appDir);
359
471
  if (resolved) deps.add(resolved);
360
472
  }
361
473
  }
362
- PARSE_CACHE.set(file, { mtimeMs, size, deps });
474
+ // Dynamic `import('…')` with a string-literal specifier (#751): a separate
475
+ // edge class so the gate admits the lazily-loaded module (no 404) without
476
+ // preloading it. Same redaction-mask + alias rules as the static scan. A
477
+ // target already statically imported is not duplicated as a dynamic edge.
478
+ const dynDeps = new Set();
479
+ for (const m of src.matchAll(DYNAMIC_IMPORT_RE)) {
480
+ if (masked[m.index] === ' ') continue;
481
+ const spec = m[1];
482
+ if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
483
+ const resolved = resolveImport(spec, file, appDir);
484
+ if (resolved && !deps.has(resolved)) dynDeps.add(resolved);
485
+ }
486
+ PARSE_CACHE.set(file, { mtimeMs, size, deps, dynDeps, bareDeps });
363
487
  if (deps.size) graph.set(file, deps);
488
+ if (dynDeps.size && dynamic) dynamic.set(file, dynDeps);
489
+ if (bareDeps.size && bare) bare.set(file, bareDeps);
364
490
  }
365
491
 
366
492
  /**
@@ -2,6 +2,7 @@ import { isNotFound, isRedirect } from '@webjsdev/core';
2
2
  import { ssrPage, ssrNotFound, loadModule } from './ssr.js';
3
3
  import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
4
4
  import { getBodyLimits } from './context.js';
5
+ import { propagateTrustedRemoteIp } from './rate-limit.js';
5
6
 
6
7
  /**
7
8
  * Page server actions: a `page.{js,ts}` may export an `action` function that
@@ -133,12 +134,20 @@ async function parseFormBody(req) {
133
134
  if (tooLarge) return { tooLarge: true, formData: new FormData(), request: req };
134
135
 
135
136
  // Rebuild a fresh Request from the bytes so the action can re-read the body.
137
+ // SECURITY (#756): strip any inbound `x-webjs-remote-ip` the copy carried so a
138
+ // client cannot spoof it through the rebuild, and carry the FRAMEWORK-trusted
139
+ // remote IP forward out of band (the rebuild is a new object, so the listener's
140
+ // WeakMap stamp on `req` does not follow it). Without this, `clientIp` inside a
141
+ // page `action` (the no-JS form write path, e.g. login throttling) would read
142
+ // the spoofable header on Bun.
136
143
  const headers = new Headers(req.headers);
144
+ headers.delete('x-webjs-remote-ip');
137
145
  const rebuilt = new Request(req.url, {
138
146
  method: req.method,
139
147
  headers,
140
148
  body: bytes && bytes.byteLength ? bytes : undefined,
141
149
  });
150
+ propagateTrustedRemoteIp(req, rebuilt);
142
151
 
143
152
  const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
144
153
  let formData = new FormData();