@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.
- package/README.md +4 -1
- package/index.d.ts +461 -102
- package/index.js +9 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-input.js +208 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +112 -15
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/helpers.js +85 -0
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +133 -28
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +109 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +24 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/dev-status.js +122 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +632 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +191 -133
- package/src/dev/server.js +109 -37
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +322 -78
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev-styles.js +64 -6
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +150 -69
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +49 -17
- package/src/ssr/head.js +228 -25
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +243 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +52 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +85 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +3 -8
- package/src/component-elision.js +0 -1852
- package/src/elision-differential.js +0 -101
- package/src/elision-report.js +0 -193
package/src/dev-reload-hold.js
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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 {
|
|
29
|
-
* @
|
|
30
|
-
* @returns {string}
|
|
30
|
+
* @param {unknown} raw
|
|
31
|
+
* @returns {{ v: string, by: string, why: string, modules: string[], css: boolean }}
|
|
31
32
|
*/
|
|
32
|
-
|
|
33
|
-
var
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return
|
|
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
|
|
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 =
|
|
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
|
+
}
|
package/src/dev-reload-worker.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
213
|
-
|
|
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
|
|
225
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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] === '{') {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
+
}
|