@webjsdev/server 0.8.91 → 0.10.0

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.
Files changed (105) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +461 -102
  3. package/index.js +9 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-input.js +208 -0
  7. package/src/action-params.js +156 -0
  8. package/src/action-route.js +8 -9
  9. package/src/action-seed.js +44 -5
  10. package/src/actions.js +112 -15
  11. package/src/api.js +67 -10
  12. package/src/app-layout.js +133 -0
  13. package/src/asset-hash.js +82 -49
  14. package/src/auth.js +612 -108
  15. package/src/browser-entries.js +2 -17
  16. package/src/build-epoch.js +1 -1
  17. package/src/cache-tags.js +5 -1
  18. package/src/check/helpers.js +85 -0
  19. package/src/check/rules-actions.js +37 -1
  20. package/src/check/rules-components.js +133 -28
  21. package/src/check/rules-imports.js +175 -4
  22. package/src/check/rules-route-modules.js +200 -0
  23. package/src/check/rules-routes.js +55 -0
  24. package/src/check/rules-routing.js +143 -95
  25. package/src/check/rules-typescript.js +24 -2
  26. package/src/check/rules.js +109 -9
  27. package/src/check/runner-support.js +53 -171
  28. package/src/check/runner.js +24 -6
  29. package/src/client-signals.js +171 -0
  30. package/src/component-scanner.js +22 -16
  31. package/src/context.js +152 -16
  32. package/src/cookie-store.js +262 -0
  33. package/src/core-exports.js +190 -0
  34. package/src/dev/bun-app-source.js +95 -5
  35. package/src/dev/config.js +7 -51
  36. package/src/dev/dev-service-worker.js +48 -0
  37. package/src/dev/dev-status.js +122 -0
  38. package/src/dev/esm-hot-hooks.js +373 -0
  39. package/src/dev/handler.js +632 -155
  40. package/src/dev/helpers.js +127 -96
  41. package/src/dev/hot-host.js +33 -6
  42. package/src/dev/poke-state.js +110 -0
  43. package/src/dev/serve.js +191 -133
  44. package/src/dev/server.js +109 -37
  45. package/src/dev/specifier-versions.js +125 -0
  46. package/src/dev/watch-ignore.js +49 -4
  47. package/src/dev/watch-poll.js +78 -0
  48. package/src/dev/watch-tree.js +59 -22
  49. package/src/dev-classify.js +59 -26
  50. package/src/dev-error.js +104 -3
  51. package/src/dev-hot-classify.js +291 -0
  52. package/src/dev-hot-client.js +245 -0
  53. package/src/dev-import.js +22 -5
  54. package/src/dev-overlay.js +26 -0
  55. package/src/dev-ready-probe.js +69 -0
  56. package/src/dev-regenerate.js +322 -78
  57. package/src/dev-reload-hold.js +49 -20
  58. package/src/dev-reload-reason.js +61 -0
  59. package/src/dev-reload-worker.js +62 -13
  60. package/src/dev-secret.js +73 -0
  61. package/src/dev-source-locations-hook.js +4 -1
  62. package/src/dev-source-locations.js +33 -2
  63. package/src/dev-styles.js +64 -6
  64. package/src/dev.js +0 -1
  65. package/src/env-files.js +124 -0
  66. package/src/file-storage.js +2 -2
  67. package/src/foreign-imports.js +228 -0
  68. package/src/form-action-identity.js +52 -25
  69. package/src/form-dispatch.js +150 -69
  70. package/src/html-cache.js +35 -4
  71. package/src/importmap.js +2 -2
  72. package/src/instrumentation.js +59 -3
  73. package/src/js-scan.js +5 -4
  74. package/src/jwe.js +155 -0
  75. package/src/lazy-components.js +166 -0
  76. package/src/listener-core.js +4 -2
  77. package/src/lit-hints.js +172 -0
  78. package/src/middleware-load.js +248 -0
  79. package/src/module-graph.js +73 -47
  80. package/src/module-side-effect.js +284 -0
  81. package/src/public-files.js +73 -0
  82. package/src/redirect-location.js +19 -0
  83. package/src/redirects.js +9 -1
  84. package/src/route-frontier.js +151 -0
  85. package/src/route-report.js +130 -0
  86. package/src/router.js +120 -13
  87. package/src/session.js +32 -10
  88. package/src/ssr/client-router-flag.js +1 -1
  89. package/src/ssr/document.js +49 -17
  90. package/src/ssr/head.js +228 -25
  91. package/src/ssr/preloads.js +31 -45
  92. package/src/ssr/render.js +243 -55
  93. package/src/ssr/responses.js +51 -4
  94. package/src/ssr/shipped-modules.js +52 -16
  95. package/src/testing.d.ts +31 -2
  96. package/src/testing.js +85 -17
  97. package/src/thenable-params.js +26 -0
  98. package/src/vendor/jspm.js +12 -1
  99. package/src/vendor/pins.js +9 -8
  100. package/src/vendor/scanner.js +14 -9
  101. package/src/vendor.js +5 -3
  102. package/webjs-config.schema.json +3 -8
  103. package/src/component-elision.js +0 -1852
  104. package/src/elision-differential.js +0 -101
  105. package/src/elision-report.js +0 -193
