@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/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
@@ -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(src)) !== null) tags.add(m[1]);
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
- fileTags.set(file, extractRenderedTags(src));
660
- if (importsReactivePrimitive(src)) reactiveFiles.add(file);
661
- if (importsClientRouter(src)) clientRouterFiles.add(file);
662
- if (EVENT_BINDING_RE.test(src) || EVENT_PROP_RE.test(src) ||
663
- importsSideEffectNonCorePackage(src) || CLIENT_GLOBAL_RE.test(src) ||
664
- hasModuleScopeSideEffect(src)) {
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(src).interactive) {
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 src.matchAll(WHEN_DEFINED_RE)) {
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 src.matchAll(TAG_DEFINED_RE)) {
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 src.matchAll(INSTANCEOF_RE)) {
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
- * @type {Map<string, { mtimeMs: number, code: string, map: string | null }>}
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 project-level elision switch from `package.json`.
152
- * `{ "webjs": { "elide": false } }` disables display-only and inert-route
153
- * elision app-wide (everything ships, like before the feature existed).
154
- * Any other value, or an absent key, leaves elision enabled (the default).
155
- * Re-read on every rebuild so toggling the switch takes effect without a
156
- * server restart.
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 already resolved + published its vendor map at boot (above), so
286
- // the deferred vendor stage is a no-op from the start; an unpinned app starts
287
- // false and resolves on the first request.
288
- let vendorResolved = bootVendorPinned; // vendor map fully resolved (or permanently tolerated)
289
- let vendorAttemptedOnce = bootVendorPinned; // the first (blocking) vendor attempt has run
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
- if (ok && gen === vendorGen) { vendorResolved = true; publishBuildId(); }
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 v = await resolveVendorImports(appDir,
427
- () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules])));
428
- await setVendorEntries(v.imports, v.integrity);
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
- TS_CACHE.clear();
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 TS_CACHE.clear above.
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 so subsequent requests are instant; a
1398
- * file edit invalidates naturally. `elideOpts` additionally strips
1399
- * side-effect imports of display-only components from the served code.
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 = TS_CACHE.get(abs);
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 (TS_CACHE.size >= TS_CACHE_MAX) {
1461
- const oldest = TS_CACHE.keys().next().value;
1462
- TS_CACHE.delete(oldest);
1515
+ if (cache.size >= TS_CACHE_MAX) {
1516
+ const oldest = cache.keys().next().value;
1517
+ cache.delete(oldest);
1463
1518
  }
1464
- TS_CACHE.set(abs, { mtimeMs: st.mtimeMs, code, map: null });
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