@webjsdev/server 0.8.17 → 0.8.18

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.17",
3
+ "version": "0.8.18",
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/asset-hash.js CHANGED
@@ -39,7 +39,9 @@
39
39
 
40
40
  import { createHash } from 'node:crypto';
41
41
  import { readFileSync } from 'node:fs';
42
- import { join, resolve, sep } from 'node:path';
42
+ import { extname, join, resolve, sep } from 'node:path';
43
+ import { redactStringsAndTemplates } from './js-scan.js';
44
+ import { resolveImport } from './module-graph.js';
43
45
 
44
46
  /**
45
47
  * Length of the emitted hex hash. 12 hex chars (48 bits) is plenty to
@@ -264,3 +266,155 @@ export function withAssetHash(url, basePath = '') {
264
266
  const sepChar = url.includes('?') ? '&' : '?';
265
267
  return `${url}${sepChar}v=${hash}`;
266
268
  }
269
+
270
+ /**
271
+ * Static `import` specifier with positional indices. Mirrors module-graph.js's
272
+ * `IMPORT_RE` (side-effect / default / namespace / named imports), but captures
273
+ * the quote (group 1) and specifier (group 2) so the `/d` flag yields the
274
+ * specifier's start/end for an in-place splice. Excludes dynamic `import(...)`
275
+ * (no whitespace after `import`) and `import.meta` (no quote), exactly like the
276
+ * graph scanner, so the rewrite set is the static-import set the modulepreload
277
+ * hints cover.
278
+ *
279
+ * @type {RegExp}
280
+ */
281
+ const IMPORT_SPEC_RE = /\bimport\s+(?:(?:[\w*{}\s,]+)\s+from\s+)?(['"])([^'"]+)\1/dg;
282
+
283
+ /** Match `export … from '…'` re-exports (mirrors module-graph's EXPORT_FROM_RE). @type {RegExp} */
284
+ const EXPORT_SPEC_RE = /\bexport\b[^'";]+?\sfrom\s+(['"])([^'"]+)\1/dg;
285
+
286
+ /** A server-file specifier: served as a stub at a bare URL, never preloaded. @type {RegExp} */
287
+ const SERVER_FILE_RE = /\.server\.(?:js|ts|mjs|mts)$/;
288
+
289
+ /** JS-ish specifier extensions that map to a `.ts`/`.mts` sibling on disk. @type {Set<string>} */
290
+ const JS_SPEC_EXTS = new Set(['.js', '.mjs', '.jsx', '.cjs']);
291
+
292
+ /**
293
+ * Normalize a relative import specifier's extension to the RESOLVED file's, so
294
+ * the URL the browser fetches equals the modulepreload href (which `ssr.js`
295
+ * derives from the resolved absolute path via `toUrlPath`, after `resolveImport`
296
+ * rewrites a `.js` specifier to its `.ts` sibling and resolves an extensionless
297
+ * one). Without this, an `import './x.js'` whose file is `x.ts` would be served
298
+ * `./x.js?v=H` (fetched as `/x.js?v=H`) while the preload is `/x.ts?v=H`: same
299
+ * hash, different path, so the preload is wasted and the file double-fetched.
300
+ *
301
+ * Conservative: only swaps a known JS-ish extension or appends to a genuinely
302
+ * extensionless basename; a directory specifier (`.` / `..` / trailing-slash)
303
+ * or an unrecognized extension is left untouched.
304
+ *
305
+ * @param {string} spec the author's relative specifier (no query)
306
+ * @param {string} abs the resolved absolute file path
307
+ * @returns {string}
308
+ */
309
+ function normalizeSpecToResolved(spec, abs) {
310
+ const absExt = extname(abs);
311
+ if (!absExt) return spec;
312
+ const specExt = extname(spec);
313
+ if (specExt === absExt) return spec;
314
+ const base = spec.slice(spec.lastIndexOf('/') + 1);
315
+ if (base === '' || base === '.' || base === '..') return spec; // directory import
316
+ if (specExt) return JS_SPEC_EXTS.has(specExt) ? spec.slice(0, -specExt.length) + absExt : spec;
317
+ return spec + absExt; // extensionless
318
+ }
319
+
320
+ /**
321
+ * Append `?v=<content-hash>` to every SAME-ORIGIN relative / root-absolute
322
+ * static-import specifier in a served module's source, so the URL the browser
323
+ * actually fetches matches the `?v=`-versioned `modulepreload` hint and boot
324
+ * specifier the framework emits for that file.
325
+ *
326
+ * The bug this fixes (#369): the boot script imports the entry modules with
327
+ * `?v=<hash>` and the head emits `<link rel=modulepreload href=".../x.ts?v=hash">`
328
+ * for every transitive module, but a layout/page imports its components with a
329
+ * BARE relative specifier (`import '../components/x.ts'`). The browser resolves
330
+ * a relative specifier against the importer's URL and a `?v` query is NOT
331
+ * inherited across that resolution, so it fetches the UN-versioned URL: a
332
+ * different cache key from the preload. Result: the preload is wasted, the
333
+ * module is downloaded a second time, and the served copy gets the 1h fallback
334
+ * cache header instead of `immutable`. Rewriting the specifier in the served
335
+ * source to carry the same `?v=<hash>` collapses both to one cache key (one
336
+ * fetch, preload used, immutable cached).
337
+ *
338
+ * The appended hash is `assetHashFor(resolvedTarget)`, the exact value
339
+ * `withAssetHash` computes for the modulepreload href of the same file (same
340
+ * absolute path -> same hash, elision-fingerprint fold included), so the two
341
+ * URLs are byte-identical by construction.
342
+ *
343
+ * The fetched URL is made to equal the preload href even when the specifier's
344
+ * extension differs from the file on disk: `normalizeSpecToResolved` rewrites a
345
+ * `.js`/extensionless specifier to the resolved `.ts` sibling (the form the
346
+ * preload, derived from the resolved path, uses), then `?v` is appended.
347
+ *
348
+ * Scope, mirroring the modulepreload set so the rewrite never diverges from it:
349
+ * - Only `.`-relative specifiers (`./`, `../`). The browser resolves these
350
+ * against the importer's own URL, which is already base-path-prefixed, so
351
+ * the rewrite is base-path-correct by construction. A `/`-root-absolute
352
+ * specifier is deliberately NOT versioned: it would miss the `webjs.basePath`
353
+ * prefix under a sub-path deploy (a pre-existing author-import limitation),
354
+ * and apps use relative imports. A BARE specifier (`@webjsdev/core`) is
355
+ * importmap-resolved and versioned at its importmap TARGET; rewriting it here
356
+ * would break the importmap key.
357
+ * - Only targets that resolve to a readable file UNDER `_appDir` (the servable
358
+ * same-origin root). A `.server.*` target is excluded: it serves as a stub
359
+ * at a bare URL and is never in the preload set, and its served bytes are
360
+ * generated (not the file bytes the hash covers).
361
+ * - A specifier already carrying a query is left as-is.
362
+ *
363
+ * Matching runs over a redaction mask with `blankStrings` on (NO literal body
364
+ * survives), so an `import`/`export … from` printed inside an `html\`\`` template
365
+ * OR a plain string literal is never rewritten (a plain-string body is NOT
366
+ * blanked by the default mask, so the stricter mask is required here to avoid
367
+ * splicing `?v` into the served string's value).
368
+ *
369
+ * A pure pass-through when fingerprinting is disabled (dev, or roots unset), so
370
+ * dev output stays byte-identical. Runs AFTER `elideImportsFromSource` in the
371
+ * serve path, so an elided side-effect import (already replaced by a comment)
372
+ * is not matched.
373
+ *
374
+ * @param {string} source the served module source (post-elision, type-stripped if TS)
375
+ * @param {string} importerAbs absolute path of the module being served
376
+ * @returns {string}
377
+ */
378
+ export function versionModuleImports(source, importerAbs) {
379
+ if (!_enabled || !_appDir) return source;
380
+ if (typeof source !== 'string') return source;
381
+ // Cheap bail before any regex/redaction work for a module with no imports.
382
+ if (!source.includes('import') && !source.includes('export')) return source;
383
+
384
+ const masked = redactStringsAndTemplates(source, true);
385
+ /** @type {Array<{ start: number, end: number, text: string }>} */
386
+ const edits = [];
387
+ for (const re of [IMPORT_SPEC_RE, EXPORT_SPEC_RE]) {
388
+ re.lastIndex = 0;
389
+ for (const m of source.matchAll(re)) {
390
+ // The keyword must sit in code position; if blanked in the mask it lives
391
+ // inside a string / template / comment and is not a real import edge.
392
+ if (masked[/** @type {number} */ (m.index)] === ' ') continue;
393
+ const spec = m[2];
394
+ // Only `./` `../` relative specifiers (base-path-correct, the app pattern).
395
+ // Bare -> importmap target; `/`-absolute -> pre-existing basePath gap.
396
+ if (spec[0] !== '.') continue;
397
+ if (spec.includes('?')) continue;
398
+ const abs = resolveImport(spec, importerAbs, _appDir);
399
+ if (!abs || SERVER_FILE_RE.test(abs)) continue;
400
+ if (!abs.startsWith(_appDir + sep)) continue;
401
+ const hash = assetHashFor(abs);
402
+ if (!hash) continue;
403
+ // `/d` flag: indices[2] is [start, end] of the specifier CONTENT (no
404
+ // quotes). Replace it with the resolved-extension form + `?v` so the
405
+ // fetched URL is byte-identical to the preload href.
406
+ const [start, end] = /** @type {[number, number]} */ (m.indices[2]);
407
+ edits.push({ start, end, text: `${normalizeSpecToResolved(spec, abs)}?v=${hash}` });
408
+ }
409
+ }
410
+ if (edits.length === 0) return source;
411
+
412
+ edits.sort((a, b) => a.start - b.start);
413
+ let out = '';
414
+ let last = 0;
415
+ for (const e of edits) {
416
+ out += source.slice(last, e.start) + e.text;
417
+ last = e.end;
418
+ }
419
+ return out + source.slice(last);
420
+ }
package/src/check.js CHANGED
@@ -91,13 +91,18 @@ export const RULES = [
91
91
  {
92
92
  name: 'gitignore-vendor-not-ignored',
93
93
  description:
94
- '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.',
94
+ '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 `**/` prefix matches `.webjs/` at any depth so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`; the older root-anchored `.webjs/*` also passes this rule (the probe is run from the app root). 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.',
95
95
  },
96
96
  {
97
97
  name: 'no-browser-globals-in-render',
98
98
  description:
99
99
  'Flags genuinely browser-only APIs used in a WebComponent constructor, willUpdate, or render() method. The SSR pipeline instantiates the component, runs willUpdate plus controllers\' hostUpdate, reflects properties, and calls render() to produce HTML, on a server element shim that backs the attribute methods but has no real DOM. So a browser global (document, window, localStorage, sessionStorage, navigator, location, matchMedia, screen, history) or an unshimmed HTMLElement member on `this` (attachShadow, shadowRoot, classList, querySelector, querySelectorAll, getBoundingClientRect, focus, blur, scrollIntoView) touched there throws at SSR time (the isomorphic footgun). The attribute methods (getAttribute/setAttribute/hasAttribute/removeAttribute/toggleAttribute), the event methods (addEventListener/removeEventListener/dispatchEvent), and attachInternals are shim-backed and run server-side, so they are NOT flagged. The flagged APIs belong in connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor (or derive them in willUpdate) only from server-known inputs (attributes, props). Conservative: only the constructor, willUpdate, and render bodies are scanned, and only direct references, so helper indirection is not flagged (the runtime SSR error covers that case).',
100
100
  },
101
+ {
102
+ name: 'no-scaffold-placeholder',
103
+ description:
104
+ 'The one sentinel-based check, and the deliberate exception to the "objectively broken code" framing of the other rules: it flags scaffold example content that has not been replaced, so a delivered app contains only what the user intended and never leftover scaffold code. `webjs create` marks its example/demo files (the example homepage in app/page.ts, the example app chrome in app/layout.ts) with a one-line marker comment carrying the literal token `webjs-scaffold-placeholder`. The rule scans raw source (the marker lives in a comment, which the redacted scan view would hide) and fails for every file that still carries the token. A freshly scaffolded app fails this rule BY DESIGN until its placeholders are addressed: replace the example content, or deliberately keep it, and in either case delete the marker line. No finished app legitimately ships a literal remove-me marker, so this still qualifies as a check rather than a convention. The marker is acknowledge-and-remove, so keeping a piece is a one-line deletion rather than a forced rewrite.',
105
+ },
101
106
  ];
102
107
 
103
108
  /** Set of all known rule names for fast lookup. */
@@ -465,6 +470,27 @@ export async function checkConventions(appDir) {
465
470
  }
466
471
  }