@@ -6,9 +6,9 @@
6
6
  * intermediate save, and releases it when the build is done. The embed bridge
7
7
  * (`dev-embed-client.js`) owns the switch, the `__webjsEmbedHold` page global,
8
8
  * set from the host's `{ type: 'hold', enabled }` command. The reload client
9
- * routes every reload signal through this module: while held it keeps only the
10
- * STRONGEST verdict seen, and on release it applies that one reload, or nothing
11
- * when nothing changed.
9
+ * routes every reload signal through this module: while held it merges every
10
+ * verdict it sees, and on release it applies that one reload, or nothing when
11
+ * nothing changed.
12
12
  *
13
13
  * Import-free and inlined verbatim (`export` stripped) into the served reload
14
14
  * client by `reloadClientJs` in `dev/helpers.js`, the same shared-source rule as
@@ -19,42 +19,71 @@
19
19
  * Verdict strength, strongest first. Restated rather than imported from
20
20
  * `dev-reload-worker.js` because this file is inlined on its own.
21
21
  */
22
- var HOLD_STRENGTH = ['reload', 'shell', 'page'];
22
+ var HOLD_STRENGTH = ['reload', 'shell', 'page', 'component', 'css'];
23
23
 
24
24
  /**
25
- * The stronger of two verdicts. Anything that is not a known verdict is a full
26
- * `reload`, the fail-safe every other reload path uses.
25
+ * Read one held signal into the verdict object shape. A bare string (a relay
26
+ * older than #1689 posts the name alone) becomes `{ v }`, and anything that
27
+ * is not a known verdict is a full `reload`, the fail-safe every other reload
28
+ * path uses.
27
29
  *
28
- * @param {string | null} a the verdict held so far (null = none)
29
- * @param {unknown} b the incoming verdict
30
- * @returns {string}
30
+ * @param {unknown} raw
31
+ * @returns {{ v: string, by: string, why: string, modules: string[], css: boolean }}
31
32
  */
