@webjsdev/server 0.8.5 → 0.8.7
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/auth.js +2 -2
- package/src/check.js +96 -0
- package/src/component-elision.js +25 -11
- package/src/dev.js +87 -32
- package/src/js-scan.js +105 -0
- package/src/vendor.js +49 -0
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -86,7 +86,7 @@ function clearCookie(name) {
|
|
|
86
86
|
// -- JWT --------------------------------------------------------------------
|
|
87
87
|
|
|
88
88
|
/** @param {Record<string,unknown>} payload @param {string} secret */
|
|
89
|
-
async function encodeJwt(payload, secret) {
|
|
89
|
+
export async function encodeJwt(payload, secret) {
|
|
90
90
|
const h = b64url(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
|
|
91
91
|
const p = b64url(enc.encode(JSON.stringify(payload)));
|
|
92
92
|
const unsigned = `${h}.${p}`;
|
|
@@ -95,7 +95,7 @@ async function encodeJwt(payload, secret) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/** @param {string} token @param {string} secret @returns {Promise<Record<string,unknown>|null>} */
|
|
98
|
-
async function decodeJwt(token, secret) {
|
|
98
|
+
export async function decodeJwt(token, secret) {
|
|
99
99
|
const parts = token.split('.');
|
|
100
100
|
if (parts.length !== 3) return null;
|
|
101
101
|
// `unb64url` → `atob` throws InvalidCharacterError on non-base64 input.
|
package/src/check.js
CHANGED
|
@@ -114,6 +114,11 @@ export const RULES = [
|
|
|
114
114
|
description:
|
|
115
115
|
'Verifies the `.gitignore` exception for `.webjs/vendor/` is structurally correct via `git check-ignore`. The intended pattern is `.webjs/*` (NOT `.webjs/`) plus `!.webjs/vendor/` plus `!.webjs/vendor/**`. The common-looking pattern `.webjs/` excludes the directory itself, after which git cannot re-include children (gitignore semantics: a parent exclusion blocks child negations). Without this rule, an AI agent or human editor would silently break `webjs vendor pin` by simplifying the pattern; the failure is invisible until production. Rule fires when the working directory is a git repo and a `.gitignore` exists; skipped when neither is true.',
|
|
116
116
|
},
|
|
117
|
+
{
|
|
118
|
+
name: 'no-browser-globals-in-render',
|
|
119
|
+
description:
|
|
120
|
+
'Flags browser-only APIs used in a WebComponent constructor or render() method. The SSR pipeline instantiates the component (running the constructor) and calls render() to produce HTML, on a bare server-side class with no DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an HTMLElement instance member on `this` (attachShadow, shadowRoot, setAttribute, getAttribute, removeAttribute, dispatchEvent, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). These belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes, props). Conservative: only the constructor and render bodies are scanned (the methods SSR actually runs), and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
|
|
121
|
+
},
|
|
117
122
|
];
|
|
118
123
|
|
|
119
124
|
/** Set of all known rule names for fast lookup. */
|
|
@@ -389,6 +394,72 @@ function findFieldInitializers(classBody, props) {
|
|
|
389
394
|
return out;
|
|
390
395
|
}
|
|
391
396
|
|
|
397
|
+
// Browser-only globals that are undefined during SSR (the server-side
|
|
398
|
+
// WebComponent base is a bare class with no DOM). High-confidence names only
|
|
399
|
+
// (unlikely to be ordinary local variables), so the rule stays low-noise.
|
|
400
|
+
const BROWSER_GLOBALS = [
|
|
401
|
+
'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
|
|
402
|
+
'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
|
|
403
|
+
'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
|
|
404
|
+
];
|
|
405
|
+
// HTMLElement instance members that do not exist on the bare server class, so
|
|
406
|
+
// `this.<member>` throws (a method call) or is `undefined` (a property) at SSR.
|
|
407
|
+
const HTMLELEMENT_MEMBERS = [
|
|
408
|
+
'attachShadow', 'shadowRoot', 'setAttribute', 'getAttribute',
|
|
409
|
+
'removeAttribute', 'hasAttribute', 'dispatchEvent', 'classList',
|
|
410
|
+
'querySelector', 'querySelectorAll', 'getBoundingClientRect',
|
|
411
|
+
'focus', 'blur', 'scrollIntoView',
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Extract the body text of a named method from a (redacted) class body, or
|
|
416
|
+
* '' if absent. Handles `async`, a TS return-type annotation, and params.
|
|
417
|
+
* @param {string} classBody
|
|
418
|
+
* @param {string} name
|
|
419
|
+
*/
|
|
420
|
+
function methodBodyOf(classBody, name) {
|
|
421
|
+
const re = new RegExp(`(?:^|[\\s;}])(?:async\\s+)?${name}\\s*\\([^)]*\\)\\s*(?::[^{]*)?\\{`, 'g');
|
|
422
|
+
const m = re.exec(classBody);
|
|
423
|
+
if (!m) return '';
|
|
424
|
+
const open = classBody.indexOf('{', m.index + m[0].length - 1);
|
|
425
|
+
if (open === -1) return '';
|
|
426
|
+
const close = matchClosingBrace(classBody, open + 1);
|
|
427
|
+
return close === -1 ? '' : classBody.slice(open + 1, close);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Find browser-only globals and HTMLElement `this.<member>` accesses in a
|
|
432
|
+
* (redacted) method body. Returns one entry per distinct member.
|
|
433
|
+
* @param {string} code
|
|
434
|
+
* @returns {{ member: string, kind: string }[]}
|
|
435
|
+
*/
|
|
436
|
+
function findBrowserMemberUses(code) {
|
|
437
|
+
// The class body arrives template-redacted, but `redactStringsAndTemplates`
|
|
438
|
+
// keeps single/double-quoted string CONTENT (real specifiers ride strings).
|
|
439
|
+
// Blank that too so a browser word inside a string literal (e.g. a label
|
|
440
|
+
// `'open the document'`) is not mistaken for a real global access.
|
|
441
|
+
code = code
|
|
442
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, (s) => `'${' '.repeat(Math.max(0, s.length - 2))}'`)
|
|
443
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, (s) => `"${' '.repeat(Math.max(0, s.length - 2))}"`);
|
|
444
|
+
const out = [];
|
|
445
|
+
const seen = new Set();
|
|
446
|
+
const gRe = new RegExp(`(?<![.\\w$])(${BROWSER_GLOBALS.join('|')})\\b`, 'g');
|
|
447
|
+
let m;
|
|
448
|
+
while ((m = gRe.exec(code)) !== null) {
|
|
449
|
+
if (seen.has(m[1])) continue;
|
|
450
|
+
seen.add(m[1]);
|
|
451
|
+
out.push({ member: m[1], kind: 'a browser global' });
|
|
452
|
+
}
|
|
453
|
+
const hRe = new RegExp(`\\bthis\\.(${HTMLELEMENT_MEMBERS.join('|')})\\b`, 'g');
|
|
454
|
+
while ((m = hRe.exec(code)) !== null) {
|
|
455
|
+
const key = `this.${m[1]}`;
|
|
456
|
+
if (seen.has(key)) continue;
|
|
457
|
+
seen.add(key);
|
|
458
|
+
out.push({ member: key, kind: 'an HTMLElement member' });
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
|
|
392
463
|
/**
|
|
393
464
|
* Scan a webjs app directory and report convention violations.
|
|
394
465
|
*
|
|
@@ -557,6 +628,31 @@ export async function checkConventions(appDir, opts) {
|
|
|
557
628
|
}
|
|
558
629
|
}
|
|
559
630
|
|
|
631
|
+
// --- Rule: no-browser-globals-in-render ---
|
|
632
|
+
// The SSR pipeline runs the constructor (`new Cls()`) and calls `render()`
|
|
633
|
+
// on a bare server-side class with no DOM. A browser global or an
|
|
634
|
+
// HTMLElement member on `this` touched there throws at SSR time. Those
|
|
635
|
+
// belong in connectedCallback / lifecycle hooks, which SSR never calls.
|
|
636
|
+
if (isRuleEnabled('no-browser-globals-in-render', overrides)) {
|
|
637
|
+
for (const { rel, scan } of files) {
|
|
638
|
+
if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
|
|
639
|
+
for (const body of extractWebComponentClassBodies(scan)) {
|
|
640
|
+
for (const method of ['constructor', 'render']) {
|
|
641
|
+
const code = methodBodyOf(body, method);
|
|
642
|
+
if (!code) continue;
|
|
643
|
+
for (const { member, kind } of findBrowserMemberUses(code)) {
|
|
644
|
+
violations.push({
|
|
645
|
+
rule: 'no-browser-globals-in-render',
|
|
646
|
+
file: rel,
|
|
647
|
+
message: `\`${member}\` (${kind}) is used in ${method}(), which runs during SSR where it is not available, so it throws and the component fails to server-render.`,
|
|
648
|
+
fix: `Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls. Seed first-paint defaults in the constructor only from server-known inputs (attributes / props), then refine in connectedCallback by writing to a signal.`,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
560
656
|
// --- Rule: no-server-env-in-components ---
|
|
561
657
|
// Catches `process.env.X` reads in component files where X is not a
|
|
562
658
|
// WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
|
package/src/component-elision.js
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
extractWebComponentClassBodies,
|
|
42
42
|
matchClosingBrace,
|
|
43
43
|
redactStringsAndTemplates,
|
|
44
|
+
maskComments,
|
|
44
45
|
} from './js-scan.js';
|
|
45
46
|
import { transitiveDeps } from './module-graph.js';
|
|
46
47
|
|
|
@@ -553,9 +554,13 @@ function topLevelPropertyValues(obj) {
|
|
|
553
554
|
export function extractRenderedTags(src) {
|
|
554
555
|
/** @type {Set<string>} */
|
|
555
556
|
const tags = new Set();
|
|
557
|
+
// Mask comments first so a `<some-tag>` written in a doc comment is not read
|
|
558
|
+
// as a rendered tag (#179). String and template content is kept, so real tags
|
|
559
|
+
// inside `html` templates are still found.
|
|
560
|
+
const masked = maskComments(src);
|
|
556
561
|
const re = /<([a-z][a-z0-9]*-[a-z0-9-]*)\b/g;
|
|
557
562
|
let m;
|
|
558
|
-
while ((m = re.exec(
|
|
563
|
+
while ((m = re.exec(masked)) !== null) tags.add(m[1]);
|
|
559
564
|
return tags;
|
|
560
565
|
}
|
|
561
566
|
|
|
@@ -656,15 +661,24 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
656
661
|
continue;
|
|
657
662
|
}
|
|
658
663
|
if (typeof src !== 'string') continue;
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
664
|
+
// Mask comments once for every signal scan below (#179): a `<tag>`, an
|
|
665
|
+
// `@event`, a browser global, an `import`, or a `whenDefined` written in a
|
|
666
|
+
// comment must not register as a real signal. String and template content
|
|
667
|
+
// is kept, so a real rendered tag, a real `@click=${}` in an html template,
|
|
668
|
+
// and a real `whenDefined('tag')` (the tag rides a string) still match.
|
|
669
|
+
// (`importsSideEffectNonCorePackage` / `hasModuleScopeSideEffect` /
|
|
670
|
+
// `analyzeComponentSource` also redact strings/templates internally; running
|
|
671
|
+
// them on the comment-masked source just additionally drops comment prose.)
|
|
672
|
+
const masked = maskComments(src);
|
|
673
|
+
fileTags.set(file, extractRenderedTags(masked));
|
|
674
|
+
if (importsReactivePrimitive(masked)) reactiveFiles.add(file);
|
|
675
|
+
if (importsClientRouter(masked)) clientRouterFiles.add(file);
|
|
676
|
+
if (EVENT_BINDING_RE.test(masked) || EVENT_PROP_RE.test(masked) ||
|
|
677
|
+
importsSideEffectNonCorePackage(masked) || CLIENT_GLOBAL_RE.test(masked) ||
|
|
678
|
+
hasModuleScopeSideEffect(masked)) {
|
|
665
679
|
clientGlobalOrBareFiles.add(file);
|
|
666
680
|
}
|
|
667
|
-
if (componentFiles.has(file) && analyzeComponentSource(
|
|
681
|
+
if (componentFiles.has(file) && analyzeComponentSource(masked).interactive) {
|
|
668
682
|
mustShip.add(file);
|
|
669
683
|
}
|
|
670
684
|
// Cross-module registration observation (#169): if THIS module observes
|
|
@@ -673,13 +687,13 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
673
687
|
// file. Resolution against tagToFile / classToFile happens after the loop
|
|
674
688
|
// (all components are known up front, but we collect here while we hold
|
|
675
689
|
// each source). Verdict-safe: only ever forces MORE components to ship.
|
|
676
|
-
for (const m of
|
|
690
|
+
for (const m of masked.matchAll(WHEN_DEFINED_RE)) {
|
|
677
691
|
const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
678
692
|
}
|
|
679
|
-
for (const m of
|
|
693
|
+
for (const m of masked.matchAll(TAG_DEFINED_RE)) {
|
|
680
694
|
const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
681
695
|
}
|
|
682
|
-
for (const m of
|
|
696
|
+
for (const m of masked.matchAll(INSTANCEOF_RE)) {
|
|
683
697
|
const f = classToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
684
698
|
}
|
|
685
699
|
}
|
package/src/dev.js
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
import { defaultLogger } from './logger.js';
|
|
59
59
|
import { withRequest } from './context.js';
|
|
60
60
|
import { attachWebSocket } from './websocket.js';
|
|
61
|
-
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile } from './vendor.js';
|
|
61
|
+
import { scanBareImports, resolveVendorImports, serveDownloadedBundle, clearVendorCache, hasVendorPin, readPinFile, prunePinToReachable } from './vendor.js';
|
|
62
62
|
import { buildModuleGraph, transitiveDeps, reachableFromEntries, resolveImport } from './module-graph.js';
|
|
63
63
|
import { primeComponentRegistry, findOrphanComponents, scanComponents } from './component-scanner.js';
|
|
64
64
|
import { analyzeElision, elideImportsFromSource } from './component-elision.js';
|
|
@@ -105,10 +105,13 @@ const MIME = {
|
|
|
105
105
|
* lint rules catch these at edit time. webjs is buildless end-to-end:
|
|
106
106
|
* there is no bundler fallback.
|
|
107
107
|
*
|
|
108
|
-
*
|
|
108
|
+
* The transformed bytes are cached per request handler in `state.tsCache`
|
|
109
|
+
* (a `Map<string, { mtimeMs, code, map }>`), bounded to `TS_CACHE_MAX`
|
|
110
|
+
* entries. The cache is per-handler rather than module-global because the
|
|
111
|
+
* cached code bakes in that handler's elision verdict, so two handlers for
|
|
112
|
+
* the same app with different elision settings must not share it.
|
|
109
113
|
*/
|
|
110
114
|
const TS_CACHE_MAX = 500;
|
|
111
|
-
const TS_CACHE = new Map();
|
|
112
115
|
|
|
113
116
|
/**
|
|
114
117
|
* Auto-load `<appDir>/.env` into `process.env` once at boot. Mirrors
|
|
@@ -148,16 +151,40 @@ function loadAppEnv(appDir) {
|
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
/**
|
|
151
|
-
* Read the
|
|
152
|
-
* `
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
154
|
+
* Read the `WEBJS_ELIDE` environment override, if set.
|
|
155
|
+
* `0` / `false` / `off` / `no` (case-insensitive) force elision OFF;
|
|
156
|
+
* `1` / `true` / `on` / `yes` force it ON. Any other value, or an unset
|
|
157
|
+
* variable, returns `undefined` so the caller falls through to the
|
|
158
|
+
* `package.json` switch. The env override is the deploy-time / ops escape
|
|
159
|
+
* hatch: force-disable elision to rule it out while debugging a wrong-strip
|
|
160
|
+
* without editing committed code, or force-enable it regardless of an
|
|
161
|
+
* app's `package.json`. It is also the seam the differential elision test
|
|
162
|
+
* uses to render the same app on and off in one process.
|
|
163
|
+
* @returns {boolean | undefined}
|
|
164
|
+
*/
|
|
165
|
+
function elideEnvOverride() {
|
|
166
|
+
const raw = process.env.WEBJS_ELIDE;
|
|
167
|
+
if (raw == null || raw === '') return undefined;
|
|
168
|
+
const v = String(raw).trim().toLowerCase();
|
|
169
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
170
|
+
if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Read the project-level elision switch.
|
|
176
|
+
* Precedence: the `WEBJS_ELIDE` env override wins when set, otherwise the
|
|
177
|
+
* `package.json` `{ "webjs": { "elide": false } }` switch disables
|
|
178
|
+
* display-only and inert-route elision app-wide (everything ships, like
|
|
179
|
+
* before the feature existed). Any other value, or an absent key, leaves
|
|
180
|
+
* elision enabled (the default). Re-read on every rebuild so toggling
|
|
181
|
+
* either control takes effect without a server restart.
|
|
157
182
|
* @param {string} appDir
|
|
158
183
|
* @returns {Promise<boolean>}
|
|
159
184
|
*/
|
|
160
|
-
async function readElideEnabled(appDir) {
|
|
185
|
+
export async function readElideEnabled(appDir) {
|
|
186
|
+
const override = elideEnvOverride();
|
|
187
|
+
if (override !== undefined) return override;
|
|
161
188
|
try {
|
|
162
189
|
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
163
190
|
if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
|
|
@@ -267,6 +294,12 @@ export async function createRequestHandler(opts) {
|
|
|
267
294
|
elidableComponents: new Set(),
|
|
268
295
|
inertRouteModules: new Set(),
|
|
269
296
|
browserBoundFiles: null,
|
|
297
|
+
// Transformed-source cache (stripped TS + applied elision). Per-handler,
|
|
298
|
+
// NOT module-global: the cached bytes bake in THIS handler's elision
|
|
299
|
+
// verdict, so two handlers for the same app with different elision
|
|
300
|
+
// settings (a multi-tenant embedder, or the differential elision test)
|
|
301
|
+
// must not share it, or the second would serve the first's elided source.
|
|
302
|
+
tsCache: new Map(),
|
|
270
303
|
};
|
|
271
304
|
|
|
272
305
|
// All whole-app analysis is built lazily on the first request, memoized so
|
|
@@ -282,11 +315,14 @@ export async function createRequestHandler(opts) {
|
|
|
282
315
|
// platform's traffic and probes are the retry loop. `readyError` holds a
|
|
283
316
|
// propagating analysis failure so /__webjs/ready can report it.
|
|
284
317
|
let analysisDone = false; // deterministic analysis complete (readiness gate)
|
|
285
|
-
// A pinned app
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
318
|
+
// A pinned app applied its FULL vendor map and published the build id at boot
|
|
319
|
+
// (above). The deferred vendor stage still runs once (and after every rebuild)
|
|
320
|
+
// to PRUNE that map to the elision-reachable specifiers, so a pinned app serves
|
|
321
|
+
// the same map an unpinned one does (#197); it does not re-publish the build id
|
|
322
|
+
// (the boot hash stays the deploy fingerprint). An unpinned app starts false and
|
|
323
|
+
// resolves live on the first request.
|
|
324
|
+
let vendorResolved = false; // vendor map fully resolved/pruned (or permanently tolerated)
|
|
325
|
+
let vendorAttemptedOnce = false; // the first (blocking) vendor attempt has run
|
|
290
326
|
let vendorGen = 0; // bumped on rebuild; a stale resolve cannot flip vendorResolved
|
|
291
327
|
let readyDone = false; // mirrors analysisDone; the /__webjs/ready gate
|
|
292
328
|
/** @type {unknown} */
|
|
@@ -314,7 +350,7 @@ export async function createRequestHandler(opts) {
|
|
|
314
350
|
// served build id stays empty (reload-safe), so no navigation hard-reloads.
|
|
315
351
|
if (analysisDone && vendorAttemptedOnce) {
|
|
316
352
|
const gen = vendorGen;
|
|
317
|
-
resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; publishBuildId(); } }).catch(() => {});
|
|
353
|
+
resolveAndApplyVendor().then((ok) => { if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); } }).catch(() => {});
|
|
318
354
|
return;
|
|
319
355
|
}
|
|
320
356
|
// Otherwise run the (single-flighted) full warm: the analysis, then the
|
|
@@ -372,7 +408,11 @@ export async function createRequestHandler(opts) {
|
|
|
372
408
|
// the importmap is now authoritatively final, so publish the build
|
|
373
409
|
// id: from here every response advertises the same stable value and
|
|
374
410
|
// the client router's deploy detection works without warmup drift.
|
|
375
|
-
|
|
411
|
+
// A pinned app published the build id at boot (hash of the committed
|
|
412
|
+
// pin) and the prune only shrinks the served map, so do NOT re-publish
|
|
413
|
+
// (that would drift the id mid-process). An unpinned app publishes its
|
|
414
|
+
// now-final live map here.
|
|
415
|
+
if (ok && gen === vendorGen) { vendorResolved = true; if (!bootVendorPinned) publishBuildId(); }
|
|
376
416
|
}
|
|
377
417
|
// Readiness reflects a FULLY warm instance: the deterministic analysis
|
|
378
418
|
// AND the first vendor attempt have both completed (note: completed,
|
|
@@ -423,9 +463,20 @@ export async function createRequestHandler(opts) {
|
|
|
423
463
|
if (vendorResolveInFlight) return vendorResolveInFlight;
|
|
424
464
|
vendorResolveInFlight = (async () => {
|
|
425
465
|
try {
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
466
|
+
const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules]));
|
|
467
|
+
const v = await resolveVendorImports(appDir, scan);
|
|
468
|
+
let { imports, integrity } = v;
|
|
469
|
+
if (bootVendorPinned) {
|
|
470
|
+
// resolveVendorImports returns a committed pin VERBATIM (it never runs
|
|
471
|
+
// the scan for a pinned app). Prune it to the elision-reachable
|
|
472
|
+
// specifiers so a pinned app serves the same map an unpinned one does
|
|
473
|
+
// (#197): an elided-only dep like dayjs is dropped. One scan; the pin
|
|
474
|
+
// path skipped it. This runs on the first warm AND after every rebuild,
|
|
475
|
+
// so the pruned map is the single source of truth.
|
|
476
|
+
const reachable = await scan();
|
|
477
|
+
({ imports, integrity } = prunePinToReachable(imports, integrity, reachable));
|
|
478
|
+
}
|
|
479
|
+
await setVendorEntries(imports, integrity);
|
|
429
480
|
return v.ok;
|
|
430
481
|
} catch (e) {
|
|
431
482
|
logger.error?.(`[webjs] vendor resolve failed (will retry on the next request):`, e);
|
|
@@ -482,12 +533,12 @@ export async function createRequestHandler(opts) {
|
|
|
482
533
|
// it so routing reflects added/removed route files immediately.
|
|
483
534
|
state.routeTable = await buildRouteTable(appDir);
|
|
484
535
|
clearVendorCache();
|
|
485
|
-
|
|
536
|
+
state.tsCache.clear();
|
|
486
537
|
// Invalidate the lazy analysis; the next request rebuilds the graph,
|
|
487
538
|
// component scan, gate, action index, middleware, elision, and vendor map.
|
|
488
539
|
// Wait out any in-flight build first so it cannot commit stale results
|
|
489
540
|
// after the reset. A dependency edit can flip an elision verdict without
|
|
490
|
-
// changing an importer's mtime, hence the
|
|
541
|
+
// changing an importer's mtime, hence the state.tsCache.clear above.
|
|
491
542
|
if (readyInFlight) { try { await readyInFlight; } catch {} }
|
|
492
543
|
// Bump the vendor generation so a vendor resolve still in flight from the
|
|
493
544
|
// previous build cannot flip vendorResolved against the fresh state.
|
|
@@ -1010,7 +1061,7 @@ async function handleCore(req, ctx) {
|
|
|
1010
1061
|
appDir,
|
|
1011
1062
|
};
|
|
1012
1063
|
if (/\.m?ts$/.test(abs)) {
|
|
1013
|
-
return tsResponse(abs, dev, elideOpts);
|
|
1064
|
+
return tsResponse(abs, dev, elideOpts, state.tsCache);
|
|
1014
1065
|
}
|
|
1015
1066
|
if (/\.m?js$/.test(abs)) {
|
|
1016
1067
|
return jsModuleResponse(abs, dev, elideOpts);
|
|
@@ -1394,17 +1445,21 @@ async function stripTs(source, _abs) {
|
|
|
1394
1445
|
|
|
1395
1446
|
/**
|
|
1396
1447
|
* Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
|
|
1397
|
-
* Result is cached by mtime
|
|
1398
|
-
* file edit invalidates naturally. `elideOpts`
|
|
1399
|
-
* side-effect imports of display-only components from
|
|
1448
|
+
* Result is cached by mtime in the handler's own `cache` so subsequent
|
|
1449
|
+
* requests are instant; a file edit invalidates naturally. `elideOpts`
|
|
1450
|
+
* additionally strips side-effect imports of display-only components from
|
|
1451
|
+
* the served code, which is exactly why `cache` is the per-handler
|
|
1452
|
+
* `state.tsCache` and not a module-global: the cached bytes bake in this
|
|
1453
|
+
* handler's elision verdict.
|
|
1400
1454
|
*
|
|
1401
1455
|
* @param {string} abs
|
|
1402
1456
|
* @param {boolean} dev
|
|
1403
1457
|
* @param {{ moduleGraph: any, elidableComponents: Set<string>|undefined, appDir: string }} [elideOpts]
|
|
1458
|
+
* @param {Map<string, { mtimeMs: number, code: string, map: string | null }>} cache the handler's `state.tsCache`
|
|
1404
1459
|
*/
|
|
1405
|
-
async function tsResponse(abs, dev, elideOpts) {
|
|
1460
|
+
async function tsResponse(abs, dev, elideOpts, cache) {
|
|
1406
1461
|
const st = await stat(abs);
|
|
1407
|
-
const cached =
|
|
1462
|
+
const cached = cache.get(abs);
|
|
1408
1463
|
if (cached && cached.mtimeMs === st.mtimeMs) {
|
|
1409
1464
|
return new Response(cached.code, {
|
|
1410
1465
|
headers: {
|
|
@@ -1457,11 +1512,11 @@ async function tsResponse(abs, dev, elideOpts) {
|
|
|
1457
1512
|
);
|
|
1458
1513
|
}
|
|
1459
1514
|
// Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
|
|
1460
|
-
if (
|
|
1461
|
-
const oldest =
|
|
1462
|
-
|
|
1515
|
+
if (cache.size >= TS_CACHE_MAX) {
|
|
1516
|
+
const oldest = cache.keys().next().value;
|
|
1517
|
+
cache.delete(oldest);
|
|
1463
1518
|
}
|
|
1464
|
-
|
|
1519
|
+
cache.set(abs, { mtimeMs: st.mtimeMs, code, map: null });
|
|
1465
1520
|
return new Response(code, {
|
|
1466
1521
|
headers: {
|
|
1467
1522
|
'content-type': 'application/javascript; charset=utf-8',
|
package/src/js-scan.js
CHANGED
|
@@ -221,6 +221,111 @@ export function redactStringsAndTemplates(src) {
|
|
|
221
221
|
return out;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Blank ONLY comments, keeping string AND template-literal content verbatim
|
|
226
|
+
* (position-preserving: same length, newlines kept). The sibling
|
|
227
|
+
* `redactStringsAndTemplates` blanks templates too, which is wrong for callers
|
|
228
|
+
* that need to read inside `html` templates (the elision render-tag scanner) or
|
|
229
|
+
* inside string arguments (`whenDefined('tag')`). This keeps both and removes
|
|
230
|
+
* only comment text, so prose in a comment cannot be read as a real signal
|
|
231
|
+
* (issue #179). It reuses the same regex-versus-division and tagged-template
|
|
232
|
+
* disambiguation so a `//` inside a string/template/regex is never mistaken for
|
|
233
|
+
* a comment.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} src
|
|
236
|
+
* @returns {string} src with comment bodies blanked, everything else verbatim
|
|
237
|
+
*/
|
|
238
|
+
export function maskComments(src) {
|
|
239
|
+
const n = src.length;
|
|
240
|
+
let out = '';
|
|
241
|
+
let i = 0;
|
|
242
|
+
let lastSig = '';
|
|
243
|
+
let lastWord = '';
|
|
244
|
+
let lastWordIsProp = false;
|
|
245
|
+
let lastWasIncDec = false;
|
|
246
|
+
const markValue = () => { lastSig = 'x'; lastWord = ''; lastWordIsProp = false; lastWasIncDec = false; };
|
|
247
|
+
const isRegex = () => {
|
|
248
|
+
if (lastSig === '') return true;
|
|
249
|
+
if (lastSig === ')' || lastSig === ']') return false;
|
|
250
|
+
if (lastSig === "'" || lastSig === '"' || lastSig === '`') return false;
|
|
251
|
+
if (lastWasIncDec) return false;
|
|
252
|
+
if (/[\w$]/.test(lastSig)) return !lastWordIsProp && REGEX_PRECEDING_KEYWORDS.has(lastWord);
|
|
253
|
+
return true;
|
|
254
|
+
};
|
|
255
|
+
// Comments: blank the body (keep the `//` / `/* */` delimiters and newlines).
|
|
256
|
+
const scanLineComment = () => { out += '//'; i += 2; while (i < n && src[i] !== '\n') { out += ' '; i++; } };
|
|
257
|
+
const scanBlockComment = () => {
|
|
258
|
+
out += '/*'; i += 2;
|
|
259
|
+
while (i < n) {
|
|
260
|
+
if (src[i] === '*' && src[i + 1] === '/') { out += '*/'; i += 2; return; }
|
|
261
|
+
out += src[i] === '\n' ? '\n' : ' '; i++;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
// String / template / regex: copy verbatim, but lex correctly so a `//` or
|
|
265
|
+
// `/*` inside them is not treated as a comment.
|
|
266
|
+
const scanString = (q) => {
|
|
267
|
+
out += q; i++;
|
|
268
|
+
while (i < n) {
|
|
269
|
+
if (src[i] === '\\' && i + 1 < n) { out += src[i] + src[i + 1]; i += 2; continue; }
|
|
270
|
+
if (src[i] === q) { out += q; i++; break; }
|
|
271
|
+
if (src[i] === '\n') { out += '\n'; i++; break; }
|
|
272
|
+
out += src[i]; i++;
|
|
273
|
+
}
|
|
274
|
+
markValue();
|
|
275
|
+
};
|
|
276
|
+
const scanRegex = () => {
|
|
277
|
+
out += '/'; i++;
|
|
278
|
+
let inClass = false;
|
|
279
|
+
while (i < n) {
|
|
280
|
+
const d = src[i];
|
|
281
|
+
if (d === '\\' && i + 1 < n) { out += d + src[i + 1]; i += 2; continue; }
|
|
282
|
+
if (d === '\n') break;
|
|
283
|
+
if (d === '[') inClass = true;
|
|
284
|
+
else if (d === ']') inClass = false;
|
|
285
|
+
else if (d === '/' && !inClass) { out += '/'; i++; break; }
|
|
286
|
+
out += d; i++;
|
|
287
|
+
}
|
|
288
|
+
markValue();
|
|
289
|
+
};
|
|
290
|
+
const scanTemplate = () => {
|
|
291
|
+
out += '`'; i++;
|
|
292
|
+
while (i < n) {
|
|
293
|
+
const c = src[i];
|
|
294
|
+
if (c === '\\' && i + 1 < n) { out += c + src[i + 1]; i += 2; continue; }
|
|
295
|
+
if (c === '`') { out += '`'; i++; break; }
|
|
296
|
+
if (c === '$' && src[i + 1] === '{') { out += '${'; i += 2; scanCode(true); if (i < n && src[i] === '}') { out += '}'; i++; } continue; }
|
|
297
|
+
out += c; i++;
|
|
298
|
+
}
|
|
299
|
+
markValue();
|
|
300
|
+
};
|
|
301
|
+
function scanCode(stopHole) {
|
|
302
|
+
let brace = 0;
|
|
303
|
+
while (i < n) {
|
|
304
|
+
const c = src[i], next = src[i + 1];
|
|
305
|
+
if (stopHole && c === '}' && brace === 0) return;
|
|
306
|
+
if (c === '/' && next === '/') { scanLineComment(); continue; }
|
|
307
|
+
if (c === '/' && next === '*') { scanBlockComment(); continue; }
|
|
308
|
+
if (c === '/' && isRegex()) { scanRegex(); continue; }
|
|
309
|
+
if (c === "'" || c === '"') { scanString(c); continue; }
|
|
310
|
+
if (c === '`') { scanTemplate(); continue; }
|
|
311
|
+
if (c === '{') { brace++; lastSig = '{'; lastWord = ''; lastWasIncDec = false; out += c; i++; continue; }
|
|
312
|
+
if (c === '}') { brace--; lastSig = '}'; lastWord = ''; lastWasIncDec = false; out += c; i++; continue; }
|
|
313
|
+
if (/[A-Za-z_$]/.test(c)) {
|
|
314
|
+
const prop = lastSig === '.';
|
|
315
|
+
let w = '';
|
|
316
|
+
while (i < n && /[\w$]/.test(src[i])) { w += src[i]; out += src[i]; i++; }
|
|
317
|
+
lastWord = w; lastSig = w[w.length - 1]; lastWordIsProp = prop; lastWasIncDec = false;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (/\s/.test(c)) { out += c; i++; continue; }
|
|
321
|
+
lastWasIncDec = (c === '+' || c === '-') && c === lastSig;
|
|
322
|
+
lastSig = c; lastWord = ''; out += c; i++;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
scanCode(false);
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
224
329
|
/**
|
|
225
330
|
* Extract the body of every `class … extends WebComponent { … }` block.
|
|
226
331
|
* Brace-counts to handle nested template literals, methods, and arrow
|
package/src/vendor.js
CHANGED
|
@@ -1308,6 +1308,55 @@ function maxSemverVersion(versions) {
|
|
|
1308
1308
|
* on the unpinned path (so a pinned app never pays the whole-app walk).
|
|
1309
1309
|
* @returns {Promise<{ imports: Record<string, string>, integrity: Record<string, string> }>}
|
|
1310
1310
|
*/
|
|
1311
|
+
/**
|
|
1312
|
+
* Base package of a bare specifier: `dayjs` -> `dayjs`,
|
|
1313
|
+
* `dayjs/plugin/utc` -> `dayjs`, `@scope/pkg/sub` -> `@scope/pkg`.
|
|
1314
|
+
*
|
|
1315
|
+
* @param {string} spec
|
|
1316
|
+
* @returns {string}
|
|
1317
|
+
*/
|
|
1318
|
+
function basePackage(spec) {
|
|
1319
|
+
const parts = spec.split('/');
|
|
1320
|
+
return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Prune a pinned import map to the vendor specifiers still reachable from
|
|
1325
|
+
* NON-elided modules. A committed pin is the whole map, but elision can make
|
|
1326
|
+
* a pinned package unreachable (its only importer is a display-only component
|
|
1327
|
+
* that ships no JS, e.g. dayjs via the blog's vendor-badge). The live-resolve
|
|
1328
|
+
* path prunes such a package by excluding elided components from the bare-
|
|
1329
|
+
* import scan; this brings the pinned path to the same result, so a pinned app
|
|
1330
|
+
* and an unpinned app serve the same import map (issue #197).
|
|
1331
|
+
*
|
|
1332
|
+
* Keeps an entry when its specifier is reachable, OR when its base package is
|
|
1333
|
+
* the base of any reachable specifier (so a pinned base entry `dayjs` survives
|
|
1334
|
+
* when code imports `dayjs/plugin/utc`, and vice versa). Integrity hashes for
|
|
1335
|
+
* dropped URLs are pruned too.
|
|
1336
|
+
*
|
|
1337
|
+
* @param {Record<string, string>} imports pin entries (specifier -> URL)
|
|
1338
|
+
* @param {Record<string, string>} integrity SRI hashes keyed by URL
|
|
1339
|
+
* @param {Set<string>} reachable bare specifiers used by non-elided modules
|
|
1340
|
+
* @returns {{ imports: Record<string, string>, integrity: Record<string, string> }}
|
|
1341
|
+
*/
|
|
1342
|
+
export function prunePinToReachable(imports, integrity, reachable) {
|
|
1343
|
+
const reachableBases = new Set([...reachable].map(basePackage));
|
|
1344
|
+
/** @type {Record<string, string>} */
|
|
1345
|
+
const keptImports = {};
|
|
1346
|
+
for (const [spec, url] of Object.entries(imports || {})) {
|
|
1347
|
+
if (reachable.has(spec) || reachableBases.has(basePackage(spec))) {
|
|
1348
|
+
keptImports[spec] = url;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
const keptUrls = new Set(Object.values(keptImports));
|
|
1352
|
+
/** @type {Record<string, string>} */
|
|
1353
|
+
const keptIntegrity = {};
|
|
1354
|
+
for (const [url, hash] of Object.entries(integrity || {})) {
|
|
1355
|
+
if (keptUrls.has(url)) keptIntegrity[url] = hash;
|
|
1356
|
+
}
|
|
1357
|
+
return { imports: keptImports, integrity: keptIntegrity };
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1311
1360
|
export async function resolveVendorImports(appDir, getBareImports) {
|
|
1312
1361
|
const file = await readPinFile(appDir);
|
|
1313
1362
|
// A committed pin file IS the import map. The whole-app bare-import scan is
|