467
472
 
473
+ // --- Rule: no-scaffold-placeholder ---
474
+ // `webjs create` marks its example/demo files (app/page.ts, app/layout.ts)
475
+ // with a one-line marker comment so a delivered app cannot silently ship
476
+ // leftover scaffold content. Scan RAW `content`: the marker lives in a
477
+ // comment, which the `scan` view redacts to whitespace. One violation per
478
+ // still-marked file. The token is assembled here so this source does not
479
+ // itself carry the contiguous literal.
480
+ {
481
+ const MARKER = 'webjs-scaffold-' + 'placeholder';
482
+ for (const { rel, content } of files) {
483
+ if (!content.includes(MARKER)) continue;
484
+ violations.push({
485
+ rule: 'no-scaffold-placeholder',
486
+ file: rel,
487
+ message:
488
+ 'Scaffold placeholder marker still present. This file is unmodified example content from `webjs create`, and the delivered app should contain only what the user intended, not leftover scaffold code.',
489
+ fix: `Replace the example content (or deliberately keep it), then delete the marker comment line carrying the ${MARKER} token.`,
490
+ });
491
+ }
492
+ }
493
+
468
494
  // --- Rule: no-server-env-in-components ---
469
495
  // Catches `process.env.X` reads in component files where X is not a
470
496
  // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