32
- export function strongerHeldVerdict(a, b) {
33
- var ib = HOLD_STRENGTH.indexOf(/** @type {string} */ (b));
34
- if (ib === -1) ib = 0;
35
- if (a === null) return HOLD_STRENGTH[ib];
36
- var ia = HOLD_STRENGTH.indexOf(a);
37
- if (ia === -1) ia = 0;
38
- return HOLD_STRENGTH[Math.min(ia, ib)];
33
+ function readHeldVerdict(raw) {
34
+ var o = typeof raw === 'string' ? { v: raw } : raw;
35
+ if (!o || typeof o !== 'object' || HOLD_STRENGTH.indexOf(/** @type {any} */ (o).v) === -1) {
36
+ return { v: 'reload', by: '', why: 'unreadable-frame', modules: [], css: false };
37
+ }
38
+ var a = /** @type {any} */ (o);
39
+ return {
40
+ v: a.v,
41
+ by: typeof a.by === 'string' ? a.by : '',
42
+ why: typeof a.why === 'string' ? a.why : '',
43
+ modules: Array.isArray(a.modules) ? a.modules.slice() : [],
44
+ css: a.css === true,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Merge an incoming verdict into the one held so far: the stronger `v` wins
50
+ * with its `by` / `why` (the held one on a tie), `modules` unions and `css`
51
+ * ORs, the same rule as the server's `mergeVerdicts`.
52
+ *
53
+ * @param {{ v: string, by: string, why: string, modules: string[], css: boolean } | null} a held so far (null = none)
54
+ * @param {unknown} b the incoming verdict, an object or a legacy string
55
+ * @returns {{ v: string, by: string, why: string, modules: string[], css: boolean }}
56
+ */
57
+ export function mergeHeldVerdict(a, b) {
58
+ var next = readHeldVerdict(b);
59
+ if (a === null) return next;
60
+ var top = HOLD_STRENGTH.indexOf(a.v) <= HOLD_STRENGTH.indexOf(next.v) ? a : next;
61
+ var modules = a.modules.slice();
62
+ for (var i = 0; i < next.modules.length; i++) {
63
+ if (modules.indexOf(next.modules[i]) === -1) modules.push(next.modules[i]);
64
+ }
65
+ modules.sort();
66
+ return { v: top.v, by: top.by, why: top.why, modules: modules, css: a.css || next.css };
39
67
  }
40
68
 
41
69
  /**
42
70
  * Build the hold gate for one page.
43
71
  *
44
- * @param {(verdict: string) => void} apply applies a reload (the client's own apply)
72
+ * @param {(verdict: { v: string, by: string, why: string, modules: string[], css: boolean }) => void} apply
73
+ * applies a reload (the client's own apply)
45
74
  * @param {() => boolean} isHeld whether the host currently holds reloads
46
- * @returns {{ offer: (verdict: unknown) => boolean, release: () => void, pending: () => string | null }}
75
+ * @returns {{ offer: (verdict: unknown) => boolean, release: () => void, pending: () => ({ v: string, by: string, why: string, modules: string[], css: boolean } | null) }}
47
76
  * `offer` returns true when it kept the signal (held), false when the caller
48
77
  * should apply it now; `release` applies the one pending reload once the hold
49
78
  * is off.
50
79
  */
51
80
  export function createReloadHold(apply, isHeld) {
52
- /** @type {string | null} */
81
+ /** @type {{ v: string, by: string, why: string, modules: string[], css: boolean } | null} */
53
82
  var held = null;
54
83
  return {
55
84
  offer: function (verdict) {
56
85
  if (!isHeld()) return false;
57
- held = strongerHeldVerdict(held, verdict);
86
+ held = mergeHeldVerdict(held, verdict);
58
87
  return true;
59
88
  },
60
89
  release: function () {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Why the dev server reloaded the page, printed AFTER the reload (#1689), the
3
+ * BROWSER half. Browser-safe and import-free: `reloadClientJs` (`dev/helpers.js`)
4
+ * inlines it after an `export` strip, so the unit test drives the exact code
5
+ * that ships.
6
+ *
7
+ * A reload replaces the document, and with it the console of the page that
8
+ * decided to reload, so a reason logged before `location.reload()` is gone by
9
+ * the time anyone looks (Next prints its `REACT_REFRESH_FULL_RELOAD` reason for
10
+ * the same reason). `reloadWithReason` stores `{ why, by }` in
11
+ * `sessionStorage`, which survives a same-tab reload, and the reloaded page's
12
+ * client prints it once with `printPendingReason`:
13
+ * `[webjs] full reload: <why> (<by>)`. Where storage is unavailable (a
14
+ * sandboxed frame, storage disabled) the reason is logged before the reload
15
+ * instead, which is the best that can be done.
16
+ */
17
+
18
+ const RELOAD_REASON_KEY = 'webjs:dev-reload-reason';
19
+
20
+ /**
21
+ * @param {{ why?: string, by?: string }} r
22
+ * @returns {string}
23
+ */
24
+ function reloadReasonLine(r) {
25
+ return '[webjs] full reload: ' + (r.why || 'reload') + (r.by ? ' (' + r.by + ')' : '');
26
+ }
27
+
28
+ /**
29
+ * Remember why, then reload.
30
+ * @param {{ v?: string, why?: string, by?: string }} verdict
31
+ * @param {{ reload: () => void }} loc
32
+ * @param {Storage | null | undefined} store
33
+ * @param {(msg: string) => void} log
34
+ */
35
+ export function reloadWithReason(verdict, loc, store, log) {
36
+ const r = { why: (verdict && (verdict.why || verdict.v)) || 'reload', by: (verdict && verdict.by) || '' };
37
+ let kept = false;
38
+ try {
39
+ if (store) { store.setItem(RELOAD_REASON_KEY, JSON.stringify(r)); kept = true; }
40
+ } catch (_) { /* storage disabled or full: log it now instead */ }
41
+ if (!kept) log(reloadReasonLine(r));
42
+ loc.reload();
43
+ }
44
+
45
+ /**
46
+ * Print the reason a previous page stored before it reloaded, once.
47
+ * @param {Storage | null | undefined} store
48
+ * @param {(msg: string) => void} log
49
+ */
50
+ export function printPendingReason(store, log) {
51
+ let raw = null;
52
+ try {
53
+ if (!store) return;
54
+ raw = store.getItem(RELOAD_REASON_KEY);
55
+ if (raw !== null) store.removeItem(RELOAD_REASON_KEY);
56
+ } catch (_) { return; }
57
+ if (!raw) return;
58
+ let r;
59
+ try { r = JSON.parse(raw); } catch (_) { return; }
60
+ if (r && typeof r === 'object') log(reloadReasonLine(r));
61
+ }
@@ -67,13 +67,13 @@ export const RELOAD_MAX_HOLD_MS = 5000;
67
67
  * component edit is a component edit, and morphing it would leave the old
68
68
  * component class running against fresh markup.
69
69
  */
70
- export const VERDICT_STRENGTH = ['reload', 'shell', 'page'];
70
+ export const VERDICT_STRENGTH = ['reload', 'shell', 'page', 'component', 'css'];
71
71
 
72
72
  /**
73
73
  * Resolve an SSE `reload` frame's `data` to a verdict name.
74
74
  *
75
75
  * ANY failure resolves to `reload`: an absent payload, malformed JSON, a
76
- * non-object, a `v` outside the three literals, or the legacy bare `data: now`.
76
+ * non-object, a `v` outside the known names, or the legacy bare `data: now`.
77
77
  * That is what makes the wire-format change safe in both directions between a
78
78
  * long-running browser tab and a restarted server, since the worst a skew can
79
79
  * produce is the full reload that was the behaviour before this existed.
@@ -89,6 +89,32 @@ export function parseVerdict(data) {
89
89
  return 'reload';
90
90
  }
91
91
 
92
+ /**
93
+ * Resolve an SSE `reload` frame's `data` to the whole verdict the relay posts
94
+ * to a tab (#1689): `{ v, by, why, modules, css }`, every field always present
95
+ * so a tab never has to guess. The same fail-safe as `parseVerdict`: anything
96
+ * it cannot read is `{ v: 'reload', why: 'unreadable-frame' }`, and a field of
97
+ * the wrong type falls back to its empty value rather than failing the frame.
98
+ *
99
+ * @param {string} data
100
+ * @returns {{ v: string, by: string, why: string, modules: string[], css: boolean }}
101
+ */
102
+ export function parseReloadFrame(data) {
103
+ try {
104
+ const o = JSON.parse(data);
105
+ if (o && typeof o === 'object' && VERDICT_STRENGTH.indexOf(o.v) !== -1) {
106
+ return {
107
+ v: o.v,
108
+ by: typeof o.by === 'string' ? o.by : '',
109
+ why: typeof o.why === 'string' ? o.why : '',
110
+ modules: Array.isArray(o.modules) ? o.modules.filter((m) => typeof m === 'string') : [],
111
+ css: o.css === true,
112
+ };
113
+ }
114
+ } catch (_) { /* fall through to the fail-safe */ }
115
+ return { v: 'reload', by: '', why: 'unreadable-frame', modules: [], css: false };
116
+ }
117
+
92
118
  /** First reconnect wait after the stream drops (#1507), doubled per failure. */
93
119
  export const RECONNECT_BASE_MS = 300;
94
120
  /** The longest wait between reconnect attempts (#1507). */
@@ -209,8 +235,13 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
209
235
  /** @type {any} */ let quietTimer = null;
210
236
  /** @type {any} */ let capTimer = null;
211
237
 
212
- /** @type {string} the strongest verdict seen in the CURRENT batch (#1398) */
213
- let batchVerdict = 'page';
238
+ /**
239
+ * The CURRENT batch's merged verdict (#1398, #1689): the strongest `v` with
240
+ * its `by` / `why`, the union of every `modules` list and whether any frame
241
+ * set `css`. Null between batches.
242
+ * @type {{ v: string, by: string, why: string, modules: Set<string>, css: boolean } | null}
243
+ */
244
+ let batch = null;
214
245
  /** Whether the batch reloads every tab, or only the stale ones in `batchPorts` (#1516). */
215
246
  let batchAll = false;
216
247
  /** @type {Set<any>} tabs whose page fell behind, reloaded on their own */
@@ -221,9 +252,9 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
221
252
  function emitReload() {
222
253
  if (quietTimer !== null) { timers.clearTimeout(quietTimer); quietTimer = null; }
223
254
  if (capTimer !== null) { timers.clearTimeout(capTimer); capTimer = null; }
224
- const v = batchVerdict;
225
- batchVerdict = 'page'; // a fresh batch starts at the weakest verdict
226
- const msg = { type: 'reload', verdict: v };
255
+ const b = batch || { v: 'reload', by: '', why: 'no-verdict', modules: new Set(), css: false };
256
+ batch = null; // a fresh batch starts from nothing
257
+ const msg = { type: 'reload', verdict: { v: b.v, by: b.by, why: b.why, modules: Array.from(b.modules).sort(), css: b.css } };
227
258
  const targets = batchAll ? Array.from(ports.keys()) : Array.from(batchPorts).filter((p) => ports.has(p));
228
259
  batchAll = false;
229
260
  batchPorts.clear();
@@ -236,6 +267,9 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
236
267
  }
237
268
  }
238
269
 
270
+ /** The reload a tab gets when its page is behind the server (#1516). */
271
+ const PAGE_BEHIND = { v: 'reload', by: '', why: 'page-behind' };
272
+
239
273
  /** Strength rank; an unrecognised name ranks strongest, so it can only ever
240
274
  * over-reload. @param {string} v */
241
275
  function rank(v) {
@@ -244,12 +278,27 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
244
278
  }
245
279
 
246
280
  /**
247
- * @param {string} verdict
281
+ * Fold one signal into the batch: the strongest `v` wins with its `by` /
282
+ * `why` (the earlier one on a tie), `modules` unions and `css` ORs, the same
283
+ * rule as `mergeVerdicts` on the server.
284
+ * @param {{ v: string, by?: string, why?: string, modules?: string[], css?: boolean }} verdict
285
+ */
286
+ function mergeIntoBatch(verdict) {
287
+ const v = VERDICT_STRENGTH[rank(verdict.v)];
288
+ if (batch === null) batch = { v, by: verdict.by || '', why: verdict.why || '', modules: new Set(), css: false };
289
+ else if (rank(v) < rank(batch.v)) { batch.v = v; batch.by = verdict.by || ''; batch.why = verdict.why || ''; }
290
+ if (verdict.modules) for (const m of verdict.modules) batch.modules.add(m);
291
+ if (verdict.css) batch.css = true;
292
+ }
293
+
294
+ /**
295
+ * @param {{ v: string, by?: string, why?: string, modules?: string[], css?: boolean }} verdict
248
296
  * @param {any} [port] reload only this tab (its page fell behind); omitted, every tab
297
+ * @param {boolean} [inPlace] the signal came from a server that reloads in place
249
298
  */
250
299
  function requestReload(verdict, port, inPlace) {
251
300
  if (port === undefined) batchAll = true; else batchPorts.add(port);
252
- if (rank(verdict) < rank(batchVerdict)) batchVerdict = VERDICT_STRENGTH[rank(verdict)];
301
+ mergeIntoBatch(verdict);
253
302
  // One restart signal anywhere in the batch keeps the long window (#1575).
254
303
  if (!inPlace) batchRestart = true;
255
304
  if (quietTimer !== null) timers.clearTimeout(quietTimer);
@@ -278,7 +327,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
278
327
  // Reload every tab whose page is behind what the server reports now (#1516).
279
328
  function checkPages() {
280
329
  for (const [p, page] of pages) {
281
- if (pageIsStale(page, lastBoot, lastSeq)) requestReload('reload', p);
330
+ if (pageIsStale(page, lastBoot, lastSeq)) requestReload(PAGE_BEHIND, p);
282
331
  }
283
332
  }
284
333
 
@@ -297,7 +346,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
297
346
  touch();
298
347
  const h = parseHello(e.data);
299
348
  if (lastBoot !== null && (h.boot !== lastBoot || (h.seq !== null && lastSeq !== null && h.seq !== lastSeq))) {
300
- requestReload('reload');
349
+ requestReload({ v: 'reload', by: '', why: h.boot !== lastBoot ? 'server-restarted' : 'missed-update' });
301
350
  }
302
351
  lastBoot = h.boot;
303
352
  lastSeq = h.seq;
@@ -308,7 +357,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
308
357
  lastError = null;
309
358
  const n = reloadSeq(e.data);
310
359
  if (n !== null) lastSeq = n;
311
- requestReload(parseVerdict(e.data), undefined, frameInPlace(e.data));
360
+ requestReload(parseReloadFrame(e.data), undefined, frameInPlace(e.data));
312
361
  });
313
362
  es.addEventListener('webjs-error', (e) => { lastError = e.data; fanout({ type: 'webjs-error', data: e.data }); });
314
363
  // The stream dropped (a restarting server, a network blip, a host that
@@ -348,7 +397,7 @@ export function startReloadWorker(scope, EventSourceCtor, eventsUrl, opts) {
348
397
  // out reload catches up, and again on every hello.
349
398
  if (typeof d.boot === 'string') {
350
399
  pages.set(port, { boot: d.boot, seq: typeof d.seq === 'number' ? d.seq : null });
351
- if (pageIsStale(pages.get(port), lastBoot, lastSeq)) requestReload('reload', port);
400
+ if (pageIsStale(pages.get(port), lastBoot, lastSeq)) requestReload(PAGE_BEHIND, port);
352
401
  }
353
402
  return;
354
403
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A signing secret for development and tests when the app sets none (#1686),
3
+ * the way Rails keeps `tmp/local_secret.txt` and NextAuth v4 fell back to a
4
+ * generated secret outside production. `.env` is gitignored, so a fresh clone
5
+ * or a git worktree had no `AUTH_SECRET` / `SESSION_SECRET` and every page
6
+ * behind `session()` or `createAuth()` answered 500 until someone wrote one.
7
+ *
8
+ * The secret is generated once and kept in `.webjs/dev-secret` under the
9
+ * current directory (the app root for `webjs dev` and `webjs test`), which the
10
+ * scaffold's `.gitignore` already excludes, so sessions survive a restart.
11
+ *
12
+ * It is OPT-IN, and `NODE_ENV` alone decides ({@link devSecretApplies}): only
13
+ * `NODE_ENV=development` or `NODE_ENV=test` gets one (`webjs dev` and
14
+ * `webjs test` set them when unset). A process a test spawns inherits
15
+ * `NODE_ENV=test`, as under Jest, so it gets one too unless the test sets
16
+ * `NODE_ENV=production` on it.
17
+ * Anything else, a production `NODE_ENV` or none at all (an embedded host, a
18
+ * container running `node server.js`), gets undefined and the caller throws,
19
+ * so a production cookie is never signed with a key from disk because an env
20
+ * var was missing. It is read per call, never latched for the process, so a
21
+ * development handler created earlier in the same process opts nothing in.
22
+ *
23
+ * @module
24
+ */
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { randomBytes } from 'node:crypto';
27
+ import { join } from 'node:path';
28
+
29
+ const warned = new Set();
30
+
31
+ /**
32
+ * Whether this process may use the generated development secret, decided
33
+ * from `NODE_ENV` alone on every call: `development` or `test`.
34
+ * @param {Record<string, string | undefined>} [env]
35
+ * @returns {boolean}
36
+ */
37
+ export function devSecretApplies(env = process.env) {
38
+ return env.NODE_ENV === 'development' || env.NODE_ENV === 'test';
39
+ }
40
+
41
+ /**
42
+ * The development secret, created on first use, or undefined unless this is a
43
+ * development or test process.
44
+ * @param {string} name the variable the app left unset, named in the warning
45
+ * @param {{ dir?: string, env?: Record<string, string | undefined>, warn?: (msg: string) => void }} [opts]
46
+ * @returns {string | undefined}
47
+ */
48
+ export function devSecret(name, opts = {}) {
49
+ const env = opts.env || process.env;
50
+ if (!devSecretApplies(env)) return undefined;
51
+ const file = join(opts.dir || process.cwd(), '.webjs', 'dev-secret');
52
+ let secret = '';
53
+ try {
54
+ if (existsSync(file)) secret = readFileSync(file, 'utf8').trim();
55
+ } catch {}
56
+ if (!secret) {
57
+ secret = randomBytes(32).toString('hex');
58
+ try {
59
+ mkdirSync(join(file, '..'), { recursive: true });
60
+ writeFileSync(file, secret + '\n', { mode: 0o600 });
61
+ } catch {
62
+ // A read-only directory still gets a secret, just one per process.
63
+ }
64
+ }
65
+ if (!warned.has(name)) {
66
+ warned.add(name);
67
+ (opts.warn || ((m) => console.warn(m)))(
68
+ `[webjs] ${name} is not set, so a generated development secret in .webjs/dev-secret signs the cookie. `
69
+ + `Set ${name} in .env for production, where it is required.`,
70
+ );
71
+ }
72
+ return secret;
73
+ }
@@ -28,6 +28,7 @@ import { readFileSync } from 'node:fs';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { relative, isAbsolute } from 'node:path';
30
30
  import { serverRuntime } from './listener-core.js';
31
+ import { rewriteAppSource } from './dev/bun-app-source.js';
31
32
  import {
32
33
  annotateSourceLocations,
33
34
  isSourceLocationCandidate,
@@ -128,7 +129,9 @@ export function registerSourceLocationHook(appDir) {
128
129
  try {
129
130
  contents = transformForSsr(abs, src) ?? src;
130
131
  } catch { /* fail open: the raw module */ }
131
- return { contents, loader };
132
+ // The same alias rewrite and module versions the app-source plugin
133
+ // applies (#1689), which this plugin wins over while it is on.
134
+ return { contents: rewriteAppSource(contents, abs), loader };
132
135
  });
133
136
  },
134
137
  });
@@ -141,14 +141,33 @@ export function annotateSourceLocations(source, file) {
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * The static strings of every `html` template in `src`, with the 1-based line
146
+ * each template starts on, for a check rule that inspects template markup
147
+ * (`no-jsx-attributes`). The strings are the raw source text between holes.
148
+ * Returns [] when the source cannot be lexed.
149
+ *
150
+ * @param {string} src
151
+ * @returns {{ strings: string[], line: number }[]}
152
+ */
153
+ export function htmlTemplateStrings(src) {
154
+ if (typeof src !== 'string' || !src.includes('html')) return [];
155
+ /** @type {{ strings: string[], line: number }[]} */
156
+ const templates = [];
157
+ try { collectInserts(src, '', templates); } catch { return []; }
158
+ return templates;
159
+ }
160
+
144
161
  /**
145
162
  * Lex `src` and return `[offset, text]` insertions, in ascending order.
146
163
  *
147
164
  * @param {string} src
148
165
  * @param {string} file
166
+ * @param {{ strings: string[], line: number }[]} [templates] when given,
167
+ * each `html` template's static strings are collected here
149
168
  * @returns {Array<[number, string]>}
150
169
  */
151
- function collectInserts(src, file) {
170
+ function collectInserts(src, file, templates) {
152
171
  /** @type {Array<[number, string]>} */
153
172
  const inserts = [];
154
173
  const n = src.length;
@@ -184,13 +203,19 @@ function collectInserts(src, file) {
184
203
  const c = src[i];
185
204
  if (c === '\\') {
186
205
  // An escape is static text the html machine sees as an opaque char.
206
+ if (html && html.record) html.record.strings[html.record.strings.length - 1] += src.slice(i, i + 2);
187
207
  if (html) html.feed(src, i, line);
188
208
  if (src[i + 1] === '\n') line++;
189
209
  i += 2;
190
210
  continue;
191
211
  }
192
212
  if (c === '`') { i++; return 'end'; }
193
- if (c === '$' && src[i + 1] === '{') { i += 2; return 'hole'; }
213
+ if (c === '$' && src[i + 1] === '{') {
214
+ i += 2;
215
+ if (html && html.record) html.record.strings.push('');
216
+ return 'hole';
217
+ }
218
+ if (html && html.record) html.record.strings[html.record.strings.length - 1] += c;
194
219
  if (html) {
195
220
  const ins = html.feed(src, i, line);
196
221
  if (ins !== -1) inserts.push([ins, ` data-webjs-src="${file}:${line}"`]);
@@ -208,6 +233,10 @@ function collectInserts(src, file) {
208
233
  const enterTemplate = (isHtml) => {
209
234
  i++; // the backtick
210
235
  const html = isHtml ? new HtmlState() : null;
236
+ if (html && templates) {
237
+ html.record = { strings: [''], line };
238
+ templates.push(html.record);
239
+ }
211
240
  const r = scanTemplateText(html);
212
241
  if (r === 'hole') { stack.push({ kind: 'tpl', html }); prev = 'punct'; }
213
242
  else prev = 'str';
@@ -320,6 +349,8 @@ function collectInserts(src, file) {
320
349
  */
321
350
  class HtmlState {
322
351
  constructor() {
352
+ /** @type {{ strings: string[], line: number } | null} the template's static strings, when collected */
353
+ this.record = null;
323
354
  /** @type {'text' | 'tag' | 'dq' | 'sq' | 'comment' | 'raw'} */
324
355
  this.mode = 'text';
325
356
  /** Tag name of the tag being read (lowercased), '' for a close / bang tag. */
package/src/dev-styles.js CHANGED
@@ -61,17 +61,28 @@ export function preloadStyles(root, now, timeoutMs) {
61
61
  kept[key] = { el, url };
62
62
  }
63
63
  let committed = false;
64
- /** @type {Array<{ key: string, old: Element, next: Element, state: string }>} */
64
+ /** @type {Array<{ key: string, file: string, old: Element, next: Element, state: string }>} */
65
65
  const pairs = [];
66
66
  // Drop every other copy of this sheet (the old link, a copy the head merge
67
- // re-appended) so exactly the replacement is left.
67
+ // re-appended, the client router's temporary preload copy) so exactly the
68
+ // replacement is left. The router marks its copy `data-webjs-preload-style`
69
+ // and gives it only the fetch attributes, so it never shares the identity
70
+ // key. Left in place it waits for a twin link this function has just
71
+ // removed, and lingers until the router's timeout. So a copy goes too when
72
+ // it is the same file (the same URL once the dev cache-buster is ignored,
73
+ // so `?theme=dark` stays when `?theme=light` is rebuilt) AND has already
74
+ // loaded: an unloaded copy is one a navigation is still waiting on, and
75
+ // removing it would stall that navigation for the whole timeout.
68
76
  const settleLoaded = function (pair) {
77
+ if (pair.old.parentNode) pair.old.parentNode.removeChild(pair.old);
69
78
  const links = [].slice.call(scope.querySelectorAll('link[rel~="stylesheet"][href]'));
70
79
  for (const el of links) {
71
80
  if (el === pair.next) continue;
72
81
  let url;
73
82
  try { url = new URL(el.getAttribute('href'), location.href); } catch (_) { continue; }
74
- if (url.origin === location.origin && identityKey(el, url) === pair.key && el.parentNode) el.parentNode.removeChild(el);
83
+ if (url.origin !== location.origin || !el.parentNode) continue;
84
+ const routerCopy = el.hasAttribute(ROUTER_PRELOAD_ATTR) && !!el.sheet && withoutBuster(url) === pair.file;
85
+ if (routerCopy || identityKey(el, url) === pair.key) el.parentNode.removeChild(el);
75
86
  }
76
87
  };
77
88
  const waits = [];
@@ -79,13 +90,30 @@ export function preloadStyles(root, now, timeoutMs) {
79
90
  const old = kept[key].el;
80
91
  const url = kept[key].url;
81
92
  if (!old.parentNode) continue;
93
+ const file = withoutBuster(url);
82
94
  url.searchParams.set('__webjs_dev', String(stamp()));
83
95
  const next = /** @type {Element} */ (old.cloneNode(false));
84
96
  next.setAttribute('href', url.pathname + url.search);
85
- const pair = { key, old, next, state: 'pending' };
97
+ // The copy loads under a media query that matches nothing, so it never
98
+ // applies before its load handler has decided whether it should: the
99
+ // client router can switch the link off or on (#1669) while this refresh
100
+ // waits, so that state is read at load, not now.
101
+ const media = authoredMedia(old);
102
+ next.removeAttribute('data-wj-style-media');
103
+ next.setAttribute('media', 'not all');
104
+ const pair = { key, file, old, next, state: 'pending' };
86
105
  pairs.push(pair);
87
106
  waits.push(new Promise(function (resolve) {
88
107
  next.addEventListener('load', function () {
108
+ // A section sheet the router switched off is refreshed too, so going
109
+ // back to the section switches on the edited file, but the copy stays
110
+ // off until then, like the link it replaces. Media first: Firefox and
111
+ // WebKit give the link a new sheet object when its media changes, so
112
+ // disabling before would be lost.
113
+ const off = old.parentNode ? old.hasAttribute('data-wj-style-off') : next.hasAttribute('data-wj-style-off');
114
+ if (off) next.setAttribute('data-wj-style-off', ''); else next.removeAttribute('data-wj-style-off');
115
+ if (media) next.setAttribute('media', media); else next.removeAttribute('media');
116
+ if (next.sheet) next.sheet.disabled = off;
89
117
  pair.state = 'load';
90
118
  if (committed) settleLoaded(pair);
91
119
  resolve(undefined);
@@ -112,6 +140,24 @@ export function preloadStyles(root, now, timeoutMs) {
112
140
  });
113
141
  }
114
142
 
143
+ /**
144
+ * The attribute the client router marks its temporary stylesheet copies with
145
+ * (`PRELOAD_ATTR` in core's `router-client/stylesheets.js`). Duplicated rather
146
+ * than imported, since this module is inlined into the reload client as source.
147
+ */
148
+ const ROUTER_PRELOAD_ATTR = 'data-webjs-preload-style';
149
+
150
+ /**
151
+ * A stylesheet URL with the dev cache-buster removed, which is what names the
152
+ * file: two links that differ only by `__webjs_dev` load the same rules.
153
+ * @param {URL} url
154
+ */
155
+ function withoutBuster(url) {
156
+ const u = new URL(url.href);
157
+ u.searchParams.delete('__webjs_dev');
158
+ return u.pathname + u.search;
159
+ }
160
+
115
161
  /**
116
162
  * A link's identity for de-duping: its path plus every attribute except `href`.
117
163
  * JSON-encoded over a sorted list so no separator can collide with an attribute
@@ -123,9 +169,21 @@ function identityKey(el, url) {
123
169
  const parts = [];
124
170
  const attrs = el.attributes;
125
171
  for (let a = 0; a < attrs.length; a++) {
126
- if (attrs[a].name === 'href') continue;
127
- parts.push([attrs[a].name, attrs[a].value]);
172
+ const name = attrs[a].name;
173
+ // The client router's switch-off state (#1669) is not part of which sheet
174
+ // a link is, and neither is a held `not all` media: the author's is.
175
+ if (name === 'href' || name === 'data-wj-style-off' || name === 'data-wj-style-media') continue;
176
+ parts.push([name, name === 'media' ? authoredMedia(el) : attrs[a].value]);
128
177
  }
129
178
  parts.sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
130
179
  return JSON.stringify([url.pathname, parts]);
131
180
  }
181
+
182
+ /**
183
+ * The media a link applies under: the author's, also while the client router
184
+ * holds it under `not all` (`data-wj-style-media`, owned-styles.js).
185
+ * @param {Element} el
186
+ */
187
+ function authoredMedia(el) {
188
+ return el.hasAttribute('data-wj-style-media') ? el.getAttribute('data-wj-style-media') : el.getAttribute('media');
189
+ }
package/src/dev.js CHANGED
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  export {
7
- readElideEnabled,
8
7
  readSeedEnabled,
9
8
  readClientRouterEnabled,
10
9
  readHeaderRules,