@webjsdev/server 0.8.30 → 0.8.32
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/asset-hash.js +36 -7
- package/src/check.js +43 -66
- package/src/component-elision.js +32 -2
- package/src/dev.js +33 -3
- package/src/importmap.js +71 -0
- package/src/js-scan.js +115 -4
- package/src/module-graph.js +77 -4
package/package.json
CHANGED
package/src/asset-hash.js
CHANGED
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
|
|
40
40
|
import { createHash } from 'node:crypto';
|
|
41
41
|
import { readFileSync } from 'node:fs';
|
|
42
|
-
import { extname, join, resolve, sep } from 'node:path';
|
|
42
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
43
43
|
import { redactStringsAndTemplates } from './js-scan.js';
|
|
44
|
-
import { resolveImport } from './module-graph.js';
|
|
44
|
+
import { resolveImport, expandImportAlias } from './module-graph.js';
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Length of the emitted hex hash. 12 hex chars (48 bits) is plenty to
|
|
@@ -306,6 +306,24 @@ const JS_SPEC_EXTS = new Set(['.js', '.mjs', '.jsx', '.cjs']);
|
|
|
306
306
|
* @param {string} abs the resolved absolute file path
|
|
307
307
|
* @returns {string}
|
|
308
308
|
*/
|
|
309
|
+
/**
|
|
310
|
+
* A base-path-safe relative specifier from the importing module to a resolved
|
|
311
|
+
* target file, used to rewrite a `#`-alias import in served source (#555). The
|
|
312
|
+
* browser resolves a relative specifier against the importer's own URL (already
|
|
313
|
+
* base-path-prefixed), so the result needs no importmap and no basePath fix-up,
|
|
314
|
+
* and it carries the `?v` the caller appends. Uses the resolved file's actual
|
|
315
|
+
* extension (so no `normalizeSpecToResolved` pass is needed).
|
|
316
|
+
*
|
|
317
|
+
* @param {string} importerAbs absolute path of the module being served
|
|
318
|
+
* @param {string} abs resolved absolute path of the import target
|
|
319
|
+
* @returns {string}
|
|
320
|
+
*/
|
|
321
|
+
function relativeSpecifier(importerAbs, abs) {
|
|
322
|
+
let rel = relative(dirname(importerAbs), abs).split(sep).join('/');
|
|
323
|
+
if (!rel.startsWith('.')) rel = './' + rel;
|
|
324
|
+
return rel;
|
|
325
|
+
}
|
|
326
|
+
|
|
309
327
|
function normalizeSpecToResolved(spec, abs) {
|
|
310
328
|
const absExt = extname(abs);
|
|
311
329
|
if (!absExt) return spec;
|
|
@@ -391,9 +409,15 @@ export function versionModuleImports(source, importerAbs) {
|
|
|
391
409
|
// inside a string / template / comment and is not a real import edge.
|
|
392
410
|
if (masked[/** @type {number} */ (m.index)] === ' ') continue;
|
|
393
411
|
const spec = m[2];
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
|
|
412
|
+
const isRelative = spec[0] === '.';
|
|
413
|
+
// A `#`-style subpath alias (#555) is rewritten to a base-path-safe
|
|
414
|
+
// RELATIVE specifier below (so it carries `?v` and matches the preload,
|
|
415
|
+
// exactly like a relative import). Only `./`/`../` relatives and
|
|
416
|
+
// app-imports aliases qualify: a bare specifier is importmap-resolved and
|
|
417
|
+
// versioned at its target; a `/`-root-absolute specifier would miss the
|
|
418
|
+
// basePath prefix.
|
|
419
|
+
const isAlias = !isRelative && expandImportAlias(spec, _appDir) !== null;
|
|
420
|
+
if (!isRelative && !isAlias) continue;
|
|
397
421
|
if (spec.includes('?')) continue;
|
|
398
422
|
const abs = resolveImport(spec, importerAbs, _appDir);
|
|
399
423
|
if (!abs || SERVER_FILE_RE.test(abs)) continue;
|
|
@@ -402,9 +426,14 @@ export function versionModuleImports(source, importerAbs) {
|
|
|
402
426
|
if (!hash) continue;
|
|
403
427
|
// `/d` flag: indices[2] is [start, end] of the specifier CONTENT (no
|
|
404
428
|
// quotes). Replace it with the resolved-extension form + `?v` so the
|
|
405
|
-
// fetched URL is byte-identical to the preload href.
|
|
429
|
+
// fetched URL is byte-identical to the preload href. An alias is emitted
|
|
430
|
+
// as a relative specifier to the resolved file (the browser resolves it
|
|
431
|
+
// against the importer's already-prefixed URL, so it stays base-path-safe
|
|
432
|
+
// without the importmap, and the `?v` collapses fetch + preload to one
|
|
433
|
+
// immutable cache key).
|
|
406
434
|
const [start, end] = /** @type {[number, number]} */ (m.indices[2]);
|
|
407
|
-
|
|
435
|
+
const versioned = isAlias ? relativeSpecifier(importerAbs, abs) : normalizeSpecToResolved(spec, abs);
|
|
436
|
+
edits.push({ start, end, text: `${versioned}?v=${hash}` });
|
|
408
437
|
}
|
|
409
438
|
}
|
|
410
439
|
if (edits.length === 0) return source;
|
package/src/check.js
CHANGED
|
@@ -74,9 +74,14 @@ export const RULES = [
|
|
|
74
74
|
'A custom-element tag name must be registered exactly once across the app. Two `Class.register(\'tag\')` / `customElements.define(\'tag\', …)` calls for the SAME tag resolve INCONSISTENTLY at runtime: SSR overwrites the registry (last registration wins) while the browser keeps the first native upgrade, so the rendered element and the webjs registry disagree. Rename one tag.',
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
|
-
name: '
|
|
77
|
+
name: 'no-static-properties',
|
|
78
78
|
description:
|
|
79
|
-
'Reactive properties
|
|
79
|
+
'Reactive properties must be declared via the `extends WebComponent({ … })` factory, never a hand-written `static properties = { … }` field in the class body. The factory types each field for you (no `declare` needed) and the runtime throws on a direct `static properties`. Migrate `class X extends WebComponent { static properties = { count: { type: Number } } }` to `class X extends WebComponent({ count: Number })`; use the `prop()` helper for options (`prop(Number, { reflect: true })`) and set defaults via the `default` option or in the constructor.',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'reactive-props-no-class-field',
|
|
83
|
+
description:
|
|
84
|
+
'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field initializer (`count = 0` or `count: number = 0`) in the class body. Under modern class-field semantics that initializer runs Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default via the `default` property option (`prop(Number, { default: 0 })`) or by assigning in the constructor after super().',
|
|
80
85
|
},
|
|
81
86
|
{
|
|
82
87
|
name: 'shell-in-non-root-layout',
|
|
@@ -166,58 +171,6 @@ function isComponentFile(relPath) {
|
|
|
166
171
|
return segments.includes('components');
|
|
167
172
|
}
|
|
168
173
|
|
|
169
|
-
/**
|
|
170
|
-
* Find every `<key>:` entry inside the first `static properties = { … }`
|
|
171
|
-
* literal in `classBody`. Returns the bare property names: the keys
|
|
172
|
-
* we'll then look up as class fields.
|
|
173
|
-
*
|
|
174
|
-
* @param {string} classBody
|
|
175
|
-
* @returns {Set<string>}
|
|
176
|
-
*/
|
|
177
|
-
function extractStaticPropertyNames(classBody) {
|
|
178
|
-
/** @type {Set<string>} */
|
|
179
|
-
const names = new Set();
|
|
180
|
-
const m = /static\s+properties\s*=\s*\{/.exec(classBody);
|
|
181
|
-
if (!m) return names;
|
|
182
|
-
const objStart = m.index + m[0].length;
|
|
183
|
-
const objEnd = matchClosingBrace(classBody, objStart);
|
|
184
|
-
if (objEnd === -1) return names;
|
|
185
|
-
const obj = classBody.slice(objStart, objEnd);
|
|
186
|
-
// Match keys at the top level of the object literal. A nested `{ … }`
|
|
187
|
-
// (the per-property declaration) is skipped via brace counting.
|
|
188
|
-
let i = 0;
|
|
189
|
-
while (i < obj.length) {
|
|
190
|
-
// Skip whitespace and commas.
|
|
191
|
-
while (i < obj.length && /[\s,]/.test(obj[i])) i++;
|
|
192
|
-
if (i >= obj.length) break;
|
|
193
|
-
// Read the identifier or string-literal key.
|
|
194
|
-
let key = '';
|
|
195
|
-
if (obj[i] === '"' || obj[i] === "'") {
|
|
196
|
-
const quote = obj[i++];
|
|
197
|
-
while (i < obj.length && obj[i] !== quote) { key += obj[i++]; }
|
|
198
|
-
i++; // closing quote
|
|
199
|
-
} else {
|
|
200
|
-
while (i < obj.length && /[A-Za-z0-9_$]/.test(obj[i])) key += obj[i++];
|
|
201
|
-
}
|
|
202
|
-
// Skip whitespace, then expect `:`.
|
|
203
|
-
while (i < obj.length && /\s/.test(obj[i])) i++;
|
|
204
|
-
if (obj[i] !== ':') break;
|
|
205
|
-
i++;
|
|
206
|
-
// Skip whitespace, then skip the value (either a `{ … }` literal or
|
|
207
|
-
// a single token like `String`).
|
|
208
|
-
while (i < obj.length && /\s/.test(obj[i])) i++;
|
|
209
|
-
if (obj[i] === '{') {
|
|
210
|
-
const valEnd = matchClosingBrace(obj, i + 1);
|
|
211
|
-
if (valEnd === -1) break;
|
|
212
|
-
i = valEnd + 1;
|
|
213
|
-
} else {
|
|
214
|
-
while (i < obj.length && obj[i] !== ',' && obj[i] !== '}') i++;
|
|
215
|
-
}
|
|
216
|
-
if (key) names.add(key);
|
|
217
|
-
}
|
|
218
|
-
return names;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
174
|
/**
|
|
222
175
|
* Scan a class body for class-field initializers naming any of `props`.
|
|
223
176
|
* "Class-field" means: at the top of the class body (brace depth 0
|
|
@@ -440,23 +393,47 @@ export async function checkConventions(appDir) {
|
|
|
440
393
|
}
|
|
441
394
|
}
|
|
442
395
|
|
|
443
|
-
// --- Rule:
|
|
396
|
+
// --- Rule: no-static-properties ---
|
|
397
|
+
// A hand-written `static properties = { … }` in a WebComponent class body is
|
|
398
|
+
// no longer supported: reactive properties are declared via the
|
|
399
|
+
// `extends WebComponent({ … })` factory (the runtime throws on a direct
|
|
400
|
+
// `static properties`). Flag it statically so the editor catches it before
|
|
401
|
+
// the page 500s.
|
|
444
402
|
{
|
|
445
403
|
for (const { rel, scan } of files) {
|
|
446
|
-
// Use redacted source so
|
|
447
|
-
// `class X extends WebComponent {
|
|
448
|
-
// literals don't trip the rule. Real declarations live at
|
|
404
|
+
// Use redacted source so fixture-style strings like
|
|
405
|
+
// `class X extends WebComponent { static properties = {…} }` inside
|
|
406
|
+
// template literals don't trip the rule. Real declarations live at
|
|
449
407
|
// top-level code where the redactor leaves them alone.
|
|
450
408
|
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
451
|
-
for (const body of extractWebComponentClassBodies(scan)) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
409
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
410
|
+
if (!/static\s+properties\s*=\s*\{/.test(body)) continue;
|
|
411
|
+
violations.push({
|
|
412
|
+
rule: 'no-static-properties',
|
|
413
|
+
file: rel,
|
|
414
|
+
message:
|
|
415
|
+
'`static properties = { … }` is no longer supported; declare reactive properties via the `extends WebComponent({ … })` factory instead.',
|
|
416
|
+
fix: 'Move the properties into the factory call: `class X extends WebComponent({ count: Number })`. Use `prop(Number, { reflect: true })` for options and set defaults via the `default` option or the constructor. Delete the `static properties` block and any `declare` fields for those props.',
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- Rule: reactive-props-no-class-field ---
|
|
423
|
+
// A reactive property declared via the factory must not also carry a plain
|
|
424
|
+
// class-field initializer: it runs Object.defineProperty after super() and
|
|
425
|
+
// clobbers the framework's reactive accessor (silent broken re-renders).
|
|
426
|
+
{
|
|
427
|
+
for (const { rel, scan } of files) {
|
|
428
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
429
|
+
for (const { body, factoryProps } of extractWebComponentClassBodies(scan)) {
|
|
430
|
+
if (factoryProps.size === 0) continue;
|
|
431
|
+
for (const bad of findFieldInitializers(body, factoryProps)) {
|
|
455
432
|
violations.push({
|
|
456
|
-
rule: 'reactive-props-
|
|
433
|
+
rule: 'reactive-props-no-class-field',
|
|
457
434
|
file: rel,
|
|
458
435
|
message: `Reactive prop \`${bad}\` uses a class-field initializer; this clobbers the framework's reactive accessor under modern class-field semantics.`,
|
|
459
|
-
fix: `
|
|
436
|
+
fix: `Remove the class-field initializer and set the default via the \`default\` option (\`prop(<Type>, { default: <value> })\`) or by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
|
|
460
437
|
});
|
|
461
438
|
}
|
|
462
439
|
}
|
|
@@ -473,7 +450,7 @@ export async function checkConventions(appDir) {
|
|
|
473
450
|
{
|
|
474
451
|
for (const { rel, scan } of files) {
|
|
475
452
|
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
476
|
-
for (const body of extractWebComponentClassBodies(scan)) {
|
|
453
|
+
for (const { body } of extractWebComponentClassBodies(scan)) {
|
|
477
454
|
for (const method of ['constructor', 'willUpdate', 'render']) {
|
|
478
455
|
const code = methodBodyOf(body, method);
|
|
479
456
|
if (!code) continue;
|
|
@@ -661,7 +638,7 @@ export async function checkConventions(appDir) {
|
|
|
661
638
|
// modifiers immediately followed by an identifier inside the
|
|
662
639
|
// constructor's parameter list.
|
|
663
640
|
regex: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/,
|
|
664
|
-
fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`.
|
|
641
|
+
fix: 'Replace `constructor(public x: number)` with `x: number; constructor(x: number) { this.x = x; }`.',
|
|
665
642
|
},
|
|
666
643
|
{
|
|
667
644
|
name: 'import = require',
|
package/src/component-elision.js
CHANGED
|
@@ -416,7 +416,7 @@ export function analyzeComponentSource(src) {
|
|
|
416
416
|
return { interactive: true, reason: 'no parseable WebComponent class body' };
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
-
for (const body of bodies) {
|
|
419
|
+
for (const { body, factoryArg } of bodies) {
|
|
420
420
|
// Shadow DOM always ships (#474). Declarative Shadow DOM attaches ONLY
|
|
421
421
|
// during HTML parsing; an elided component that arrives via a client DOM
|
|
422
422
|
// insertion (a soft-nav swap, a streamed <webjs-suspense> boundary's
|
|
@@ -447,7 +447,7 @@ export function analyzeComponentSource(src) {
|
|
|
447
447
|
return { interactive: true, reason: `calls '${call}'` };
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
|
-
if (hasNonStateReactiveProperty(body)) {
|
|
450
|
+
if (hasNonStateReactiveProperty(body) || hasNonStateFactoryProperty(factoryArg)) {
|
|
451
451
|
return {
|
|
452
452
|
interactive: true,
|
|
453
453
|
reason: 'declares a reactive property that is not { state: true }',
|
|
@@ -571,6 +571,36 @@ function hasNonStateReactiveProperty(classBody) {
|
|
|
571
571
|
return false;
|
|
572
572
|
}
|
|
573
573
|
|
|
574
|
+
/**
|
|
575
|
+
* True if the class factory argument declares any property that is NOT
|
|
576
|
+
* marked `{ state: true }`.
|
|
577
|
+
*
|
|
578
|
+
* @param {string} factoryArg
|
|
579
|
+
* @returns {boolean}
|
|
580
|
+
*/
|
|
581
|
+
function hasNonStateFactoryProperty(factoryArg) {
|
|
582
|
+
if (!factoryArg) return false;
|
|
583
|
+
const objStart = factoryArg.indexOf('{');
|
|
584
|
+
if (objStart === -1) return false;
|
|
585
|
+
const objEnd = matchClosingBrace(factoryArg, objStart);
|
|
586
|
+
if (objEnd === -1) return true;
|
|
587
|
+
const obj = factoryArg.slice(objStart + 1, objEnd);
|
|
588
|
+
if (/\.\.\./.test(obj)) return true; // spread
|
|
589
|
+
for (const entry of topLevelPropertyValues(obj)) {
|
|
590
|
+
if (entry.startsWith('{')) {
|
|
591
|
+
const code = entry
|
|
592
|
+
.replace(/'[^'\n]*'/g, "''")
|
|
593
|
+
.replace(/"[^"\n]*"/g, '""')
|
|
594
|
+
.replace(/`[^`]*`/g, '``');
|
|
595
|
+
if (!/\bstate\s*:\s*true\b/.test(code)) return true;
|
|
596
|
+
} else {
|
|
597
|
+
// Shorthand like `count: Number` rides an attribute, not state
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
|
|
574
604
|
/**
|
|
575
605
|
* Yield the trimmed VALUE text of each top-level `key: value` entry in a
|
|
576
606
|
* properties object body, splitting on depth-0 commas and respecting
|
package/src/dev.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer as createHttp1Server } from 'node:http';
|
|
2
2
|
import { stat, readFile, watch as fsWatch } from 'node:fs/promises';
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join, extname, resolve, dirname, relative, sep } from 'node:path';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
varyWithAcceptEncoding,
|
|
46
46
|
} from './listener-core.js';
|
|
47
47
|
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
|
|
48
|
-
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
|
|
48
|
+
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport, appImportsMap } from './module-graph.js';
|
|
49
49
|
import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
|
|
50
50
|
import { analyzeElision, elideImportsFromSource } from './component-elision.js';
|
|
51
51
|
|
|
@@ -85,7 +85,7 @@ function resolveRequestId(req) {
|
|
|
85
85
|
function shouldAccessLog(pathname) {
|
|
86
86
|
return !pathname.startsWith('/__webjs/');
|
|
87
87
|
}
|
|
88
|
-
import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
|
|
88
|
+
import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
|
|
89
89
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
90
90
|
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
|
|
91
91
|
import { urlFromRequest } from './forwarded.js';
|
|
@@ -493,6 +493,16 @@ export async function createRequestHandler(opts) {
|
|
|
493
493
|
existsSync(join(distDir, 'webjs-core-browser.js'));
|
|
494
494
|
await setCoreInstall(coreDir, distComplete);
|
|
495
495
|
|
|
496
|
+
// Path-alias imports (#555). Emit the browser importmap scopes for the app's
|
|
497
|
+
// `package.json "imports"` aliases, derived from the SAME map the server
|
|
498
|
+
// resolver (`expandImportAlias`) reads, so SSR and the browser agree. The
|
|
499
|
+
// scaffold ships the single catch-all `"#*": "./*"` (one key, zero
|
|
500
|
+
// maintenance, Bun-safe), which expands to one browser prefix scope per
|
|
501
|
+
// top-level dir (`#lib/` -> `/lib/`, ...); the dir scan is here so a new
|
|
502
|
+
// folder is picked up on boot. An app with no `"imports"` block yields {}
|
|
503
|
+
// (a no-op, byte-identical to before this feature).
|
|
504
|
+
await setImportAliasEntries(importAliasBrowserEntries(appImportsMap(appDir), appTopLevelDirs(appDir)));
|
|
505
|
+
|
|
496
506
|
// Content-hash asset URLs for immutable caching (issue #243). Bind the
|
|
497
507
|
// asset-hash module to the app + core roots and enable fingerprinting in
|
|
498
508
|
// PROD only. In dev `withAssetHash` stays a pure no-op (never enabled), so
|
|
@@ -2361,6 +2371,26 @@ function computeBrowserBoundFiles(routeTable, moduleGraph, components, appDir) {
|
|
|
2361
2371
|
return reachableFromEntries(moduleGraph, [...entries], appDir);
|
|
2362
2372
|
}
|
|
2363
2373
|
|
|
2374
|
+
/**
|
|
2375
|
+
* List the app's top-level source directory names, for expanding a `#*`
|
|
2376
|
+
* catch-all import alias into one browser importmap prefix scope per dir (#555).
|
|
2377
|
+
* Excludes infra dirs that are never imported via `#` (node_modules, dotfiles,
|
|
2378
|
+
* the build/vendor caches). A new top-level folder is picked up on the next boot
|
|
2379
|
+
* (dev restarts on changes), so the alias stays zero-maintenance.
|
|
2380
|
+
* @param {string} appDir
|
|
2381
|
+
* @returns {string[]}
|
|
2382
|
+
*/
|
|
2383
|
+
function appTopLevelDirs(appDir) {
|
|
2384
|
+
const SKIP = new Set(['node_modules', 'dist', 'public']);
|
|
2385
|
+
try {
|
|
2386
|
+
return readdirSync(appDir, { withFileTypes: true })
|
|
2387
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP.has(e.name))
|
|
2388
|
+
.map((e) => e.name);
|
|
2389
|
+
} catch {
|
|
2390
|
+
return [];
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2364
2394
|
/**
|
|
2365
2395
|
* Find the absolute directory of the `@webjsdev/core` package, regardless of
|
|
2366
2396
|
* whether we're running from the monorepo or an installed copy.
|
package/src/importmap.js
CHANGED
|
@@ -28,6 +28,17 @@ function escapeAttr(s) {
|
|
|
28
28
|
/** @type {Record<string, string>} */
|
|
29
29
|
let _extraEntries = {};
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Browser importmap entries for the app's `package.json "imports"` subpath
|
|
33
|
+
* aliases (#555), e.g. `{ "#lib/": "/lib/" }` for `"#lib/*": "./lib/*"`. Kept in lockstep
|
|
34
|
+
* with the server-side resolver (`module-graph.js`'s `expandImportAlias`):
|
|
35
|
+
* BOTH are derived from the one `"imports"` map, so an alias that resolves in
|
|
36
|
+
* SSR never 404s in the browser (the #158/#159 preload-mismatch class). Set at
|
|
37
|
+
* boot by `dev.js` via `setImportAliasEntries`.
|
|
38
|
+
* @type {Record<string, string>}
|
|
39
|
+
*/
|
|
40
|
+
let _aliasEntries = {};
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* The normalized `webjs.basePath` for a sub-path deployment (issue #256),
|
|
33
44
|
* `''` (the default) for a root mount. When non-empty, every same-origin
|
|
@@ -67,6 +78,65 @@ export function basePath() {
|
|
|
67
78
|
return _basePath;
|
|
68
79
|
}
|
|
69
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Derive the BROWSER importmap entries for an app's `package.json "imports"`
|
|
83
|
+
* subpath-alias map (#555). The scaffold ships a single root catch-all key
|
|
84
|
+
* `"#*": "./*"` (one key, zero maintenance: a new top-level folder is aliased
|
|
85
|
+
* with no config change, and `#*` resolves natively on Node AND Bun, unlike a
|
|
86
|
+
* `#/`-prefixed key which Bun rejects). A browser importmap needs a
|
|
87
|
+
* trailing-slash PREFIX key to match, and a bare `#` is not one, so a catch-all
|
|
88
|
+
* is expanded into one prefix scope PER top-level directory (`topLevelDirs`):
|
|
89
|
+
* `#lib/` -> `/lib/`, `#components/` -> `/components/`, etc. The dirs are scanned
|
|
90
|
+
* by the caller (dev.js) so this stays pure; a new folder produces a new scope
|
|
91
|
+
* on the next boot. A non-catch-all key is mapped directly: a per-dir wildcard
|
|
92
|
+
* `"#lib/*": "./lib/*"` becomes `"#lib/": "/lib/"`, an exact `"#db": "./x.ts"`
|
|
93
|
+
* becomes `"#db": "/x.ts"`. The leading `./` becomes a root-absolute `/` and the
|
|
94
|
+
* `*` is dropped (the trailing slash carries the prefix match). A non-default
|
|
95
|
+
* base (`"#*": "./src/*"`) folds into the emitted URL. Only string targets are
|
|
96
|
+
* mappable (a conditional-export object is skipped). Derived from the SAME map
|
|
97
|
+
* the server resolver reads, so SSR and the browser agree.
|
|
98
|
+
*
|
|
99
|
+
* @param {Record<string, unknown> | null | undefined} importsMap
|
|
100
|
+
* @param {string[]} [topLevelDirs] app top-level dir names, for expanding a `#*` catch-all
|
|
101
|
+
* @returns {Record<string, string>}
|
|
102
|
+
*/
|
|
103
|
+
export function importAliasBrowserEntries(importsMap, topLevelDirs = []) {
|
|
104
|
+
/** @type {Record<string, string>} */
|
|
105
|
+
const out = {};
|
|
106
|
+
if (!importsMap || typeof importsMap !== 'object') return out;
|
|
107
|
+
for (const [key, value] of Object.entries(importsMap)) {
|
|
108
|
+
if (typeof value !== 'string') continue;
|
|
109
|
+
if (!value.startsWith('./')) continue; // only app-root-relative targets map to a URL
|
|
110
|
+
const star = key.indexOf('*');
|
|
111
|
+
if (star !== -1 && key.slice(0, star) === '#') {
|
|
112
|
+
// Root catch-all `#*` -> `./<base>*`: expand to a prefix scope per dir.
|
|
113
|
+
// base is the part of the value between `.` and `*` (`'' ` for `./*`,
|
|
114
|
+
// `/src/` for `./src/*`), so `#<dir>/` -> `/<base><dir>/`.
|
|
115
|
+
const base = value.slice(1, value.lastIndexOf('*')); // './*' -> '/', './src/*' -> '/src/'
|
|
116
|
+
for (const dir of topLevelDirs) out[`#${dir}/`] = `${base}${dir}/`;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// Per-dir wildcard or exact key: map directly.
|
|
120
|
+
out[key.replace('*', '')] = value.replace(/^\./, '').replace('*', '');
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Bind the browser importmap to the app's `package.json "imports"` aliases
|
|
127
|
+
* (#555). Called once at boot by `dev.js`, derived from the SAME `"imports"`
|
|
128
|
+
* map the server resolver reads, so SSR and the browser agree. The hash is
|
|
129
|
+
* recomputed eagerly (like `setBasePath` / `setCoreInstall`) so
|
|
130
|
+
* `importMapHash()` stays synchronous on the per-request path.
|
|
131
|
+
*
|
|
132
|
+
* @param {Record<string, string>} entries browser entries from `importAliasBrowserEntries`
|
|
133
|
+
* @returns {Promise<void>}
|
|
134
|
+
*/
|
|
135
|
+
export async function setImportAliasEntries(entries) {
|
|
136
|
+
_aliasEntries = entries && typeof entries === 'object' ? entries : {};
|
|
137
|
+
_importMapHash = await digestHex('SHA-256', JSON.stringify(buildImportMap({ fingerprint: false })));
|
|
138
|
+
}
|
|
139
|
+
|
|
70
140
|
/**
|
|
71
141
|
* SRI integrity hashes keyed by FINAL URL (post-importmap-rewrite).
|
|
72
142
|
* Populated only when a pin file with `integrity` is present;
|
|
@@ -338,6 +408,7 @@ export function buildImportMap(opts = {}) {
|
|
|
338
408
|
const merged = {
|
|
339
409
|
..._coreEntries,
|
|
340
410
|
..._extraEntries,
|
|
411
|
+
..._aliasEntries,
|
|
341
412
|
};
|
|
342
413
|
// Sort keys so logically-identical importmaps serialize byte-for-byte
|
|
343
414
|
// identically. The client router compares textContent to detect
|
package/src/js-scan.js
CHANGED
|
@@ -347,16 +347,127 @@ export function maskComments(src) {
|
|
|
347
347
|
*/
|
|
348
348
|
export function extractWebComponentClassBodies(content) {
|
|
349
349
|
const bodies = [];
|
|
350
|
-
const re = /class\s+\w+\s+extends\s+WebComponent
|
|
350
|
+
const re = /class\s+\w+\s+extends\s+WebComponent/g;
|
|
351
351
|
let m;
|
|
352
352
|
while ((m = re.exec(content)) !== null) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (
|
|
353
|
+
let i = m.index + m[0].length;
|
|
354
|
+
while (i < content.length && /\s/.test(content[i])) i++;
|
|
355
|
+
if (i >= content.length) continue;
|
|
356
|
+
|
|
357
|
+
let factoryArg = '';
|
|
358
|
+
const factoryProps = new Set();
|
|
359
|
+
if (content[i] === '(') {
|
|
360
|
+
const closeParen = matchClosingParenthesis(content, i + 1);
|
|
361
|
+
if (closeParen === -1) continue;
|
|
362
|
+
factoryArg = content.slice(i + 1, closeParen);
|
|
363
|
+
|
|
364
|
+
const objStart = factoryArg.indexOf('{');
|
|
365
|
+
if (objStart !== -1) {
|
|
366
|
+
const objEnd = matchClosingBrace(factoryArg, objStart + 1);
|
|
367
|
+
if (objEnd !== -1) {
|
|
368
|
+
const objContent = factoryArg.slice(objStart + 1, objEnd);
|
|
369
|
+
parsePropsFromObjectLiteral(objContent, factoryProps);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
i = closeParen + 1;
|
|
373
|
+
while (i < content.length && /\s/.test(content[i])) i++;
|
|
374
|
+
}
|
|
375
|
+
if (content[i] === '{') {
|
|
376
|
+
const bodyStart = i + 1;
|
|
377
|
+
const end = matchClosingBrace(content, bodyStart);
|
|
378
|
+
if (end !== -1) {
|
|
379
|
+
bodies.push({
|
|
380
|
+
body: content.slice(bodyStart, end),
|
|
381
|
+
factoryProps,
|
|
382
|
+
factoryArg
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
356
386
|
}
|
|
357
387
|
return bodies;
|
|
358
388
|
}
|
|
359
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Extract properties from an object literal block.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} obj
|
|
394
|
+
* @param {Set<string>} names
|
|
395
|
+
*/
|
|
396
|
+
export function parsePropsFromObjectLiteral(obj, names) {
|
|
397
|
+
let i = 0;
|
|
398
|
+
while (i < obj.length) {
|
|
399
|
+
while (i < obj.length && /[\s,]/.test(obj[i])) i++;
|
|
400
|
+
if (i >= obj.length) break;
|
|
401
|
+
let key = '';
|
|
402
|
+
if (obj[i] === '"' || obj[i] === "'") {
|
|
403
|
+
const quote = obj[i++];
|
|
404
|
+
while (i < obj.length && obj[i] !== quote) { key += obj[i++]; }
|
|
405
|
+
i++; // closing quote
|
|
406
|
+
} else {
|
|
407
|
+
while (i < obj.length && /[A-Za-z0-9_$]/.test(obj[i])) key += obj[i++];
|
|
408
|
+
}
|
|
409
|
+
while (i < obj.length && /\s/.test(obj[i])) i++;
|
|
410
|
+
if (obj[i] !== ':') break;
|
|
411
|
+
i++;
|
|
412
|
+
while (i < obj.length && /\s/.test(obj[i])) i++;
|
|
413
|
+
if (obj[i] === '{') {
|
|
414
|
+
const valEnd = matchClosingBrace(obj, i + 1);
|
|
415
|
+
if (valEnd === -1) break;
|
|
416
|
+
i = valEnd + 1;
|
|
417
|
+
} else {
|
|
418
|
+
while (i < obj.length && obj[i] !== ',' && obj[i] !== '}') i++;
|
|
419
|
+
}
|
|
420
|
+
if (key) names.add(key);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Walk forward from `start` (just after an opening `(`) and return the
|
|
427
|
+
* index of the matching `)`. Tracks string/template-literal state so
|
|
428
|
+
* `)` inside strings or templates does not decrement depth.
|
|
429
|
+
* Returns -1 if no balanced parenthesis is found.
|
|
430
|
+
*
|
|
431
|
+
* @param {string} s
|
|
432
|
+
* @param {number} start
|
|
433
|
+
*/
|
|
434
|
+
export function matchClosingParenthesis(s, start) {
|
|
435
|
+
let depth = 1;
|
|
436
|
+
let i = start;
|
|
437
|
+
let str = ''; // '', "'", '"', or backtick
|
|
438
|
+
while (i < s.length) {
|
|
439
|
+
const c = s[i];
|
|
440
|
+
if (str) {
|
|
441
|
+
if (c === '\\') { i += 2; continue; }
|
|
442
|
+
if (c === str) str = '';
|
|
443
|
+
else if (str === '`' && c === '$' && s[i + 1] === '{') {
|
|
444
|
+
const closeBrace = matchClosingBrace(s, i + 2);
|
|
445
|
+
if (closeBrace === -1) return -1;
|
|
446
|
+
i = closeBrace + 1;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
i++;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
|
|
453
|
+
if (c === '/' && s[i + 1] === '/') { // line comment
|
|
454
|
+
while (i < s.length && s[i] !== '\n') i++;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (c === '/' && s[i + 1] === '*') { // block comment
|
|
458
|
+
i += 2;
|
|
459
|
+
while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++;
|
|
460
|
+
i += 2;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (c === '(') depth++;
|
|
464
|
+
else if (c === ')') { depth--; if (depth === 0) return i; }
|
|
465
|
+
i++;
|
|
466
|
+
}
|
|
467
|
+
return -1;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
|
|
360
471
|
/**
|
|
361
472
|
* Walk forward from `start` (just after an opening `{`) and return the
|
|
362
473
|
* index of the matching `}`. Tracks string/template-literal state so
|
package/src/module-graph.js
CHANGED
|
@@ -12,10 +12,71 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
15
|
-
import { existsSync } from 'node:fs';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
16
|
import { join, resolve, dirname, extname, sep } from 'node:path';
|
|
17
17
|
import { redactStringsAndTemplates } from './js-scan.js';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Per-appDir cache of the parsed `package.json "imports"` subpath-alias map
|
|
21
|
+
* (#555). `null` means "no imports block" (the common case for an app that
|
|
22
|
+
* does not use the `#` alias). Read once per appDir; cleared with the parse
|
|
23
|
+
* cache on rebuild.
|
|
24
|
+
* @type {Map<string, Record<string,string> | null>}
|
|
25
|
+
*/
|
|
26
|
+
const IMPORTS_CACHE = new Map();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read an app's `package.json "imports"` subpath-import map (Node's native
|
|
30
|
+
* import-alias mechanism). Cached per appDir.
|
|
31
|
+
* @param {string} appDir
|
|
32
|
+
* @returns {Record<string,string> | null}
|
|
33
|
+
*/
|
|
34
|
+
export function appImportsMap(appDir) {
|
|
35
|
+
if (IMPORTS_CACHE.has(appDir)) return IMPORTS_CACHE.get(appDir) ?? null;
|
|
36
|
+
let map = null;
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8'));
|
|
39
|
+
if (pkg && typeof pkg.imports === 'object' && pkg.imports) map = pkg.imports;
|
|
40
|
+
} catch { /* no package.json / unparseable: no aliases */ }
|
|
41
|
+
IMPORTS_CACHE.set(appDir, map);
|
|
42
|
+
return map;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Expand a `package.json "imports"` subpath alias (e.g. `#lib/db.server.ts`
|
|
47
|
+
* with the scaffold's catch-all `"#*": "./*"`) to its real APP-RELATIVE target
|
|
48
|
+
* string (`./lib/db.server.ts`).
|
|
49
|
+
* The security-critical seam (#555): the graph walker, auth gate, elision, and
|
|
50
|
+
* `no-server-import-in-browser-module` all route through `resolveImport`, so
|
|
51
|
+
* expanding the alias here (to the real path) is what stops an alias from
|
|
52
|
+
* laundering a `.server.ts` past those checks. Driven off the actual `"imports"`
|
|
53
|
+
* map so a non-default base (`"#lib/*": "./src/lib/*"`) is honored; never
|
|
54
|
+
* hardcodes `./`. Supports one trailing `*` wildcard per key (Node's rule) plus
|
|
55
|
+
* exact keys. (A `#/`-prefixed key is avoided in the scaffold because Bun's
|
|
56
|
+
* native resolver rejects it; the expansion here is key-shape-agnostic.)
|
|
57
|
+
* @param {string} spec the import specifier, e.g. `'#lib/db.server.ts'`
|
|
58
|
+
* @param {string} appDir
|
|
59
|
+
* @returns {string | null} the app-relative target (e.g. `'./lib/db.server.ts'`), or null if no alias matches
|
|
60
|
+
*/
|
|
61
|
+
export function expandImportAlias(spec, appDir) {
|
|
62
|
+
const map = appImportsMap(appDir);
|
|
63
|
+
if (!map) return null;
|
|
64
|
+
for (const [key, value] of Object.entries(map)) {
|
|
65
|
+
if (typeof value !== 'string') continue; // conditional-export objects are not graph-resolvable
|
|
66
|
+
const star = key.indexOf('*');
|
|
67
|
+
if (star === -1) {
|
|
68
|
+
if (spec === key) return value;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const prefix = key.slice(0, star);
|
|
72
|
+
const suffix = key.slice(star + 1);
|
|
73
|
+
if (!spec.startsWith(prefix) || !spec.endsWith(suffix)) continue;
|
|
74
|
+
const middle = spec.slice(prefix.length, spec.length - suffix.length);
|
|
75
|
+
return value.replace('*', middle);
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
19
80
|
/** @type {RegExp} match static `import … from '…'` and `import '…'` */
|
|
20
81
|
const IMPORT_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
21
82
|
|
|
@@ -52,6 +113,9 @@ const EXPORT_FROM_RE = /\bexport\b[^'";]+?\sfrom\s+['"]([^'"]+)['"]/g;
|
|
|
52
113
|
* @returns {Promise<ModuleGraph>}
|
|
53
114
|
*/
|
|
54
115
|
export async function buildModuleGraph(appDir) {
|
|
116
|
+
// Re-read the app's `package.json "imports"` each build so an edit to the
|
|
117
|
+
// alias map (#555) is picked up on rebuild.
|
|
118
|
+
IMPORTS_CACHE.delete(appDir);
|
|
55
119
|
/** @type {ModuleGraph} */
|
|
56
120
|
const graph = new Map();
|
|
57
121
|
/** @type {Set<string>} every file walked this build (graph holds only files
|
|
@@ -286,8 +350,11 @@ async function parseFile(file, appDir, graph, seen) {
|
|
|
286
350
|
// and is not a real import edge.
|
|
287
351
|
if (masked[m.index] === ' ') continue;
|
|
288
352
|
const spec = m[1];
|
|
289
|
-
// Only resolve relative imports
|
|
290
|
-
|
|
353
|
+
// Only resolve relative imports + `#`-style subpath aliases (#555)
|
|
354
|
+
// within the project. A bare npm specifier (dayjs) has no alias match
|
|
355
|
+
// and is skipped; an aliased `#lib/x.server.ts` IS followed so the
|
|
356
|
+
// graph / auth gate / elision see the real path through the alias.
|
|
357
|
+
if (!spec.startsWith('.') && !spec.startsWith('/') && !expandImportAlias(spec, appDir)) continue;
|
|
291
358
|
const resolved = resolveImport(spec, file, appDir);
|
|
292
359
|
if (resolved) deps.add(resolved);
|
|
293
360
|
}
|
|
@@ -308,7 +375,13 @@ async function parseFile(file, appDir, graph, seen) {
|
|
|
308
375
|
export function resolveImport(spec, fromFile, appDir) {
|
|
309
376
|
const base = dirname(fromFile);
|
|
310
377
|
let target;
|
|
311
|
-
|
|
378
|
+
const aliased = expandImportAlias(spec, appDir);
|
|
379
|
+
if (aliased) {
|
|
380
|
+
// `#`-style subpath alias (#555): the app-relative target is resolved
|
|
381
|
+
// against the app root, then run through the same extension fallback below
|
|
382
|
+
// so the boundary / elision / preload all see the real on-disk path.
|
|
383
|
+
target = resolve(appDir, aliased);
|
|
384
|
+
} else if (spec.startsWith('/')) {
|
|
312
385
|
// Absolute from app root (how browser sees it)
|
|
313
386
|
target = join(appDir, spec);
|
|
314
387
|
} else {
|