@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.
- package/package.json +1 -1
- package/src/auth.js +87 -26
- package/src/dev/config.js +22 -0
- package/src/dev/handler.js +39 -1
- package/src/dev/serve.js +24 -1
- package/src/dev-embed-client.js +331 -0
- package/src/dev-embed.js +147 -0
- package/src/dev-overlay.js +8 -0
- package/src/dev-source-locations-hook.js +139 -0
- package/src/dev-source-locations.js +453 -0
- package/src/ssr/head.js +2 -1
- package/src/webjs-config-validate.js +3 -3
- package/webjs-config.schema.json +34 -0
|
@@ -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
|
+
}
|
package/src/dev-embed.js
ADDED
|
@@ -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
|
+
}
|
package/src/dev-overlay.js
CHANGED
|
@@ -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
|
+
}
|