@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/src/rate-limit.js CHANGED
@@ -89,13 +89,68 @@ export function rateLimit(opts = {}) {
89
89
  */
90
90
  const REMOTE_IP_HEADER = 'x-webjs-remote-ip';
91
91
 
92
+ /**
93
+ * Out-of-band trusted remote IP (#756). A listener shell that already holds the
94
+ * socket IP can stamp it here instead of reallocating a whole `Request` just to
95
+ * set the `x-webjs-remote-ip` header (the Bun hot path). When an entry exists
96
+ * for a request it is AUTHORITATIVE: `trustedRemoteIp` returns it and ignores
97
+ * the inbound header entirely, so a client-spoofed `x-webjs-remote-ip` is never
98
+ * trusted even though the original (unmodified) request still carries it.
99
+ * @type {WeakMap<Request, string>}
100
+ */
101
+ const trustedIpMap = new WeakMap();
102
+
103
+ /**
104
+ * Stamp the trusted remote IP for a request out-of-band (no Request clone).
105
+ * Pass `''` (or a falsy value) to mark the request as listener-stamped with no
106
+ * known IP, which still makes the inbound header untrusted (resolves to the
107
+ * anon fallback rather than a spoofable header value).
108
+ * @param {Request} req
109
+ * @param {string | null | undefined} ip
110
+ */
111
+ export function setTrustedRemoteIp(req, ip) {
112
+ trustedIpMap.set(req, ip || '');
113
+ }
114
+
115
+ /**
116
+ * The framework-trusted remote IP for a request: the out-of-band WeakMap value
117
+ * when a listener stamped it (authoritative, header ignored), else the
118
+ * framework-stamped `x-webjs-remote-ip` header (the node / embedded path).
119
+ * @param {Request} req
120
+ * @returns {string}
121
+ */
122
+ function trustedRemoteIp(req) {
123
+ if (trustedIpMap.has(req)) return trustedIpMap.get(req) || '';
124
+ return req.headers.get(REMOTE_IP_HEADER) || '';
125
+ }
126
+
127
+ /**
128
+ * Carry the trusted remote IP from `src` to a freshly-rebuilt `dst` Request
129
+ * (#756). When the framework re-wraps a request (the page-action body rebuild in
130
+ * `page-action.js`), the out-of-band WeakMap key is a new object, so the trusted
131
+ * IP would be lost and `clientIp(dst)` would fall back to the (spoofable) header
132
+ * the rebuild copied over. Propagating the trusted value via the WeakMap keeps
133
+ * `dst` authoritative on BOTH runtimes (it reads `trustedRemoteIp(src)`, which is
134
+ * the WeakMap value on Bun and the framework-stamped header on Node). The caller
135
+ * must ALSO strip the inbound `x-webjs-remote-ip` header from `dst` so a client
136
+ * copy can never win. Idempotent and safe to call on any rebuild.
137
+ * @param {Request} src
138
+ * @param {Request} dst
139
+ */
140
+ export function propagateTrustedRemoteIp(src, dst) {
141
+ setTrustedRemoteIp(dst, trustedRemoteIp(src));
142
+ }
143
+
92
144
  /**
93
145
  * Resolve the client IP for rate-limit bucket keying.
94
146
  *
95
147
  * `trustProxy: false` (default, safe everywhere): read ONLY the
96
- * framework-stamped `x-webjs-remote-ip` header. Under `startServer`
97
- * the framework derives it from the actual TCP socket and strips
98
- * any inbound copy via `toWebRequest`, so clients cannot spoof it.
148
+ * framework-trusted remote IP. Under `startServer` the framework derives it from
149
+ * the actual TCP socket: the node shell stamps the `x-webjs-remote-ip` header
150
+ * (stripping any inbound copy via `toWebRequest`), and the Bun shell stamps it
151
+ * OUT OF BAND via `setTrustedRemoteIp` (a WeakMap, no per-request Request clone,
152
+ * #756) which `trustedRemoteIp` reads in preference to (and to the exclusion of)
153
+ * the header, so a client cannot spoof it either way.
99
154
  * Under `createRequestHandler` (embedded use) the host adapter MUST
100
155
  * call `stampRemoteIp(req, remoteAddress)` first, otherwise the
101
156
  * adapter may pass forged inbound headers straight through and the
@@ -122,11 +177,11 @@ export function clientIp(req, opts = {}) {
122
177
  req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
123
178
  req.headers.get('cf-connecting-ip') ||
124
179
  req.headers.get('x-real-ip') ||
125
- req.headers.get(REMOTE_IP_HEADER) ||
180
+ trustedRemoteIp(req) ||
126
181
  '_anon_'
127
182
  );
128
183
  }
129
- return req.headers.get(REMOTE_IP_HEADER) || '_anon_';
184
+ return trustedRemoteIp(req) || '_anon_';
130
185
  }
131
186
 
132
187
  /**
package/src/router.js CHANGED
@@ -158,7 +158,7 @@ export async function buildRouteTable(appDir) {
158
158
  a.middlewares = chainDirs.map((d) => middlewares.get(d)).filter(Boolean);
159
159
  }
160
160
 
161
- pages.sort((a, b) => dynScore(a) - dynScore(b));
161
+ pages.sort(compareSpecificity);
162
162
  return { pages, apis, notFound, notFounds, metadataRoutes, appDir };
163
163
  }
164
164
 
@@ -191,11 +191,68 @@ function isUrlSegment(seg) {
191
191
  return true;
192
192
  }
193
193
 
194
- /** @param {PageRoute} r */
195
- function dynScore(r) {
196
- if (r.isCatchAll) return 3;
197
- if (r.paramNames.length) return 2;
198
- return 1;
194
+ /**
195
+ * Per-URL-segment specificity kind: 0 = static literal, 1 = dynamic `[x]`,
196
+ * 2 = catch-all `[...x]` / `[[...x]]`. Lower is MORE specific. Checks the
197
+ * catch-all forms before the bare `[` (a `[[...x]]` also starts with `[`).
198
+ * @param {string} seg
199
+ * @returns {0 | 1 | 2}
200
+ */
201
+ function segKind(seg) {
202
+ if (seg.startsWith('[[...') || seg.startsWith('[...')) return 2;
203
+ if (seg.startsWith('[') && seg.endsWith(']')) return 1;
204
+ return 0;
205
+ }
206
+
207
+ /**
208
+ * The ordered URL segments of a route (route groups + private folders removed),
209
+ * which is the basis for positional specificity.
210
+ * @param {string} routeDir
211
+ * @returns {string[]}
212
+ */
213
+ function urlSegmentsOf(routeDir) {
214
+ return (routeDir === '.' ? [] : routeDir.split('/')).filter(isUrlSegment);
215
+ }
216
+
217
+ /**
218
+ * Deterministic route specificity ordering (#750). Replaces the old coarse
219
+ * 3-bucket score (static=1 / dynamic=2 / catch-all=3) whose same-bucket ties
220
+ * resolved by filesystem walk order, so two overlapping dynamic routes
221
+ * (`/[org]/[repo]` vs `/[user]/settings`) could match the WRONG page depending
222
+ * on traversal order. Specificity is fully POSITIONAL, like Next.js's router:
223
+ * compare segment by segment, and the catch-all kind is just the lowest-priority
224
+ * kind AT ITS POSITION, NOT a global "always last" bucket. The contract, most
225
+ * specific first:
226
+ * 1. Compare segment by segment: at the same position a static literal (kind 0)
227
+ * outranks a dynamic `[x]` (kind 1), which outranks a catch-all
228
+ * `[...x]` / `[[...x]]` (kind 2). So `/[user]/settings` beats `/[org]/[repo]`
229
+ * (static tail wins), AND `/docs/[[...slug]]` beats `/[org]/[repo]` (a
230
+ * literal first segment outranks a dynamic one, even though the former ends
231
+ * in a catch-all). A global catch-all-last rule would wrongly shadow the
232
+ * literal-prefixed catch-all behind every all-dynamic route.
233
+ * 2. With an identical kind prefix, the SHORTER (prefix) route ranks first, so
234
+ * an explicit `/docs` beats the optional-catch-all base `/docs/[[...slug]]`
235
+ * for `/docs` (the optional catch-all also matches zero trailing segments,
236
+ * so the shorter exact route is the more specific match). Standard
237
+ * lexicographic order on the kind arrays.
238
+ * 3. A genuine tie (identical kinds + length, e.g. `/[a]/[b]` vs `/[c]/[d]`)
239
+ * resolves by an alphabetical `routeDir` key, NOT walk order, so the match
240
+ * is deterministic across environments.
241
+ *
242
+ * The result is a valid total order (lexicographic on kind arrays with a unique
243
+ * final tiebreak), so `Array.sort` is correct and deterministic regardless of
244
+ * the filesystem traversal order.
245
+ * @param {PageRoute} a
246
+ * @param {PageRoute} b
247
+ * @returns {number}
248
+ */
249
+ export function compareSpecificity(a, b) {
250
+ const sa = urlSegmentsOf(a.routeDir).map(segKind);
251
+ const sb = urlSegmentsOf(b.routeDir).map(segKind);
252
+ const n = Math.min(sa.length, sb.length);
253
+ for (let i = 0; i < n; i++) if (sa[i] !== sb[i]) return sa[i] - sb[i];
254
+ if (sa.length !== sb.length) return sa.length - sb.length; // shorter (prefix) first
255
+ return a.routeDir < b.routeDir ? -1 : a.routeDir > b.routeDir ? 1 : 0;
199
256
  }
200
257
 
201
258
  /**
package/src/ssr.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
3
  import { renderToString, isNotFound, isRedirect, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
4
- import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPreconnectOrigins } from './importmap.js';
4
+ import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js';
5
5
  import { withBasePath } from './base-path.js';
6
6
  import { withAssetHash } from './asset-hash.js';
7
7
  import { jsonForScriptTag } from './script-tag-json.js';
8
- import { transitiveDeps } from './module-graph.js';
8
+ import { transitiveDeps, bareImports } from './module-graph.js';
9
9
  import { seedingEnabled, collectSeeds, buildSeedScript } from './action-seed.js';
10
10
  import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
11
11
  import {
@@ -189,6 +189,26 @@ export async function ssrPage(route, params, url, opts) {
189
189
  opts.serverFiles,
190
190
  opts.elidableComponents,
191
191
  );
192
+ // Vendor modulepreload (#754): flatten the npm CDN waterfall by hinting the
193
+ // vendor URLs the page's SHIPPED modules actually import, fetched in parallel
194
+ // instead of discovered level by level. The walk roots are the BOOT's shipped
195
+ // module set (`moduleUrls`, which already drops inert page/layout modules and
196
+ // substitutes an import-only page with its components) plus the rendered
197
+ // components, so a vendor reached ONLY through a dropped module (a page's
198
+ // SSR-only direct import OR its SSR-only relative helper) is never preloaded
199
+ // (no over-fetch).
200
+ const shippedRoots = moduleUrls.map((u) =>
201
+ resolve(opts.appDir, u.startsWith('/') ? u.slice(1) : u));
202
+ const vendorPreloads = vendorPreloadTargets(
203
+ reachedVendorSpecifiers(
204
+ opts.moduleGraph,
205
+ shippedRoots,
206
+ eagerComponents,
207
+ opts.appDir,
208
+ opts.elidableComponents,
209
+ opts.serverFiles,
210
+ ),
211
+ );
192
212
  // Extract CSP nonce from request headers (if present).
193
213
  const nonce = opts.req ? getNonce(opts.req) : undefined;
194
214
  const wrapOpts = {
@@ -197,6 +217,7 @@ export async function ssrPage(route, params, url, opts) {
197
217
  dev: opts.dev,
198
218
  streaming: suspenseCtx.pending.length > 0,
199
219
  preloads,
220
+ vendorPreloads,
200
221
  lazyComponents,
201
222
  nonce,
202
223
  };
@@ -1232,6 +1253,26 @@ function wrapHead(opts) {
1232
1253
  `${preloadCrossOriginAttr(url)}${integrityAttr(url)}${noncePreload}>`,
1233
1254
  );
1234
1255
  }
1256
+ // Vendor modulepreload (#754): the npm CDN dependencies the page reaches,
1257
+ // hinted up front to flatten the cross-origin waterfall. The href comes
1258
+ // straight from the importmap target (byte-identical, no `fp()` rewrite, so
1259
+ // the browser does not double-fetch) and carries the importmap's `integrity`.
1260
+ // `preloadCrossOriginAttr` adds `crossorigin` for a cross-origin CDN url (a
1261
+ // same-origin pinned `/__webjs/vendor/*` url gets none). Deduped against the
1262
+ // app module/component preloads already emitted above.
1263
+ const emittedPreloadHrefs = new Set([
1264
+ ...opts.moduleUrls.map((u) => fp(u)),
1265
+ ...(opts.preloads || []).map((u) => fp(u)),
1266
+ ]);
1267
+ for (const v of opts.vendorPreloads || []) {
1268
+ if (emittedPreloadHrefs.has(v.href)) continue;
1269
+ emittedPreloadHrefs.add(v.href);
1270
+ const integrity = v.integrity ? ` integrity="${escapeAttr(v.integrity)}"` : '';
1271
+ linkTags.push(
1272
+ `<link rel="modulepreload" href="${escapeAttr(v.href)}"` +
1273
+ `${preloadCrossOriginAttr(v.href)}${integrity}${noncePreload}>`,
1274
+ );
1275
+ }
1235
1276
  if (Array.isArray(m.preload)) {
1236
1277
  for (const p of m.preload) {
1237
1278
  if (!p || !p.href) continue;
@@ -1493,6 +1534,63 @@ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appD
1493
1534
  return result;
1494
1535
  }
1495
1536
 
1537
+ /**
1538
+ * Collect the bare npm vendor specifiers the page's SHIPPED modules import
1539
+ * (#754). The walk ROOTS are the boot's actually-shipped module set: the caller
1540
+ * passes `entryFiles` = the absolute paths of `moduleUrls` (which already drops
1541
+ * inert page/layout modules and substitutes an import-only page with its
1542
+ * components), plus `componentUrls` = the rendered components. From those roots
1543
+ * it walks the transitive app-graph closure (elidable components and the subtree
1544
+ * reachable only through them excluded), and collects each reached file's bare
1545
+ * imports, excluding server files. The specifiers are resolved to `modulepreload`
1546
+ * targets via the vendor importmap; a specifier not in the map (unpinned /
1547
+ * unreached) drops out there.
1548
+ *
1549
+ * Because the roots are the SHIPPED set, a vendor reached ONLY through a module
1550
+ * dropped from the boot, whether a dropped page's SSR-only DIRECT vendor import
1551
+ * or its SSR-only RELATIVE HELPER's vendor, is never collected: the dropped
1552
+ * module is not a root, and nothing that ships imports it (pages/layouts are not
1553
+ * importable). So the canonical SSR-only-dependency pattern (which elision keeps
1554
+ * off the client) is never preloaded (no over-fetch).
1555
+ *
1556
+ * @param {import('./module-graph.js').ModuleGraph | undefined} graph
1557
+ * @param {string[]} entryFiles absolute paths of the SHIPPED page/layout modules (from `moduleUrls`)
1558
+ * @param {string[]} componentUrls rendered eager component URL paths
1559
+ * @param {string} appDir
1560
+ * @param {Set<string>} [elidableComponents]
1561
+ * @param {Set<string>} [serverFiles] the action / server-file index (`'use server'`, incl. no-`.server.` files)
1562
+ * @returns {Set<string>}
1563
+ */
1564
+ function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elidableComponents, serverFiles) {
1565
+ /** @type {Set<string>} */
1566
+ const specs = new Set();
1567
+ if (!graph) return specs;
1568
+ const bare = bareImports(graph);
1569
+ if (!bare.size) return specs;
1570
+ // Roots = the SHIPPED page/layout modules (already inert-dropped + import-only
1571
+ // expanded by the caller) + the rendered components, keyed by the graph's own
1572
+ // absolute paths. Walk their non-elided transitive closure.
1573
+ const allEntries = [...entryFiles];
1574
+ for (const url of componentUrls) {
1575
+ allEntries.push(resolve(appDir, url.startsWith('/') ? url.slice(1) : url));
1576
+ }
1577
+ const files = new Set(allEntries);
1578
+ for (const dep of transitiveDeps(graph, allEntries, appDir, elidableComponents)) files.add(dep);
1579
+ for (const file of files) {
1580
+ // A server file is never served to the browser (its source is an RPC /
1581
+ // throw-at-load stub), so a vendor it imports never ships and must NOT be
1582
+ // preloaded. `transitiveDeps` stops AT a server-file boundary but still
1583
+ // returns the boundary file itself, so filter it: the `.server.*` suffix
1584
+ // AND the action index (a `'use server'` file without the suffix), matching
1585
+ // `deduplicatedPreloads`' `byIndex` filter.
1586
+ if (/\.server\.m?[jt]s$/.test(file)) continue;
1587
+ if (serverFiles && serverFiles.has && serverFiles.has(file)) continue;
1588
+ const fileBare = bare.get(file);
1589
+ if (fileBare) for (const spec of fileBare) specs.add(spec);
1590
+ }
1591
+ return specs;
1592
+ }
1593
+
1496
1594
  /**
1497
1595
  * Build a streaming Response. Degrades to a single-flush response when
1498
1596
  * there are no pending Suspense boundaries.
package/src/testing.js CHANGED
@@ -295,9 +295,13 @@ export async function invokeActionForTest(app, serverFilePath, fnName, args = []
295
295
 
296
296
  if (!res.ok && throwOnError) {
297
297
  const msg = (parsed && parsed.error) || `webjs action ${fnName} -> ${res.status}`;
298
- const err = /** @type {Error & { status?: number, response?: Response }} */ (new Error(msg));
298
+ const err = /** @type {Error & { status?: number, response?: Response, digest?: string }} */ (new Error(msg));
299
299
  err.status = res.status;
300
300
  err.response = res;
301
+ // Prod sanitization (#749) returns a generic message + a correlation digest;
302
+ // surface the digest on the thrown error so a test (or a caller) can map it
303
+ // to the server log line, mirroring the generated client stub.
304
+ if (parsed && parsed.digest) err.digest = parsed.digest;
301
305
  throw err;
302
306
  }
303
307
  return parsed;