@webjsdev/server 0.8.6 → 0.8.8
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 +9 -4
- package/src/broadcast.js +5 -1
- package/src/check.js +96 -0
- package/src/component-elision.js +25 -11
- package/src/dev.js +58 -21
- package/src/js-scan.js +105 -0
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -49,8 +49,13 @@ async function unsign(input, secret) {
|
|
|
49
49
|
const idx = input.lastIndexOf('.');
|
|
50
50
|
if (idx < 1) return null;
|
|
51
51
|
const value = input.slice(0, idx);
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// `unb64url` -> `atob` throws on non-base64 input. A malformed signature
|
|
53
|
+
// (a corrupted or attacker-supplied cookie) must read as "not signed by
|
|
54
|
+
// us", not crash the request. Mirrors the guard in `decodeJwt`.
|
|
55
|
+
try {
|
|
56
|
+
const ok = await crypto.subtle.verify('HMAC', await hmacKey(secret), unb64url(input.slice(idx + 1)), enc.encode(value));
|
|
57
|
+
return ok ? value : null;
|
|
58
|
+
} catch { return null; }
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
function randomId() {
|
|
@@ -86,7 +91,7 @@ function clearCookie(name) {
|
|
|
86
91
|
// -- JWT --------------------------------------------------------------------
|
|
87
92
|
|
|
88
93
|
/** @param {Record<string,unknown>} payload @param {string} secret */
|
|
89
|
-
async function encodeJwt(payload, secret) {
|
|
94
|
+
export async function encodeJwt(payload, secret) {
|
|
90
95
|
const h = b64url(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
|
|
91
96
|
const p = b64url(enc.encode(JSON.stringify(payload)));
|
|
92
97
|
const unsigned = `${h}.${p}`;
|
|
@@ -95,7 +100,7 @@ async function encodeJwt(payload, secret) {
|
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
/** @param {string} token @param {string} secret @returns {Promise<Record<string,unknown>|null>} */
|
|
98
|
-
async function decodeJwt(token, secret) {
|
|
103
|
+
export async function decodeJwt(token, secret) {
|
|
99
104
|
const parts = token.split('.');
|
|
100
105
|
if (parts.length !== 3) return null;
|
|
101
106
|
// `unb64url` → `atob` throws InvalidCharacterError on non-base64 input.
|
package/src/broadcast.js
CHANGED
|
@@ -55,7 +55,11 @@ export function broadcast(path, data, opts) {
|
|
|
55
55
|
const msg = typeof data === 'string' ? data : data.toString();
|
|
56
56
|
for (const ws of clients) {
|
|
57
57
|
if (opts?.except && ws === opts.except) continue;
|
|
58
|
-
if (ws.readyState
|
|
58
|
+
if (ws.readyState !== 1) continue;
|
|
59
|
+
// A socket can die between the readyState check and the send (or send
|
|
60
|
+
// can throw for other reasons). Isolate each send so one dead client
|
|
61
|
+
// cannot abort the fan-out to everyone after it in the set.
|
|
62
|
+
try { ws.send(msg); } catch { /* drop this client's frame; close handler removes it */ }
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
65
|
|
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
|
@@ -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
|
|
@@ -500,12 +533,12 @@ export async function createRequestHandler(opts) {
|
|
|
500
533
|
// it so routing reflects added/removed route files immediately.
|
|
501
534
|
state.routeTable = await buildRouteTable(appDir);
|
|
502
535
|
clearVendorCache();
|
|
503
|
-
|
|
536
|
+
state.tsCache.clear();
|
|
504
537
|
// Invalidate the lazy analysis; the next request rebuilds the graph,
|
|
505
538
|
// component scan, gate, action index, middleware, elision, and vendor map.
|
|
506
539
|
// Wait out any in-flight build first so it cannot commit stale results
|
|
507
540
|
// after the reset. A dependency edit can flip an elision verdict without
|
|
508
|
-
// changing an importer's mtime, hence the
|
|
541
|
+
// changing an importer's mtime, hence the state.tsCache.clear above.
|
|
509
542
|
if (readyInFlight) { try { await readyInFlight; } catch {} }
|
|
510
543
|
// Bump the vendor generation so a vendor resolve still in flight from the
|
|
511
544
|
// previous build cannot flip vendorResolved against the fresh state.
|
|
@@ -1028,7 +1061,7 @@ async function handleCore(req, ctx) {
|
|
|
1028
1061
|
appDir,
|
|
1029
1062
|
};
|
|
1030
1063
|
if (/\.m?ts$/.test(abs)) {
|
|
1031
|
-
return tsResponse(abs, dev, elideOpts);
|
|
1064
|
+
return tsResponse(abs, dev, elideOpts, state.tsCache);
|
|
1032
1065
|
}
|
|
1033
1066
|
if (/\.m?js$/.test(abs)) {
|
|
1034
1067
|
return jsModuleResponse(abs, dev, elideOpts);
|
|
@@ -1412,17 +1445,21 @@ async function stripTs(source, _abs) {
|
|
|
1412
1445
|
|
|
1413
1446
|
/**
|
|
1414
1447
|
* Serve a `.ts` / `.mts` source file as JavaScript via {@link stripTs}.
|
|
1415
|
-
* Result is cached by mtime
|
|
1416
|
-
* file edit invalidates naturally. `elideOpts`
|
|
1417
|
-
* 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.
|
|
1418
1454
|
*
|
|
1419
1455
|
* @param {string} abs
|
|
1420
1456
|
* @param {boolean} dev
|
|
1421
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`
|
|
1422
1459
|
*/
|
|
1423
|
-
async function tsResponse(abs, dev, elideOpts) {
|
|
1460
|
+
async function tsResponse(abs, dev, elideOpts, cache) {
|
|
1424
1461
|
const st = await stat(abs);
|
|
1425
|
-
const cached =
|
|
1462
|
+
const cached = cache.get(abs);
|
|
1426
1463
|
if (cached && cached.mtimeMs === st.mtimeMs) {
|
|
1427
1464
|
return new Response(cached.code, {
|
|
1428
1465
|
headers: {
|
|
@@ -1475,11 +1512,11 @@ async function tsResponse(abs, dev, elideOpts) {
|
|
|
1475
1512
|
);
|
|
1476
1513
|
}
|
|
1477
1514
|
// Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
|
|
1478
|
-
if (
|
|
1479
|
-
const oldest =
|
|
1480
|
-
|
|
1515
|
+
if (cache.size >= TS_CACHE_MAX) {
|
|
1516
|
+
const oldest = cache.keys().next().value;
|
|
1517
|
+
cache.delete(oldest);
|
|
1481
1518
|
}
|
|
1482
|
-
|
|
1519
|
+
cache.set(abs, { mtimeMs: st.mtimeMs, code, map: null });
|
|
1483
1520
|
return new Response(code, {
|
|
1484
1521
|
headers: {
|
|
1485
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
|