@webjsdev/server 0.8.67 → 0.8.69

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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * The dev embed bridge, BROWSER half (#1498).
3
+ *
4
+ * A tool that previews a WebJs app inside an iframe (an AI app builder, a docs
5
+ * playground, an editor pane) learns what the framed page is doing from this
6
+ * bridge, and can drive it. It is a browser-safe module with no imports, so it
7
+ * is both unit-tested in a real browser AND inlined into every dev document by
8
+ * `embedScriptTag` (`dev-embed.js` reads this file's source, strips the
9
+ * `export` keyword, and wraps it in a nonce-carrying inline `<script>`). One
10
+ * source, so the test drives the exact code that ships.
11
+ *
12
+ * Dev-only and opt-in: the server emits it only under `webjs dev` with
13
+ * `WEBJS_EMBED_ORIGINS` set, and never under `webjs start`.
14
+ *
15
+ * Wire protocol. Every message the page sends is `{ source: 'webjs-embed',
16
+ * type, ...fields }`, posted to `window.parent` with an EXACT target origin
17
+ * from the allow list (never `'*'`), so a parent on any other origin never
18
+ * receives a byte of it. The host sends `{ source: 'webjs-embed-host', type,
19
+ * ...fields }`, honoured only when it comes from `window.parent` on an allowed
20
+ * origin.
21
+ *
22
+ * Security: the highlight box is built with `createElement` + inline style
23
+ * only, never `innerHTML`, and host input is validated (a navigate path must
24
+ * be a same-origin local path) before it touches the page.
25
+ */
26
+
27
+ /**
28
+ * Install the bridge. Idempotent per window: a second call is a no-op, so an
29
+ * inlined copy in a re-rendered document cannot double-wrap `fetch`.
30
+ *
31
+ * @param {string[]} origins the allowed parent origins (already normalized)
32
+ * @param {{ win?: Window, parent?: { postMessage: (msg: unknown, origin: string) => void } }} [opts]
33
+ * test seams: the window to instrument, and a stand-in for `window.parent`
34
+ * @returns {{ uninstall: () => void } | null} null when not framed or nothing allowed
35
+ */
36
+ export function installEmbedBridge(origins, opts) {
37
+ var win = (opts && opts.win) || window;
38
+ var parent = (opts && opts.parent) || (win.parent !== win ? win.parent : null);
39
+ if (!parent || !origins || !origins.length) return null;
40
+ if (win.__webjsEmbedInstalled) return null;
41
+ win.__webjsEmbedInstalled = true;
42
+ var doc = win.document;
43
+ var cleanups = [];
44
+ // Once the host has spoken from an allowed origin we know its exact origin
45
+ // and post only there. Until then, post once per allowed origin: the browser
46
+ // delivers a message only when the target origin matches the parent's real
47
+ // origin, so at most one copy lands and a non-listed parent gets nothing.
48
+ var known = null;
49
+ var ancestors = win.location && win.location.ancestorOrigins;
50
+ if (ancestors && ancestors.length && origins.indexOf(ancestors[0]) !== -1) known = ancestors[0];
51
+
52
+ function post(type, fields) {
53
+ var msg = { source: 'webjs-embed', type: type };
54
+ if (fields) for (var k in fields) msg[k] = fields[k];
55
+ var targets = known ? [known] : origins;
56
+ for (var i = 0; i < targets.length; i++) {
57
+ try { parent.postMessage(msg, targets[i]); } catch (_) { /* a non-cloneable field: drop it */ }
58
+ }
59
+ }
60
+
61
+ function here() {
62
+ return win.location.pathname + win.location.search + win.location.hash;
63
+ }
64
+
65
+ // A url as a same-origin path when it is one (so the host sees `/api/x`, not
66
+ // the dev server's host and port), else the absolute url unchanged.
67
+ function toPath(u) {
68
+ try {
69
+ var url = new URL(String(u), win.location.href);
70
+ return url.origin === win.location.origin ? url.pathname + url.search : url.href;
71
+ } catch (_) { return String(u); }
72
+ }
73
+
74
+ function clip(s, n) {
75
+ s = String(s == null ? '' : s);
76
+ return s.length > n ? s.slice(0, n) + '...' : s;
77
+ }
78
+
79
+ function describe(v) {
80
+ if (v instanceof Error) return v.message;
81
+ if (typeof v === 'string') return v;
82
+ try { return JSON.stringify(v); } catch (_) { return String(v); }
83
+ }
84
+
85
+ function listen(target, name, fn, capture) {
86
+ target.addEventListener(name, fn, capture);
87
+ cleanups.push(function () { target.removeEventListener(name, fn, capture); });
88
+ }
89
+
90
+ // ready: once the document has parsed.
91
+ function sendReady() { post('ready', { path: here(), title: doc.title }); }
92
+ if (doc.readyState === 'loading') listen(doc, 'DOMContentLoaded', sendReady);
93
+ else sendReady();
94
+
95
+ // navigate: every client-router navigation, and history traversal.
96
+ function sendNavigate() { post('navigate', { path: here(), title: doc.title }); }
97
+ listen(doc, 'webjs:navigate', sendNavigate);
98
+ listen(win, 'popstate', sendNavigate);
99
+
100
+ // console: error + warn, rate limited to 20 per second. What the limit drops
101
+ // is counted and reported on the next message that gets through, so a loop
102
+ // that logs forever cannot flood the host and the host still knows.
103
+ var windowStart = 0;
104
+ var inWindow = 0;
105
+ var dropped = 0;
106
+ var con = win.console;
107
+ ['error', 'warn'].forEach(function (level) {
108
+ var orig = con[level];
109
+ con[level] = function () {
110
+ var now = Date.now();
111
+ if (now - windowStart > 1000) { windowStart = now; inWindow = 0; }
112
+ if (++inWindow <= 20) {
113
+ var parts = [];
114
+ for (var i = 0; i < arguments.length; i++) parts.push(describe(arguments[i]));
115
+ var fields = { level: level, message: clip(parts.join(' '), 2000) };
116
+ if (dropped) { fields.dropped = dropped; dropped = 0; }
117
+ post('console', fields);
118
+ } else {
119
+ dropped++;
120
+ }
121
+ return orig.apply(this, arguments);
122
+ };
123
+ cleanups.push(function () { con[level] = orig; });
124
+ });
125
+
126
+ // error: uncaught exceptions and unhandled promise rejections. A resource
127
+ // load failure also fires `error` on window in the capture phase but is not
128
+ // an ErrorEvent, so the `message` guard skips it.
129
+ listen(win, 'error', function (e) {
130
+ if (!e || typeof e.message !== 'string' || !e.message) return;
131
+ post('error', {
132
+ message: clip(e.message, 2000),
133
+ stack: e.error && e.error.stack ? clip(e.error.stack, 8000) : null,
134
+ file: e.filename ? toPath(e.filename) : null,
135
+ line: e.lineno || null,
136
+ column: e.colno || null,
137
+ });
138
+ });
139
+ listen(win, 'unhandledrejection', function (e) {
140
+ var r = e && e.reason;
141
+ post('error', {
142
+ message: clip('Unhandled rejection: ' + describe(r), 2000),
143
+ stack: r && r.stack ? clip(r.stack, 8000) : null,
144
+ file: null,
145
+ line: null,
146
+ column: null,
147
+ });
148
+ });
149
+
150
+ // network: fetch and XHR answering >= 500, or failing outright. The dev
151
+ // server's own probes are skipped: `/__webjs/version` is polled on purpose
152
+ // while a restarting server refuses connections, which is not an app fault.
153
+ function ignored(path) {
154
+ return path.indexOf('/__webjs/version') !== -1 || path.indexOf('/__webjs/events') !== -1;
155
+ }
156
+ function sendNetwork(method, url, status, error) {
157
+ var path = toPath(url);
158
+ if (ignored(path)) return;
159
+ var fields = { method: String(method || 'GET').toUpperCase(), url: path, status: status };
160
+ if (error) fields.error = clip(error, 500);
161
+ post('network', fields);
162
+ }
163
+ var origFetch = win.fetch;
164
+ if (typeof origFetch === 'function') {
165
+ win.fetch = function (input, init) {
166
+ var method = (init && init.method) || (input && typeof input === 'object' && input.method) || 'GET';
167
+ var url = input && typeof input === 'object' && 'url' in input ? input.url : input;
168
+ return origFetch.apply(this, arguments).then(function (res) {
169
+ if (res && res.status >= 500) sendNetwork(method, url, res.status, null);
170
+ return res;
171
+ }, function (err) {
172
+ // An abort is the caller's own decision (a superseded navigation, a
173
+ // cancelled prefetch), not a failure.
174
+ if (!(err && err.name === 'AbortError')) sendNetwork(method, url, 0, describe(err));
175
+ throw err;
176
+ });
177
+ };
178
+ cleanups.push(function () { win.fetch = origFetch; });
179
+ }
180
+ var XHR = win.XMLHttpRequest;
181
+ if (XHR && XHR.prototype) {
182
+ var origOpen = XHR.prototype.open;
183
+ var origSend = XHR.prototype.send;
184
+ XHR.prototype.open = function (method, url) {
185
+ this.__webjsEmbed = { method: method, url: url };
186
+ return origOpen.apply(this, arguments);
187
+ };
188
+ XHR.prototype.send = function () {
189
+ var xhr = this;
190
+ var info = xhr.__webjsEmbed;
191
+ if (info) {
192
+ xhr.addEventListener('load', function () {
193
+ if (xhr.status >= 500) sendNetwork(info.method, info.url, xhr.status, null);
194
+ });
195
+ xhr.addEventListener('error', function () { sendNetwork(info.method, info.url, 0, 'Network error'); });
196
+ }
197
+ return origSend.apply(this, arguments);
198
+ };
199
+ cleanups.push(function () { XHR.prototype.open = origOpen; XHR.prototype.send = origSend; });
200
+ }
201
+
202
+ // server-error: the dev error overlay went up. `renderDevOverlay` is the one
203
+ // gate deciding a frame belongs on this page (#1047), and it dispatches
204
+ // `webjs:dev-overlay` exactly when it renders, so this relays what the
205
+ // developer would see, no more.
206
+ listen(doc, 'webjs:dev-overlay', function (e) {
207
+ var f = (e && e.detail) || {};
208
+ post('server-error', {
209
+ kind: f.kind || 'render',
210
+ message: clip(f.message || '', 2000),
211
+ file: f.file || null,
212
+ line: f.line || null,
213
+ path: here(),
214
+ });
215
+ });
216
+
217
+ // Inspect mode: hover highlights the element under the pointer and a click
218
+ // is captured (never reaching the app) and reported as `select`, carrying
219
+ // the nearest `data-webjs-src` (set by `WEBJS_SOURCE_LOCATIONS=1`, #1499).
220
+ var box = null;
221
+ var inspecting = false;
222
+ // The element the event is really about, through any shadow root.
223
+ function targetOf(e) {
224
+ var p = e.composedPath ? e.composedPath() : null;
225
+ var t = p && p.length ? p[0] : e.target;
226
+ return t && t.nodeType === 1 ? t : null;
227
+ }
228
+ // `closest('[data-webjs-src]')` that also climbs out of shadow roots, and
229
+ // only takes a `file:line` value: the importmap script carries an unrelated
230
+ // `data-webjs-src` (the app-source deploy id).
231
+ function sourceOf(el) {
232
+ for (var n = el; n; n = n.parentNode || n.host) {
233
+ if (n.nodeType === 1 && /:\d+$/.test(n.getAttribute('data-webjs-src') || '')) return n;
234
+ }
235
+ return null;
236
+ }
237
+ function rectOf(el) {
238
+ var r = el.getBoundingClientRect();
239
+ return { x: Math.round(r.left), y: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height) };
240
+ }
241
+ function onOver(e) {
242
+ var el = targetOf(e);
243
+ if (!el || !box) return;
244
+ var r = rectOf(sourceOf(el) || el);
245
+ box.style.transform = 'translate(' + r.x + 'px,' + r.y + 'px)';
246
+ box.style.width = r.width + 'px';
247
+ box.style.height = r.height + 'px';
248
+ box.style.display = 'block';
249
+ }
250
+ function swallow(e) {
251
+ e.preventDefault();
252
+ e.stopPropagation();
253
+ if (e.stopImmediatePropagation) e.stopImmediatePropagation();
254
+ }
255
+ var hostClick = false;
256
+ function onClick(e) {
257
+ // The host's own `navigate` clicks an anchor; that click must pass.
258
+ if (hostClick) return;
259
+ var el = targetOf(e);
260
+ if (!el) return;
261
+ swallow(e);
262
+ var src = sourceOf(el);
263
+ var picked = src || el;
264
+ post('select', {
265
+ src: src ? src.getAttribute('data-webjs-src') : null,
266
+ tag: picked.localName,
267
+ text: (picked.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
268
+ rect: rectOf(picked),
269
+ });
270
+ }
271
+ function setInspect(on) {
272
+ if (on === inspecting) return;
273
+ inspecting = on;
274
+ var verbs = ['pointerdown', 'mousedown', 'pointerup', 'mouseup'];
275
+ if (on) {
276
+ box = doc.createElement('div');
277
+ box.setAttribute('data-webjs-embed-highlight', '');
278
+ box.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483646;pointer-events:none;display:none;' +
279
+ 'box-sizing:border-box;border:2px solid #22d3ee;background:rgba(34,211,238,.12);border-radius:2px';
280
+ (doc.body || doc.documentElement).appendChild(box);
281
+ doc.addEventListener('pointerover', onOver, true);
282
+ doc.addEventListener('click', onClick, true);
283
+ for (var i = 0; i < verbs.length; i++) doc.addEventListener(verbs[i], swallow, true);
284
+ } else {
285
+ if (box) box.remove();
286
+ box = null;
287
+ doc.removeEventListener('pointerover', onOver, true);
288
+ doc.removeEventListener('click', onClick, true);
289
+ for (var j = 0; j < verbs.length; j++) doc.removeEventListener(verbs[j], swallow, true);
290
+ }
291
+ }
292
+ cleanups.push(function () { setInspect(false); });
293
+
294
+ // Host commands. Only from the parent window, only from an allowed origin.
295
+ listen(win, 'message', function (e) {
296
+ if (!e || e.source !== parent || origins.indexOf(e.origin) === -1) return;
297
+ var d = e.data;
298
+ if (!d || typeof d !== 'object' || d.source !== 'webjs-embed-host') return;
299
+ known = e.origin;
300
+ if (d.type === 'reload') {
301
+ win.location.reload();
302
+ } else if (d.type === 'navigate') {
303
+ // A local path only: one leading slash, not `//host` or `/\host`, which
304
+ // a browser would resolve to another origin.
305
+ var p = d.path;
306
+ if (typeof p !== 'string' || p.charAt(0) !== '/' || p.charAt(1) === '/' || p.charAt(1) === '\\') return;
307
+ // Click a detached-from-layout anchor so the client router takes it as
308
+ // the soft navigation a user click would be; with no router (an app that
309
+ // opted out, or a page shipping no component) the browser follows the
310
+ // link as a normal full navigation. Either way the router's own rules
311
+ // apply, so nothing here has to know whether it is installed.
312
+ var a = doc.createElement('a');
313
+ a.href = p;
314
+ a.style.display = 'none';
315
+ (doc.body || doc.documentElement).appendChild(a);
316
+ hostClick = true;
317
+ try { a.click(); } finally { hostClick = false; }
318
+ a.remove();
319
+ } else if (d.type === 'inspect') {
320
+ setInspect(d.enabled === true);
321
+ }
322
+ });
323
+
324
+ return {
325
+ uninstall: function () {
326
+ for (var i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
327
+ cleanups = [];
328
+ win.__webjsEmbedInstalled = false;
329
+ },
330
+ };
331
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The dev embed bridge, SERVER half (#1498).
3
+ *
4
+ * `webjs dev` with `WEBJS_EMBED_ORIGINS` set (a comma-separated list of parent
5
+ * origins, e.g. `https://builder.example,http://localhost:8080`), or the app's
6
+ * `webjs.dev.embedOrigins` list (#1504), makes an app ready to be previewed
7
+ * inside an iframe on those origins:
8
+ *
9
+ * 1. every HTML document gets a small inline script (the browser half,
10
+ * `dev-embed-client.js`) that reports page state to the parent and takes
11
+ * a few commands from it, carrying the request's CSP nonce like every
12
+ * other inline script WebJs emits;
13
+ * 2. the response stops refusing the frame: `X-Frame-Options` is dropped
14
+ * and a CSP `frame-ancestors` directive is widened to the listed origins.
15
+ *
16
+ * Unset, both are a no-op and a document is byte-identical to before. Under
17
+ * `webjs start` (`dev: false`) neither ever happens: the handler records no
18
+ * origins and `wrapHead` gates on `opts.dev` as well.
19
+ *
20
+ * The origins are a module-level switch (mirroring `setClientRouterEnabled`),
21
+ * set by `createRequestHandler`, so no option has to thread through every
22
+ * render path that reaches `wrapHead`.
23
+ */
24
+ import { readFileSync } from 'node:fs';
25
+ import { jsonForScriptTag } from './script-tag-json.js';
26
+ import { escapeAttr } from './ssr/escape.js';
27
+
28
+ /**
29
+ * The browser half, read once and inlined: its `export` keyword stripped, and
30
+ * its comments too, since they are the bulk of the file and every framed page
31
+ * would carry them. The source has no string or regex containing a comment
32
+ * opener, which `test/dev/dev-embed.test.js` pins.
33
+ */
34
+ const CLIENT_SRC = readFileSync(new URL('./dev-embed-client.js', import.meta.url), 'utf8')
35
+ .replace(/\/\*[\s\S]*?\*\//g, '')
36
+ .replace(/^\s*\/\/.*$/gm, '')
37
+ .replace(/^export /gm, '')
38
+ .replace(/^\s+/gm, '')
39
+ .trim();
40
+
41
+ /** @type {string[]} */
42
+ let _origins = [];
43
+
44
+ /**
45
+ * Parse `WEBJS_EMBED_ORIGINS`. Each entry must be an http(s) origin; a path or
46
+ * trailing slash is normalized away (`https://a.dev/` is `https://a.dev`).
47
+ * Anything else (a bare host, `*`, another scheme) is dropped with a warning,
48
+ * because a wildcard would hand every page's console and errors to any site
49
+ * that frames it.
50
+ *
51
+ * @param {string | undefined} raw
52
+ * @param {{ warn?: (msg: string) => void }} [opts]
53
+ * @returns {string[]} unique normalized origins, in input order
54
+ */
55
+ export function parseEmbedOrigins(raw, opts) {
56
+ if (typeof raw !== 'string' || !raw.trim()) return [];
57
+ const warn = (opts && opts.warn) || ((m) => console.warn(m));
58
+ /** @type {string[]} */
59
+ const out = [];
60
+ for (const part of raw.split(',')) {
61
+ const v = part.trim();
62
+ if (!v) continue;
63
+ let origin = '';
64
+ try {
65
+ const u = new URL(v);
66
+ if (u.protocol === 'http:' || u.protocol === 'https:') origin = u.origin;
67
+ } catch { /* not a URL */ }
68
+ if (!origin) {
69
+ warn(`[webjs] WEBJS_EMBED_ORIGINS: ignoring "${v}", expected an origin like https://example.com`);
70
+ continue;
71
+ }
72
+ if (!out.includes(origin)) out.push(origin);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * The effective embed origins (#1504): `WEBJS_EMBED_ORIGINS`, when set and
79
+ * non-empty, replaces the app's `webjs.dev.embedOrigins`; otherwise the config
80
+ * list applies. Both go through `parseEmbedOrigins`, so a bad entry is dropped
81
+ * with the same warning wherever it was written.
82
+ *
83
+ * @param {string | undefined} envRaw
84
+ * @param {unknown} configured the app's `webjs.dev.embedOrigins`
85
+ * @param {{ warn?: (msg: string) => void }} [opts]
86
+ * @returns {string[]}
87
+ */
88
+ export function resolveEmbedOrigins(envRaw, configured, opts) {
89
+ if (typeof envRaw === 'string' && envRaw.trim()) return parseEmbedOrigins(envRaw, opts);
90
+ if (!Array.isArray(configured)) return [];
91
+ return parseEmbedOrigins(configured.filter((o) => typeof o === 'string').join(','), opts);
92
+ }
93
+
94
+ /**
95
+ * Record the allowed embed origins for this process. `createRequestHandler`
96
+ * calls it at construction: the parsed env value in dev, `[]` otherwise.
97
+ *
98
+ * @param {string[]} origins
99
+ */
100
+ export function setEmbedOrigins(origins) {
101
+ _origins = Array.isArray(origins) ? origins.slice() : [];
102
+ }
103
+
104
+ /** @returns {string[]} the allowed embed origins (empty = bridge off) */
105
+ export function embedOrigins() {
106
+ return _origins;
107
+ }
108
+
109
+ /**
110
+ * The inline bridge `<script>` for a document, or `''` when the bridge is off.
111
+ * Includes its own leading newline so the off case adds zero bytes to the
112
+ * head (the same shape as the client-router opt-out flag beside it).
113
+ *
114
+ * @param {{ dev: boolean, nonce?: string }} opts
115
+ * @returns {string}
116
+ */
117
+ export function embedScriptTag(opts) {
118
+ if (!opts.dev || !_origins.length) return '';
119
+ const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
120
+ return `\n<script${n} data-webjs-embed>(function(){${CLIENT_SRC}\ninstallEmbedBridge(${jsonForScriptTag(_origins)});})();</script>`;
121
+ }
122
+
123
+ /**
124
+ * Let the listed origins frame a dev response: drop `X-Frame-Options` (it has
125
+ * no way to name another origin) and add the origins to a `frame-ancestors`
126
+ * directive in either CSP header. A policy without `frame-ancestors` already
127
+ * allows any parent, so it is left alone. Mutates `headers` in place.
128
+ *
129
+ * @param {Headers} headers
130
+ * @param {string[]} origins
131
+ */
132
+ export function allowEmbedFraming(headers, origins) {
133
+ if (!origins.length) return;
134
+ headers.delete('x-frame-options');
135
+ for (const name of ['content-security-policy', 'content-security-policy-report-only']) {
136
+ const value = headers.get(name);
137
+ if (!value) continue;
138
+ const next = value.split(';').map((d) => {
139
+ const t = d.trim();
140
+ if (!/^frame-ancestors(\s|$)/i.test(t)) return d;
141
+ const sources = t.split(/\s+/).slice(1).filter((s) => s !== "'none'");
142
+ for (const o of origins) if (!sources.includes(o)) sources.push(o);
143
+ return ` frame-ancestors ${sources.join(' ')}`;
144
+ }).join(';').trim();
145
+ try { headers.set(name, next); } catch { /* keep the original policy */ }
146
+ }
147
+ }
@@ -158,6 +158,14 @@ export function renderDevOverlay(f, currentPath) {
158
158
  (document.body || document.documentElement).appendChild(o);
159
159
  __wjOverlay = o;
160
160
  __wjFrame = f;
161
+ // Announce that an overlay is now on screen, for the dev embed bridge
162
+ // (#1498), which relays it to a host framing this page. Dispatched here,
163
+ // after the scope gate above, so a frame held for another url never counts.
164
+ try {
165
+ document.dispatchEvent(new CustomEvent('webjs:dev-overlay', {
166
+ detail: { kind: f.kind || 'render', message: f.message || '', file: f.file || null, line: f.line || null },
167
+ }));
168
+ } catch (_) { /* a listener throwing must not break the overlay */ }
161
169
  }
162
170
 
163
171
  /**
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The SSR half of dev source locations (#1499): a module load hook that runs
3
+ * `annotateSourceLocations` over every app module the SERVER imports, so SSR
4
+ * output carries the same `data-webjs-src` attributes the browser-served copy
5
+ * of the module renders (the browser half is in `dev/serve.js`).
6
+ *
7
+ * Installed only by the dev handler, only when `WEBJS_SOURCE_LOCATIONS=1`, and
8
+ * before any app module is imported (ESM caches by URL, so a module loaded
9
+ * first would stay unannotated until its next dev cache-bust re-import). The
10
+ * hook is process-global and cannot be uninstalled, so it is scoped to the app
11
+ * roots registered with it: a module outside every registered root is never
12
+ * touched.
13
+ *
14
+ * Node: `module.registerHooks` with a `load` hook that calls `nextLoad` FIRST
15
+ * and transforms its result. That keeps it chained with the `'use server'`
16
+ * seed hook (which it never sees anyway: `*.server.*` modules are excluded) and
17
+ * keeps Node's own TypeScript stripping, since a `.ts` result keeps its
18
+ * `module-typescript` format and Node strips it after the hook.
19
+ *
20
+ * Bun: `Bun.plugin` `onLoad`. A Bun `onLoad` must return contents for every
21
+ * path its filter matches, and when two plugins match the same path the first
22
+ * registered wins, so the filter itself excludes `node_modules` and `*.server.*`
23
+ * (a lookahead, which Bun's filter honours). The seed plugin therefore keeps
24
+ * every server module whatever the registration order.
25
+ */
26
+ import * as nodeModule from 'node:module';
27
+ import { readFileSync } from 'node:fs';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { relative, isAbsolute } from 'node:path';
30
+ import { serverRuntime } from './listener-core.js';
31
+ import {
32
+ annotateSourceLocations,
33
+ isSourceLocationCandidate,
34
+ sourceLocationFile,
35
+ } from './dev-source-locations.js';
36
+
37
+ /** App roots the hook annotates. */
38
+ const _roots = new Set();
39
+ let _installed = false;
40
+
41
+ /**
42
+ * The registered app root containing `abs`, or null.
43
+ * @param {string} abs
44
+ * @returns {string | null}
45
+ */
46
+ function rootFor(abs) {
47
+ for (const root of _roots) {
48
+ const rel = relative(root, abs);
49
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return root;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Annotate one module's source, or return null when it is not a candidate.
56
+ * @param {string} abs absolute path, no query
57
+ * @param {string} src
58
+ * @returns {string | null}
59
+ */
60
+ export function transformForSsr(abs, src) {
61
+ const root = rootFor(abs);
62
+ if (!root || !isSourceLocationCandidate(abs, root)) return null;
63
+ return annotateSourceLocations(src, sourceLocationFile(abs, root));
64
+ }
65
+
66
+ /**
67
+ * Node `load` hook. Fail-open: any surprise returns `nextLoad`'s result as is.
68
+ * @param {string} url
69
+ * @param {object} context
70
+ * @param {(u: string, c: object) => any} nextLoad
71
+ */
72
+ function sourceLocationLoadHook(url, context, nextLoad) {
73
+ const result = nextLoad(url, context);
74
+ try {
75
+ if (!url.startsWith('file:') || !result || result.source == null) return result;
76
+ if (result.format !== 'module' && result.format !== 'module-typescript') return result;
77
+ const abs = fileURLToPath(url.split('?')[0]);
78
+ const src = typeof result.source === 'string'
79
+ ? result.source
80
+ : Buffer.from(result.source).toString('utf8');
81
+ const out = transformForSsr(abs, src);
82
+ if (out == null || out === src) return result;
83
+ return { ...result, source: out };
84
+ } catch {
85
+ return result;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Escape a string for use inside a RegExp.
91
+ * @param {string} s
92
+ */
93
+ function escapeRe(s) {
94
+ return s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
95
+ }
96
+
97
+ /**
98
+ * Register `appDir` with the SSR source-location hook, installing the hook on
99
+ * the first call. Idempotent per root.
100
+ *
101
+ * @param {string} appDir absolute app root
102
+ * @returns {boolean} whether the hook is (now) installed
103
+ */
104
+ export function registerSourceLocationHook(appDir) {
105
+ if (_roots.has(appDir)) return _installed;
106
+ _roots.add(appDir);
107
+ if (serverRuntime() === 'bun') {
108
+ // One plugin per root, each with a filter that can never match a
109
+ // `node_modules` or `*.server.*` path (see the module comment).
110
+ const filter = new RegExp(
111
+ '^' + escapeRe(appDir) + '/(?:(?!node_modules/)(?!.*\\.server\\.).)*\\.m?[jt]s(\\?.*)?$',
112
+ );
113
+ /** @type {any} */ (globalThis).Bun.plugin({
114
+ name: 'webjs-source-locations',
115
+ setup(build) {
116
+ build.onLoad({ filter }, async (args) => {
117
+ const abs = args.path.split('?')[0];
118
+ const loader = /\.m?ts$/.test(abs) ? 'ts' : 'js';
119
+ const src = readFileSync(abs, 'utf8');
120
+ let contents = src;
121
+ try {
122
+ contents = transformForSsr(abs, src) ?? src;
123
+ } catch { /* fail open: the raw module */ }
124
+ return { contents, loader };
125
+ });
126
+ },
127
+ });
128
+ _installed = true;
129
+ return true;
130
+ }
131
+ if (_installed) return true;
132
+ if (typeof nodeModule.registerHooks !== 'function') {
133
+ console.warn('[webjs] WEBJS_SOURCE_LOCATIONS: this runtime has no module.registerHooks, so SSR output carries no data-webjs-src (client renders still do).');
134
+ return false;
135
+ }
136
+ nodeModule.registerHooks({ load: sourceLocationLoadHook });
137
+ _installed = true;
138
+ return true;
139
+ }