@@ -728,9 +754,12 @@ export async function checkConventions(appDir) {
728
754
  // --- Rule: gitignore-vendor-not-ignored ---
729
755
  // The .gitignore pattern for .webjs/vendor/ is subtle: `.webjs/`
730
756
  // alone excludes the directory entirely and git can't re-include
731
- // children of an excluded parent. The correct pattern is `.webjs/*`
732
- // plus `!.webjs/vendor/` plus `!.webjs/vendor/**`. AI agents
733
- // and human reviewers frequently "simplify" this back to `.webjs/`,
757
+ // children of an excluded parent. The correct pattern is `**/.webjs/*`
758
+ // plus `!**/.webjs/vendor/` plus `!**/.webjs/vendor/**` (the `**/`
759
+ // prefix ignores `.webjs/` at any depth so a nested app does not leak
760
+ // its generated routes.d.ts; the older root-anchored `.webjs/*` also
761
+ // passes, since this probe runs from the app root). AI agents and
762
+ // human reviewers frequently "simplify" this back to `.webjs/`,
734
763
  // silently breaking `webjs vendor pin`.
735
764
  //
736
765
  // This rule verifies the actual gitignore behavior by spawning
@@ -778,9 +807,10 @@ export async function checkConventions(appDir) {
778
807
  `${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ and they MUST be committed for production deploys to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line in .gitignore that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second possible cause is a broader rule (e.g. \`*.js\` at root) that hides bundle files added by \`webjs vendor pin --download\`.`,
779
808
  fix:
780
809
  'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
781
- ' .webjs/*\n' +
782
- ' !.webjs/vendor/\n' +
783
- ' !.webjs/vendor/**\n' +
810
+ ' **/.webjs/*\n' +
811
+ ' !**/.webjs/vendor/\n' +
812
+ ' !**/.webjs/vendor/**\n' +
813
+ 'The `**/` prefix ignores `.webjs/` at any depth (so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`) while still re-including the committed vendor pin. ' +
784
814
  'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
785
815
  });
