@webjsdev/server 0.8.48 → 0.8.49

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.48",
3
+ "version": "0.8.49",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/js-scan.js CHANGED
@@ -432,7 +432,13 @@ export function maskComments(src) {
432
432
  */
433
433
  export function extractWebComponentClassBodies(content) {
434
434
  const bodies = [];
435
- const re = /class\s+\w+\s+extends\s+WebComponent/g;
435
+ // The optional `(?:\s*<[^{}();]*?>)?` skips a TypeScript generic parameter
436
+ // list on the class name (`class Grid<T> extends WebComponent`), so a generic
437
+ // component is not missed (which would let elision wrongly strip an
438
+ // interactive component, the unsafe direction). The exclusion class keeps the
439
+ // generic from swallowing the class body / a call, and stops it staying
440
+ // within the type-parameter list (#753, found differentially).
441
+ const re = /class\s+\w+(?:\s*<[^{}();]*?>)?\s+extends\s+WebComponent/g;
436
442
  let m;
437
443
  while ((m = re.exec(content)) !== null) {
438
444
  let i = m.index + m[0].length;
@@ -238,6 +238,16 @@ async function bunUpgrade(req, srv, ctx) {
238
238
 
239
239
  const wrapper = new BunWsAdapter();
240
240
  const handlerReq = upgradeRequest(req, url);
241
+ // Stamp the framework-trusted socket IP on the handler request so a `WS`
242
+ // handler's `clientIp(req)` returns the real peer, not a spoofed inbound
243
+ // `x-webjs-remote-ip` (#778). `srv.requestIP` must be queried with Bun's
244
+ // ORIGINAL `req` (the rebuilt `handlerReq` is unknown to it); the WeakMap
245
+ // stamp then makes `handlerReq` authoritative. Mirrors `stampRemoteIp` on the
246
+ // fetch path (which strips + stamps `req` directly). `upgradeRequest` already
247
+ // dropped any inbound copy of the header.
248
+ let ip;
249
+ try { ip = srv.requestIP(req)?.address; } catch {}
250
+ setTrustedRemoteIp(handlerReq, ip || '');
241
251
  const ok = srv.upgrade(req, {
242
252
  data: { wrapper, mod, req: handlerReq, params: match.params, pathname: url.pathname },
243
253
  });
@@ -254,7 +264,14 @@ async function bunUpgrade(req, srv, ctx) {
254
264
  */
255
265
  function upgradeRequest(req, url) {
256
266
  const headers = new Headers();
257
- req.headers.forEach((v, k) => { if (!k.startsWith(':')) headers.set(k, v); });
267
+ // Skip a client-supplied `x-webjs-remote-ip`: it is a framework-internal trust
268
+ // header the caller re-stamps via the authoritative WeakMap (#778). Copying it
269
+ // would let a client spoof `clientIp` inside the WS handler on the fallback
270
+ // header read.
271
+ req.headers.forEach((v, k) => {
272
+ if (k.startsWith(':') || k === 'x-webjs-remote-ip') return;
273
+ headers.set(k, v);
274
+ });
258
275
  return new Request(url, { method: 'GET', headers });
259
276
  }
260
277
 
@@ -14,7 +14,7 @@
14
14
  import { readFile, readdir, stat } from 'node:fs/promises';
15
15
  import { existsSync, readFileSync } from 'node:fs';
16
16
  import { join, resolve, dirname, extname, sep } from 'node:path';
17
- import { redactStringsAndTemplates } from './js-scan.js';
17
+ import { redactStringsAndTemplates, redactToPlaceholders } from './js-scan.js';
18
18
 
19
19
  /**
20
20
  * Per-appDir cache of the parsed `package.json "imports"` subpath-alias map
@@ -78,7 +78,7 @@ export function expandImportAlias(spec, appDir) {
78
78
  }
79
79
 
80
80
  /** @type {RegExp} match static `import … from '…'` and `import '…'` */
81
- const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
81
+ const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/gd;
82
82
 
83
83
  /**
84
84
  * @type {RegExp} match `export … from '…'` re-exports.
@@ -94,12 +94,16 @@ const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
94
94
  *
95
95
  * Barrel files are common (`lib/index.ts` re-exports its siblings),
96
96
  * and the graph must follow these edges or downstream consumers of
97
- * the barrel see authorisation 404s on the underlying files. The
98
- * gap class excludes quotes and `;` (so the lazy match cannot cross
99
- * a statement boundary) but DOES allow newlines, so multi-line
100
- * brace lists are caught.
97
+ * the barrel see authorisation 404s on the underlying files. The gap
98
+ * class excludes quotes, `;`, AND the backtick but DOES allow newlines,
99
+ * so multi-line brace lists are caught. The primary defence against a
100
+ * stray `export` word inside a literal swallowing a real later edge is
101
+ * that `parseFile` scans over the FULLY-BLANKED mask (every comment /
102
+ * string / template / regex body blanked), so no keyword can anchor
103
+ * inside a literal at all (#753); the exclusions here are belt-and-
104
+ * suspenders on top of that.
101
105
  */
102
- const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
106
+ const EXPORT_FROM_RE = /\bexport\b[^'";`]+?\sfrom\s+['"]([^'"]+)['"]/gd;
103
107
 
104
108
  /**
105
109
  * @type {RegExp} match a dynamic `import('…')` with a STRING-LITERAL specifier
@@ -118,7 +122,7 @@ const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
118
122
  * `import('./pages/' + name)` the char after the closing quote is `+`, neither
119
123
  * `,` nor `)`, so it does not match.
120
124
  */
121
- const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*[,)]/g;
125
+ const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*[,)]/gd;
122
126
 
123
127
  /**
124
128
  * @typedef {Map<string, Set<string>>} ModuleGraph
@@ -459,16 +463,26 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
459
463
  // graph edge to whatever path that string names. Since this caller only
460
464
  // checks keyword-in-code-position, blank every literal body so a
461
465
  // code-example `import` string never becomes an edge.
466
+ // Scan for edges over the FULLY-BLANKED mask: every comment / string /
467
+ // template / regex body is blanked to spaces (delimiters kept), `${...}`
468
+ // holes stay as code. Reason (#753, found differentially): a stray `import` /
469
+ // `export` word inside a comment, template, OR regex literal must not anchor a
470
+ // lazy regex match (e.g. `EXPORT_FROM_RE`'s `[^'";`]+?`) that spans across the
471
+ // literal's closing delimiter and consumes the next real `from '<spec>'`,
472
+ // whose keyword then sits in the blanked literal and is dropped, silently
473
+ // losing the real edge (the gate then 404s a legitimately imported module).
474
+ // Scanning over the blanked mask means NO keyword can anchor inside any
475
+ // literal or comment at all, so the whole class of delimiter blind spots
476
+ // (comment, string, template, regex) is closed at once, and there is nothing
477
+ // left for a per-match position guard to reject. The specifier itself is a
478
+ // string (blanked here), so it is read back from raw `src` via the match's
479
+ // group indices (the `d` flag).
462
480
  const masked = redactStringsAndTemplates(src, true);
463
481
  const deps = new Set();
464
482
  /** @type {Set<string>} bare npm vendor specifiers imported by this file (#754) */
465
483
  const bareDeps = new Set();
466
484
  for (const re of [IMPORT_RE, EXPORT_FROM_RE]) {
467
- for (const m of src.matchAll(re)) {
468
- // m.index is the keyword start (`\bimport` / `\bexport`). If that
469
- // position is blanked in the mask, the match lives inside a literal
470
- // and is not a real import edge.
471
- if (masked[m.index] === ' ') continue;
485
+ for (const m of masked.matchAll(re)) {
472
486
  // A statement-leading `import type { X }` / `import type * as X` /
473
487
  // `import type Foo` (default type import) / `export type { X }` /
474
488
  // `export type *` is fully erased by the TS stripper, so it never becomes
@@ -481,22 +495,17 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
481
495
  // followed by `from` is NOT type-only.
482
496
  // - a MIXED `import { type A, b }` does NOT lead with `type`, so it is
483
497
  // kept (its `b` binding is a real runtime edge).
498
+ // (m[0] here is over the mask, which keeps the binding syntax verbatim.)
484
499
  // #805
485
500
  const lead = m[0];
486
501
  const typeOnly =
487
502
  /^(?:import|export)\s+type\s*[{*]/.test(lead) ||
488
503
  (/^import\s+type\s+[A-Za-z_$]/.test(lead) && !/^import\s+type\s+from\b/.test(lead));
489
504
  if (typeOnly) continue;
490
- const spec = m[1];
491
- // Guard a match whose `from '<spec>'` tail reaches INTO a blanked literal:
492
- // EXPORT_FROM_RE's lazy `[^'";]+?` can span a template body to a `from`
493
- // written inside example code (`export const t = html\`...import x from
494
- // 'left-pad'\``), so the KEYWORD is real but the SPECIFIER is not. A real
495
- // string keeps its delimiters in the mask (only the body blanks), while a
496
- // template body blanks whole, so a blanked opening quote means the
497
- // specifier is inside a literal and the match is spurious.
498
- const quoteAt = m.index + m[0].length - spec.length - 2;
499
- if (masked[quoteAt] === ' ') continue;
505
+ // The specifier is read from RAW src at the match's captured-group indices
506
+ // (the group is the blanked string body in the mask; its position is the
507
+ // real specifier in src).
508
+ const spec = src.slice(m.indices[1][0], m.indices[1][1]);
500
509
  // Only resolve relative imports + `#`-style subpath aliases (#555)
501
510
  // within the project. A bare npm specifier (dayjs) has no alias match
502
511
  // and is skipped FROM THE GRAPH, but recorded as a vendor edge so SSR
@@ -513,12 +522,30 @@ async function parseFile(file, appDir, graph, seen, dynamic, bare) {
513
522
  }
514
523
  // Dynamic `import('…')` with a string-literal specifier (#751): a separate
515
524
  // edge class so the gate admits the lazily-loaded module (no 404) without
516
- // preloading it. Same redaction-mask + alias rules as the static scan. A
517
- // target already statically imported is not duplicated as a dynamic edge.
525
+ // preloading it. A target already statically imported is not duplicated as a
526
+ // dynamic edge.
527
+ //
528
+ // The static scan above runs over the FULLY-BLANKED mask, which also blanks
529
+ // the code inside `${...}` template holes. That drops a dynamic import written
530
+ // directly inside a hole (`html\`${import('./x.ts')}\``), which the AST counts
531
+ // as an edge but the mask scan did not, so the gate 404s a legitimately-loaded
532
+ // module (#918, the admit-fewer direction of the #753 family). So the dynamic
533
+ // scan instead runs over the PLACEHOLDER redaction (#634), which keeps `${...}`
534
+ // hole code readable (so a hole-position `import(...)` IS seen) while still
535
+ // tokenizing string / template-TEXT bodies to `__STR_<idx>__` and blanking
536
+ // comment / regex bodies (so a dynamic import written as literal TEXT is NOT
537
+ // an edge). The #753 swallow class stays closed because `DYNAMIC_IMPORT_RE` is
538
+ // tightly anchored on `import(`, never a lazy cross-delimiter span. The
539
+ // specifier surfaces as a `__STR_<idx>__` token, recovered from `literals`; a
540
+ // computed `import(expr)` still falls out (its char after the quote is not
541
+ // `,`/`)`), preserving the documented limitation.
542
+ const { redacted: holeAware, literals } = redactToPlaceholders(src);
518
543
  const dynDeps = new Set();
519
- for (const m of src.matchAll(DYNAMIC_IMPORT_RE)) {
520
- if (masked[m.index] === ' ') continue;
521
- const spec = m[1];
544
+ for (const m of holeAware.matchAll(DYNAMIC_IMPORT_RE)) {
545
+ const token = m[1];
546
+ const pm = /^__STR_(\d+)__$/.exec(token);
547
+ const spec = pm ? literals[Number(pm[1])] : token;
548
+ if (spec == null) continue;
522
549
  if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
523
550
  const resolved = resolveImport(spec, file, appDir);
524
551
  if (resolved && !deps.has(resolved)) dynDeps.add(resolved);
package/src/ssr.js CHANGED
@@ -194,25 +194,30 @@ export async function ssrPage(route, params, url, opts) {
194
194
  // enter the viewport.
195
195
  const { eager: eagerComponents, lazy: lazyComponents } =
196
196
  componentPreloads(suspenseCtx.usedComponents, opts.appDir, opts.elidableComponents);
197
+ // The walk roots for BOTH preload passes are the BOOT's actually-shipped
198
+ // module set (`moduleUrls`, which already drops inert page/layout modules and
199
+ // substitutes an import-only page with its components), NOT the raw route
200
+ // entries `[route.file, ...route.layouts]`. Rooting at the raw entries would
201
+ // walk a dropped page's SSR-only subtree (a direct import OR a relative
202
+ // helper) and hint a `modulepreload` for a module nothing that ships imports,
203
+ // an over-fetch (#780, the app-module analog of the #754 vendor over-fetch).
204
+ const shippedRoots = moduleUrls.map((u) =>
205
+ resolve(opts.appDir, u.startsWith('/') ? u.slice(1) : u));
197
206
  const preloads = deduplicatedPreloads(
198
207
  eagerComponents,
199
208
  moduleUrls,
200
209
  opts.moduleGraph,
201
- [route.file, ...route.layouts],
210
+ shippedRoots,
202
211
  opts.appDir,
203
212
  opts.serverFiles,
204
213
  opts.elidableComponents,
205
214
  );
206
215
  // Vendor modulepreload (#754): flatten the npm CDN waterfall by hinting the
207
216
  // vendor URLs the page's SHIPPED modules actually import, fetched in parallel
208
- // instead of discovered level by level. The walk roots are the BOOT's shipped
209
- // module set (`moduleUrls`, which already drops inert page/layout modules and
210
- // substitutes an import-only page with its components) plus the rendered
211
- // components, so a vendor reached ONLY through a dropped module (a page's
212
- // SSR-only direct import OR its SSR-only relative helper) is never preloaded
213
- // (no over-fetch).
214
- const shippedRoots = moduleUrls.map((u) =>
215
- resolve(opts.appDir, u.startsWith('/') ? u.slice(1) : u));
217
+ // instead of discovered level by level. Same shipped-module roots as the
218
+ // app-module walk above, so a vendor reached ONLY through a dropped module
219
+ // (a page's SSR-only direct import OR its SSR-only relative helper) is never
220
+ // preloaded (no over-fetch).
216
221
  const vendorPreloads = vendorPreloadTargets(
217
222
  reachedVendorSpecifiers(
218
223
  opts.moduleGraph,
@@ -1581,10 +1586,24 @@ function componentPreloads(usedTags, appDir, elidable) {
1581
1586
  * Merge component preloads with transitive dependencies from the module
1582
1587
  * graph, then deduplicate against the already-imported module URLs.
1583
1588
  *
1589
+ * The walk ROOTS (`entryFiles`) are the boot's actually-SHIPPED page/layout
1590
+ * module set (the caller passes the absolute paths of `moduleUrls`, which
1591
+ * already drops an inert page/layout and substitutes an import-only page with
1592
+ * its components), NOT the raw `[route.file, ...route.layouts]` route entries.
1593
+ * This matches `reachedVendorSpecifiers`' roots so the two walks stay
1594
+ * consistent. Rooting at the shipped set means a module reached ONLY through a
1595
+ * dropped page/layout (its SSR-only direct app import OR its SSR-only relative
1596
+ * helper) is never a walk root's dep, so it gets no `modulepreload` hint (no
1597
+ * over-fetch, #780). A module that also ships some other way (a component shared
1598
+ * with a live route, or reached via an import-only page's substituted
1599
+ * components) is still reached through a real shipped root, so its hint stays
1600
+ * (no under-fetch). `seen = new Set(moduleUrls)` already excludes the shipped
1601
+ * modules' own URLs; the shipped-roots change closes the TRANSITIVE gap.
1602
+ *
1584
1603
  * @param {string[]} componentUrls direct component module URLs
1585
1604
  * @param {string[]} moduleUrls boot script imports (page + layouts)
1586
1605
  * @param {import('./module-graph.js').ModuleGraph | undefined} graph
1587
- * @param {string[]} entryFiles absolute paths of page + layout files
1606
+ * @param {string[]} entryFiles absolute paths of the SHIPPED page/layout modules (from `moduleUrls`)
1588
1607
  * @param {string} appDir
1589
1608
  * @param {Set<string>} [elidableComponents] absolute paths to skip in the walk
1590
1609
  * @returns {string[]}
package/src/websocket.js CHANGED
@@ -4,6 +4,7 @@ import { urlFromRequest } from './forwarded.js';
4
4
  import { registerClient } from './broadcast.js';
5
5
  import { loadWsModule } from './listener-core.js';
6
6
  import { makeThenable } from './thenable-params.js';
7
+ import { setTrustedRemoteIp } from './rate-limit.js';
7
8
 
8
9
  /**
9
10
  * WebSocket support.
@@ -106,7 +107,17 @@ function buildRequestFromUpgrade(req, url) {
106
107
  const headers = {};
107
108
  for (const [k, v] of Object.entries(req.headers)) {
108
109
  if (k.startsWith(':')) continue;
110
+ // Never copy an inbound `x-webjs-remote-ip`: it is a framework-internal
111
+ // trust header a client could spoof on the upgrade. The real socket IP is
112
+ // stamped below via the authoritative WeakMap (#778, the WS-seam analog of
113
+ // the #773 fetch-path IP-trust fix).
114
+ if (k === 'x-webjs-remote-ip') continue;
109
115
  headers[k] = Array.isArray(v) ? v.join(',') : String(v ?? '');
110
116
  }
111
- return new Request(url, { method: 'GET', headers });
117
+ const webReq = new Request(url, { method: 'GET', headers });
118
+ // Stamp the framework-trusted socket IP so `clientIp(req)` inside the `WS`
119
+ // handler returns the real peer address (the WeakMap is authoritative and
120
+ // ignores the inbound header), matching the fetch path's `toWebRequest`.
121
+ setTrustedRemoteIp(webReq, req.socket?.remoteAddress || '');
122
+ return webReq;
112
123
  }