@webjsdev/server 0.8.48 → 0.8.50
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 +1 -1
- package/src/check.js +165 -1
- package/src/js-scan.js +7 -1
- package/src/listener-bun.js +18 -1
- package/src/module-graph.js +55 -28
- package/src/ssr.js +30 -10
- package/src/vendor.js +34 -5
- package/src/websocket.js +12 -1
package/package.json
CHANGED
package/src/check.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
matchClosingBrace,
|
|
8
8
|
parsePropEntries,
|
|
9
9
|
} from './js-scan.js';
|
|
10
|
-
import { buildModuleGraph, transitiveDeps } from './module-graph.js';
|
|
10
|
+
import { buildModuleGraph, transitiveDeps, resolveImport } from './module-graph.js';
|
|
11
11
|
import { scanComponents } from './component-scanner.js';
|
|
12
12
|
import { buildRouteTable } from './router.js';
|
|
13
13
|
import { analyzeElision } from './component-elision.js';
|
|
@@ -144,6 +144,11 @@ export const RULES = [
|
|
|
144
144
|
description:
|
|
145
145
|
'Flags a template interpolation (`${...}`) placed as a child of a `<style>` or `<script>` element inside a COMPONENT `html` template. Raw-text elements are an SSR/client asymmetry trap: the server renderer emits the interpolated content, but the client renderer drops it (a raw-text hole is a `noop`, since the compile cache is keyed on the static strings), so the element renders correctly on the server and then wipes to empty on hydration. Scoped to components (files with a `WebComponent` class), which hydrate; pages and layouts render server-only and never hydrate, so a page interpolating a `css` result into a `<style>` is a legitimate pattern and is not flagged. In a component, author scoped CSS with `static styles` (shadow DOM) or a `css` template. Found dogfooding a tic-tac-toe app (#845): a `<style>${STYLE}</style>` painted at SSR then vanished on hydrate.',
|
|
146
146
|
},
|
|
147
|
+
{
|
|
148
|
+
name: 'no-missing-local-import',
|
|
149
|
+
description:
|
|
150
|
+
'Flags a NAMED value import of a symbol that a resolvable app-internal module does not export (`import { todos } from \'#db/schema.server.ts\'` when the schema no longer exports `todos`). The binding is `undefined` at runtime and crashes on first use, yet the rest of `webjs check` (elision-based, it does not compile types) misses it, so a schema swap that orphans a gallery module can pass `check` while `typecheck` is red. This is the runtime-crash class `check` exists to catch, filling that gap so an agent running only `check` cannot ship a broken import. Deliberately conservative to never false-positive on a valid app: it only inspects app-internal specifiers (a relative `./` path or a `#` alias) that resolve to a file in the app, only NAMED value imports (a `type` import, a default import, a namespace import, and any bare / `node:` / npm-package specifier are skipped), and it treats a module as UNKNOWABLE (never flags imports from it) when its exports are not fully enumerable: a `export * from ...` star re-export, a destructuring `export const { a } = ...`, or a multi-declarator `export const a = .., b = ..`. It reads names from `export function/class/const/let/var/type/interface/enum` and from `export { a, b as c }` / `export { x } from ...` clauses (the alias after `as` is the exported name). A re-export barrel therefore resolves correctly, and a `\'use server\'` action file exports its function names normally. Found dogfooding a tic-tac-toe app: dropping the example `todos` table left a gallery module importing it, green under `check` but red under `typecheck`.',
|
|
151
|
+
},
|
|
147
152
|
];
|
|
148
153
|
|
|
149
154
|
/** Set of all known rule names for fast lookup. */
|
|
@@ -379,6 +384,112 @@ function findBrowserMemberUses(code) {
|
|
|
379
384
|
return out;
|
|
380
385
|
}
|
|
381
386
|
|
|
387
|
+
/**
|
|
388
|
+
* True if any `export const/let/var` declares more than one binding
|
|
389
|
+
* (`export const a = 1, b = 2`), which the single-name collector would
|
|
390
|
+
* under-count. Depth-aware: a comma inside an initializer (`f(a, b)`, `[a, b]`,
|
|
391
|
+
* `{ a, b }`) does not count, only a top-level comma before the statement end.
|
|
392
|
+
* Bailing the whole module on this errs toward a false negative (safe), never a
|
|
393
|
+
* false positive. Runs on the string/comment-redacted `scan`.
|
|
394
|
+
* @param {string} scan
|
|
395
|
+
* @returns {boolean}
|
|
396
|
+
*/
|
|
397
|
+
function hasMultiDeclaratorExport(scan) {
|
|
398
|
+
const re = /\bexport\s+(?:const|let|var)\b/g;
|
|
399
|
+
let m;
|
|
400
|
+
while ((m = re.exec(scan))) {
|
|
401
|
+
let depth = 0; // () [] {} nesting
|
|
402
|
+
let angle = 0; // <> generics, tracked ONLY inside a type annotation
|
|
403
|
+
let seenEq = false; // passed this declarator's `=` (now in the initializer)
|
|
404
|
+
let inType = false; // inside a `: Type` annotation (before the `=`)
|
|
405
|
+
for (let i = m.index + m[0].length; i < scan.length; i++) {
|
|
406
|
+
const ch = scan[i];
|
|
407
|
+
if (ch === '(' || ch === '[' || ch === '{') { depth++; continue; }
|
|
408
|
+
if (ch === ')' || ch === ']' || ch === '}') { if (depth === 0) break; depth--; continue; }
|
|
409
|
+
if (depth !== 0) continue;
|
|
410
|
+
if (ch === ';') break;
|
|
411
|
+
// A plain `=` ends the type annotation and enters the initializer. `<=`,
|
|
412
|
+
// `>=`, `=>`, `==` are harmless here (seenEq only latches true).
|
|
413
|
+
if (ch === '=') { seenEq = true; inType = false; angle = 0; continue; }
|
|
414
|
+
// A `:` BEFORE the `=` opens the type annotation. A `:` after `=` (a
|
|
415
|
+
// ternary in the initializer) is NOT a type and must not suppress a real
|
|
416
|
+
// declarator comma.
|
|
417
|
+
if (ch === ':' && !seenEq) { inType = true; continue; }
|
|
418
|
+
// Inside a type annotation `<` / `>` are generic delimiters (a comparison
|
|
419
|
+
// only appears in the initializer, after `=`). Track their depth so a
|
|
420
|
+
// generic's comma (`Map<string, number>`) is skipped while a real
|
|
421
|
+
// declarator comma (`a: number, b: number`) still counts. A `>` that is
|
|
422
|
+
// part of a `=>` function-type arrow is not a generic close.
|
|
423
|
+
if (inType && ch === '<') { angle++; continue; }
|
|
424
|
+
if (inType && ch === '>' && scan[i - 1] !== '=') { if (angle > 0) angle--; continue; }
|
|
425
|
+
// A top-level comma starts a second declarator UNLESS it is a generic's
|
|
426
|
+
// comma inside a type annotation.
|
|
427
|
+
if (ch === ',' && !(inType && angle > 0)) return true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Enumerate a module's provable named exports from its redacted `scan`, for the
|
|
435
|
+
* `no-missing-local-import` rule. Returns `null` when the export list is NOT
|
|
436
|
+
* fully enumerable (a `export *` star re-export, a destructuring export, or a
|
|
437
|
+
* multi-declarator `export const a = .., b = ..`), which tells the rule to treat
|
|
438
|
+
* the module as unknowable and flag nothing from it. Otherwise returns the set
|
|
439
|
+
* of exported names (the alias after `as` for a clause, and type/interface/enum
|
|
440
|
+
* names too, so a value import of a type is not falsely flagged; tsc owns that).
|
|
441
|
+
* @param {string} scan string/template/comment-redacted source
|
|
442
|
+
* @returns {Set<string>|null}
|
|
443
|
+
*/
|
|
444
|
+
function enumerableExports(scan) {
|
|
445
|
+
// Unknowable export shapes: bail so the rule never false-positives.
|
|
446
|
+
if (/\bexport\s*\*/.test(scan)) return null; // export * from
|
|
447
|
+
if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) return null; // destructuring export
|
|
448
|
+
if (hasMultiDeclaratorExport(scan)) return null; // export const a=1, b=2
|
|
449
|
+
const names = new Set();
|
|
450
|
+
let m;
|
|
451
|
+
const collect = (re) => { while ((m = re.exec(scan))) names.add(m[1]); };
|
|
452
|
+
collect(/\bexport\s+(?:async\s+)?function\b\s*\*?\s*([A-Za-z_$][\w$]*)/g);
|
|
453
|
+
collect(/\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g);
|
|
454
|
+
collect(/\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g);
|
|
455
|
+
collect(/\bexport\s+(?:type|interface|enum|namespace)\s+([A-Za-z_$][\w$]*)/g);
|
|
456
|
+
// A named-default import (`import { default as Foo }`) is legal against an
|
|
457
|
+
// `export default` module, so record the `default` name when present.
|
|
458
|
+
if (/\bexport\s+default\b/.test(scan)) names.add('default');
|
|
459
|
+
// `export { a, b as c }` / `export type { ... }` / `export { x } from '...'`:
|
|
460
|
+
// the EXPORTED name is the alias after `as`, else the bare name.
|
|
461
|
+
const reClause = /\bexport\s+(?:type\s+)?\{([^}]*)\}/g;
|
|
462
|
+
while ((m = reClause.exec(scan))) {
|
|
463
|
+
for (let part of m[1].split(',')) {
|
|
464
|
+
part = part.trim().replace(/^type\s+/, '');
|
|
465
|
+
if (!part) continue;
|
|
466
|
+
const name = part.split(/\s+as\s+/).pop().trim();
|
|
467
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return names;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The NAMED VALUE names a single `import { ... } from` clause pulls in, for the
|
|
475
|
+
* `no-missing-local-import` rule. Returns `null` for an `import type { ... }`
|
|
476
|
+
* (all type, skip), and `[]` for a default / namespace / side-effect import
|
|
477
|
+
* (nothing named to verify). A per-specifier inline `type` marker is dropped,
|
|
478
|
+
* and for `a as b` the IMPORTED name is `a` (what the target must export).
|
|
479
|
+
* @param {string} clause the text between `import` and `from`
|
|
480
|
+
* @returns {string[]|null}
|
|
481
|
+
*/
|
|
482
|
+
function importedValueNames(clause) {
|
|
483
|
+
if (/^\s*type\b/.test(clause)) return null; // import type { ... }
|
|
484
|
+
const brace = clause.match(/\{([^}]*)\}/);
|
|
485
|
+
if (!brace) return []; // default / namespace only
|
|
486
|
+
return brace[1].split(',')
|
|
487
|
+
.map((s) => s.trim())
|
|
488
|
+
.filter((s) => s && !/^type\s/.test(s))
|
|
489
|
+
.map((s) => s.split(/\s+as\s+/)[0].trim())
|
|
490
|
+
.filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
|
|
491
|
+
}
|
|
492
|
+
|
|
382
493
|
/**
|
|
383
494
|
* Scan a WebJs app directory and report convention violations.
|
|
384
495
|
*
|
|
@@ -1125,6 +1236,59 @@ export async function checkConventions(appDir) {
|
|
|
1125
1236
|
// directly, so it is not elided and ships the server import.
|
|
1126
1237
|
await checkServerImportInBrowserModule(appDir, violations);
|
|
1127
1238
|
|
|
1239
|
+
// --- Rule: no-missing-local-import ---
|
|
1240
|
+
// A named value import of a symbol an app-internal module does not export is a
|
|
1241
|
+
// runtime crash (the binding is undefined) that the elision-based checks miss,
|
|
1242
|
+
// so `check` can stay green while `typecheck` is red (a dropped schema table
|
|
1243
|
+
// orphaning a gallery module is the motivating dogfood case). Conservative by
|
|
1244
|
+
// construction: only app-internal specifiers resolving to a known app file,
|
|
1245
|
+
// only named value imports, and only when the target's exports are fully
|
|
1246
|
+
// enumerable (see enumerableExports / importedValueNames).
|
|
1247
|
+
{
|
|
1248
|
+
// Fully-blanked view (blankStrings=true): string AND template AND comment
|
|
1249
|
+
// bodies blank to spaces, position-preserving. The default `scan` keeps
|
|
1250
|
+
// plain-string bodies VERBATIM (so callers can read `register('tag')`),
|
|
1251
|
+
// which would let an `import`/`export` inside a string be matched, so use
|
|
1252
|
+
// the fully-blanked view for both the export map and the import scan. The
|
|
1253
|
+
// real specifier is read back from `content` at the same (length-preserved)
|
|
1254
|
+
// offset.
|
|
1255
|
+
const maskedByAbs = new Map();
|
|
1256
|
+
for (const f of files) maskedByAbs.set(f.abs, redactStringsAndTemplates(f.content, true));
|
|
1257
|
+
const exportsByAbs = new Map();
|
|
1258
|
+
for (const f of files) exportsByAbs.set(f.abs, enumerableExports(maskedByAbs.get(f.abs)));
|
|
1259
|
+
// `import\s+` excludes `import.meta` and a dynamic `import(`. The clause is
|
|
1260
|
+
// `[^'";]*?` so it cannot swallow a side-effect `import '...'` (its quote) or
|
|
1261
|
+
// bridge across a `;` into the next statement's `from`.
|
|
1262
|
+
const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
|
|
1263
|
+
for (const { abs, rel, content } of files) {
|
|
1264
|
+
const masked = maskedByAbs.get(abs);
|
|
1265
|
+
reImport.lastIndex = 0;
|
|
1266
|
+
let m;
|
|
1267
|
+
while ((m = reImport.exec(masked))) {
|
|
1268
|
+
const quote = m[2];
|
|
1269
|
+
const specStart = reImport.lastIndex; // just past the opening quote
|
|
1270
|
+
const specEnd = content.indexOf(quote, specStart);
|
|
1271
|
+
if (specEnd < 0) continue;
|
|
1272
|
+
const spec = content.slice(specStart, specEnd);
|
|
1273
|
+
if (!/^(?:\.|#)/.test(spec)) continue; // app-internal only (relative or #alias)
|
|
1274
|
+
const names = importedValueNames(m[1]);
|
|
1275
|
+
if (!names || names.length === 0) continue;
|
|
1276
|
+
const target = resolveImport(spec, abs, appDir);
|
|
1277
|
+
const exp = exportsByAbs.get(target);
|
|
1278
|
+
if (exp == null) continue; // not an app file, or exports not enumerable
|
|
1279
|
+
for (const name of names) {
|
|
1280
|
+
if (exp.has(name)) continue;
|
|
1281
|
+
violations.push({
|
|
1282
|
+
rule: 'no-missing-local-import',
|
|
1283
|
+
file: rel,
|
|
1284
|
+
message: `Imports \`${name}\` from \`${spec}\`, but that module does not export \`${name}\`. The binding is undefined at runtime and crashes on first use (often a renamed or removed export, e.g. a dropped schema table).`,
|
|
1285
|
+
fix: `Add \`${name}\` to \`${spec}\`, correct the imported name, or remove the import.`,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1128
1292
|
return violations;
|
|
1129
1293
|
}
|
|
1130
1294
|
|
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
|
-
|
|
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;
|
package/src/listener-bun.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/module-graph.js
CHANGED
|
@@ -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+)?['"]([^'"]+)['"]/
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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[^'"
|
|
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*[,)]/
|
|
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
|
|
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
|
-
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
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.
|
|
517
|
-
//
|
|
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
|
|
520
|
-
|
|
521
|
-
const
|
|
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
|
-
|
|
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.
|
|
209
|
-
// module
|
|
210
|
-
//
|
|
211
|
-
//
|
|
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,
|
|
@@ -1523,6 +1528,7 @@ function wrapHead(opts) {
|
|
|
1523
1528
|
<html lang="en">
|
|
1524
1529
|
<head>
|
|
1525
1530
|
<meta charset="utf-8">
|
|
1531
|
+
<style${n}>@layer webjs-host{:where([data-wj-host]){display:block}:where([data-wj-host][hidden]:not([hidden='until-found'])){display:none}}</style>
|
|
1526
1532
|
${opts.nonce ? `<meta name="csp-nonce" content="${escapeAttr(opts.nonce)}">` : ''}
|
|
1527
1533
|
${metaTags.join('\n')}
|
|
1528
1534
|
<title>${escapeHtml(title)}</title>
|
|
@@ -1581,10 +1587,24 @@ function componentPreloads(usedTags, appDir, elidable) {
|
|
|
1581
1587
|
* Merge component preloads with transitive dependencies from the module
|
|
1582
1588
|
* graph, then deduplicate against the already-imported module URLs.
|
|
1583
1589
|
*
|
|
1590
|
+
* The walk ROOTS (`entryFiles`) are the boot's actually-SHIPPED page/layout
|
|
1591
|
+
* module set (the caller passes the absolute paths of `moduleUrls`, which
|
|
1592
|
+
* already drops an inert page/layout and substitutes an import-only page with
|
|
1593
|
+
* its components), NOT the raw `[route.file, ...route.layouts]` route entries.
|
|
1594
|
+
* This matches `reachedVendorSpecifiers`' roots so the two walks stay
|
|
1595
|
+
* consistent. Rooting at the shipped set means a module reached ONLY through a
|
|
1596
|
+
* dropped page/layout (its SSR-only direct app import OR its SSR-only relative
|
|
1597
|
+
* helper) is never a walk root's dep, so it gets no `modulepreload` hint (no
|
|
1598
|
+
* over-fetch, #780). A module that also ships some other way (a component shared
|
|
1599
|
+
* with a live route, or reached via an import-only page's substituted
|
|
1600
|
+
* components) is still reached through a real shipped root, so its hint stays
|
|
1601
|
+
* (no under-fetch). `seen = new Set(moduleUrls)` already excludes the shipped
|
|
1602
|
+
* modules' own URLs; the shipped-roots change closes the TRANSITIVE gap.
|
|
1603
|
+
*
|
|
1584
1604
|
* @param {string[]} componentUrls direct component module URLs
|
|
1585
1605
|
* @param {string[]} moduleUrls boot script imports (page + layouts)
|
|
1586
1606
|
* @param {import('./module-graph.js').ModuleGraph | undefined} graph
|
|
1587
|
-
* @param {string[]} entryFiles absolute paths of
|
|
1607
|
+
* @param {string[]} entryFiles absolute paths of the SHIPPED page/layout modules (from `moduleUrls`)
|
|
1588
1608
|
* @param {string} appDir
|
|
1589
1609
|
* @param {Set<string>} [elidableComponents] absolute paths to skip in the walk
|
|
1590
1610
|
* @returns {string[]}
|
package/src/vendor.js
CHANGED
|
@@ -1176,9 +1176,16 @@ async function pruneOrphans(appDir, expected) {
|
|
|
1176
1176
|
* instead. When the app has zero bare-specifier imports at all
|
|
1177
1177
|
* (scanned source produced nothing), returns
|
|
1178
1178
|
* `{ pins: [], pruned: [], downloaded: 0, noBareImports: true }`
|
|
1179
|
-
* WITHOUT writing the pin file.
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1179
|
+
* WITHOUT writing the pin file. When the scan DID find specifiers but
|
|
1180
|
+
* every one was dropped because no local version resolved (the package
|
|
1181
|
+
* is not installed under `node_modules`), returns
|
|
1182
|
+
* `{ pins: [], pruned: [], downloaded: 0, droppedUnresolvable: [...] }`
|
|
1183
|
+
* (also WITHOUT a pin file), so the caller can name the found-but-
|
|
1184
|
+
* uninstalled specifiers instead of claiming there were none. A
|
|
1185
|
+
* partial pin (some resolved, some dropped) still writes the file and
|
|
1186
|
+
* carries `droppedUnresolvable` alongside `pins`. Callers that need to
|
|
1187
|
+
* surface a non-zero exit code key off `failed`, `noBareImports`, or an
|
|
1188
|
+
* all-dropped `droppedUnresolvable`; all are absent on a clean success.
|
|
1182
1189
|
*
|
|
1183
1190
|
* The `from` option mirrors importmap-rails's `bin/importmap pin foo
|
|
1184
1191
|
* --from jsdelivr`. Default `jspm` resolves to jspm.io; other values
|
|
@@ -1196,6 +1203,7 @@ async function pruneOrphans(appDir, expected) {
|
|
|
1196
1203
|
* provider?: string,
|
|
1197
1204
|
* failed?: boolean,
|
|
1198
1205
|
* noBareImports?: boolean,
|
|
1206
|
+
* droppedUnresolvable?: string[],
|
|
1199
1207
|
* attemptedInstalls?: string[],
|
|
1200
1208
|
* }>}
|
|
1201
1209
|
*/
|
|
@@ -1224,12 +1232,23 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
1224
1232
|
* @type {Map<string, { pkg: string, version: string, subpath: string }>}
|
|
1225
1233
|
*/
|
|
1226
1234
|
const partsByInstall = new Map();
|
|
1235
|
+
/**
|
|
1236
|
+
* Bare specifiers the scan FOUND but that were dropped because no local
|
|
1237
|
+
* version could be resolved (the package is not installed under the app's
|
|
1238
|
+
* `node_modules`). Kept distinct from "scan found nothing" so the CLI can
|
|
1239
|
+
* name them and point at the remedy instead of claiming there were none.
|
|
1240
|
+
* @type {string[]}
|
|
1241
|
+
*/
|
|
1242
|
+
const droppedUnresolvable = [];
|
|
1227
1243
|
for (const spec of bare) {
|
|
1228
1244
|
if (BUILTIN.has(spec)) continue;
|
|
1229
1245
|
const pkg = extractPackageName(spec);
|
|
1230
1246
|
if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
|
|
1231
1247
|
const version = getPackageVersion(pkg, appDir);
|
|
1232
|
-
if (!version)
|
|
1248
|
+
if (!version) {
|
|
1249
|
+
droppedUnresolvable.push(spec);
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1233
1252
|
const subpath = spec.slice(pkg.length);
|
|
1234
1253
|
const install = `${pkg}@${version}${subpath}`;
|
|
1235
1254
|
installs.push(install);
|
|
@@ -1350,12 +1369,22 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
1350
1369
|
// -imports filter, so the file exists but does nothing. The CLI
|
|
1351
1370
|
// surfaces this as a clearer "no bare imports found" message.
|
|
1352
1371
|
if (installs.length === 0) {
|
|
1372
|
+
// Distinguish the two empty-set causes the CLI must report differently:
|
|
1373
|
+
// the scan genuinely found nothing (noBareImports) vs it found specifiers
|
|
1374
|
+
// that were all dropped for a missing local version (droppedUnresolvable).
|
|
1375
|
+
if (droppedUnresolvable.length > 0) {
|
|
1376
|
+
return { pins, pruned: [], downloaded, droppedUnresolvable, provider: from };
|
|
1377
|
+
}
|
|
1353
1378
|
return { pins, pruned: [], downloaded, noBareImports: true, provider: from };
|
|
1354
1379
|
}
|
|
1355
1380
|
|
|
1356
1381
|
await writePinFile(appDir, importmap, integrity, from);
|
|
1357
1382
|
const pruned = await pruneOrphans(appDir, expected);
|
|
1358
|
-
|
|
1383
|
+
// Some specifiers may have pinned while others were dropped for a missing
|
|
1384
|
+
// version; surface the dropped ones so a partial pin is not silent.
|
|
1385
|
+
return droppedUnresolvable.length > 0
|
|
1386
|
+
? { pins, pruned, downloaded, provider: from, droppedUnresolvable }
|
|
1387
|
+
: { pins, pruned, downloaded, provider: from };
|
|
1359
1388
|
}
|
|
1360
1389
|
|
|
1361
1390
|
/**
|
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
|
-
|
|
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
|
}
|