786
816
  }
package/src/dev.js CHANGED
@@ -115,7 +115,7 @@ function shouldAccessLog(pathname) {
115
115
  }
116
116
  import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath } from './importmap.js';
117
117
  import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
118
- import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor } from './asset-hash.js';
118
+ import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
119
119
  import { urlFromRequest } from './forwarded.js';
120
120
  import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
121
121
  import {
@@ -2142,9 +2142,13 @@ async function jsModuleResponse(abs, dev, elideOpts, immutable) {
2142
2142
  let source;
2143
2143
  try { source = await readFile(abs, 'utf8'); }
2144
2144
  catch { return new Response('Not found', { status: 404 }); }
2145
- const code = elideImportsFromSource(
2145
+ let code = elideImportsFromSource(
2146
2146
  source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
2147
2147
  );
2148
+ // Version same-origin relative import specifiers so the URL the browser
2149
+ // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
2150
+ // A no-op in dev (fingerprinting disabled).
2151
+ code = versionModuleImports(code, abs);
2148
2152
  // Buffered (string) body, so opt into the conditional-GET funnel for the
2149
2153
  // weak ETag + 304 (see fileResponse).
2150
2154
  const headers = {
@@ -2267,6 +2271,11 @@ async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError)
2267
2271
  code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
2268
2272
  );
2269
2273
  }
2274
+ // Version same-origin relative import specifiers so the URL the browser
2275
+ // fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
2276
+ // A no-op in dev (fingerprinting disabled). Cached with the elision result:
2277
+ // PROD files are static within a deploy, so the baked `?v` stays correct.
2278
+ code = versionModuleImports(code, abs);
2270
2279
  // Evict oldest entry if cache is full (simple FIFO: Map preserves insertion order).
2271
2280
  if (cache.size >= TS_CACHE_MAX) {
2272
2281
  const oldest = cache.keys().next().value;
package/src/js-scan.js CHANGED
@@ -67,10 +67,20 @@ const REGEX_PRECEDING_KEYWORDS = new Set([
67
67
  * nesting, so a nested `` html`...${html`...`}...` `` is delimited correctly
68
68
  * (the inner backtick is not mistaken for the outer close).
69
69
  *
70
+ * `blankStrings` (default false, so existing callers are byte-identical)
71
+ * additionally blanks PLAIN string bodies and disables the verbatim-template
72
+ * fast path, yielding a mask in which NO literal body survives. Callers that
73
+ * only check whether a keyword sits in code position (not inside any literal),
74
+ * e.g. the import-versioning pass, pass true so an `import '…'` written inside
75
+ * a plain string is correctly masked out (the default mask keeps plain-string
76
+ * bodies verbatim so `register('tag')` stays readable, which would otherwise
77
+ * leave such a string looking like a real import).
78
+ *
70
79
  * @param {string} src
80
+ * @param {boolean} [blankStrings=false] also blank plain-string + verbatim-template bodies
71
81
  * @returns {string}
72
82
  */
73
- export function redactStringsAndTemplates(src) {
83
+ export function redactStringsAndTemplates(src, blankStrings = false) {
74
84
  const n = src.length;
75
85
  let out = '';
76
86
  let i = 0;
@@ -157,7 +167,7 @@ export function redactStringsAndTemplates(src) {
157
167
  if (ch === '\n') hasNewline = true;
158
168
  k++;
159
169
  }
160
- const verbatim = !forceBlank && !tagged && closed && !hasNewline && !hasInterp;
170
+ const verbatim = !forceBlank && !blankStrings && !tagged && closed && !hasNewline && !hasInterp;
161
171
  out += '`'; i++;
162
172
  if (verbatim) {
163
173
  while (i < n) {
@@ -197,7 +207,7 @@ export function redactStringsAndTemplates(src) {
197
207
  if (c === '/' && next === '/') { scanLineComment(); continue; }
198
208
  if (c === '/' && next === '*') { scanBlockComment(); continue; }
199
209
  if (c === '/' && isRegex()) { scanRegex(); continue; }
200
- if (c === "'" || c === '"') { scanString(c, blank); continue; }
210
+ if (c === "'" || c === '"') { scanString(c, blank || blankStrings); continue; }
201
211
  if (c === '`') { scanTemplate(blank); continue; }
202
212
  if (c === '{') { brace++; lastSig = '{'; lastWord = ''; lastWasIncDec = false; out += blank ? ' ' : c; i++; continue; }
203
213
  if (c === '}') { brace--; lastSig = '}'; lastWord = ''; lastWasIncDec = false; out += blank ? ' ' : c; i++; continue; }