@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.
- package/index.d.ts +9 -0
- package/package.json +1 -1
- package/src/action-error.js +56 -0
- package/src/action-stream.js +20 -4
- package/src/actions.js +26 -14
- package/src/cache-fn.js +28 -5
- package/src/cache-tags.js +43 -19
- package/src/cache.js +53 -0
- package/src/check.js +12 -8
- package/src/dev.js +30 -2
- package/src/importmap.js +36 -0
- package/src/listener-bun.js +78 -33
- package/src/listener-core.js +175 -1
- package/src/module-graph.js +143 -17
- package/src/page-action.js +9 -0
- package/src/rate-limit.js +60 -5
- package/src/router.js +63 -6
- package/src/ssr.js +100 -2
- package/src/testing.js +5 -1
package/src/listener-bun.js
CHANGED
|
@@ -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
|
|
7
|
-
* compat on Bun vs `Bun.serve`) measured ~1.9x more req/s,
|
|
8
|
-
* a second shell rather than running the node compat path
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
const resp = await app.handle(
|
|
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`,
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* the
|
|
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
|
-
|
|
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.
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
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 });
|
package/src/listener-core.js
CHANGED
|
@@ -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 {
|
|
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
|
package/src/module-graph.js
CHANGED
|
@@ -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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (!
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
356
|
-
//
|
|
357
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/page-action.js
CHANGED
|
@@ -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();
|