@webjsdev/server 0.8.67 → 0.8.68

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,129 @@
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://crisp.app,http://localhost:8080`) makes an app ready
6
+ * to be previewed inside an iframe on those origins:
7
+ *
8
+ * 1. every HTML document gets a small inline script (the browser half,
9
+ * `dev-embed-client.js`) that reports page state to the parent and takes
10
+ * a few commands from it, carrying the request's CSP nonce like every
11
+ * other inline script WebJs emits;
12
+ * 2. the response stops refusing the frame: `X-Frame-Options` is dropped
13
+ * and a CSP `frame-ancestors` directive is widened to the listed origins.
14
+ *
15
+ * Unset, both are a no-op and a document is byte-identical to before. Under
16
+ * `webjs start` (`dev: false`) neither ever happens: the handler records no
17
+ * origins and `wrapHead` gates on `opts.dev` as well.
18
+ *
19
+ * The origins are a module-level switch (mirroring `setClientRouterEnabled`),
20
+ * set by `createRequestHandler`, so no option has to thread through every
21
+ * render path that reaches `wrapHead`.
22
+ */
23
+ import { readFileSync } from 'node:fs';
24
+ import { jsonForScriptTag } from './script-tag-json.js';
25
+ import { escapeAttr } from './ssr/escape.js';
26
+
27
+ /**
28
+ * The browser half, read once and inlined: its `export` keyword stripped, and
29
+ * its comments too, since they are the bulk of the file and every framed page
30
+ * would carry them. The source has no string or regex containing a comment
31
+ * opener, which `test/dev/dev-embed.test.js` pins.
32
+ */
33
+ const CLIENT_SRC = readFileSync(new URL('./dev-embed-client.js', import.meta.url), 'utf8')
34
+ .replace(/\/\*[\s\S]*?\*\//g, '')
35
+ .replace(/^\s*\/\/.*$/gm, '')
36
+ .replace(/^export /gm, '')
37
+ .replace(/^\s+/gm, '')
38
+ .trim();
39
+
40
+ /** @type {string[]} */
41
+ let _origins = [];
42
+
43
+ /**
44
+ * Parse `WEBJS_EMBED_ORIGINS`. Each entry must be an http(s) origin; a path or
45
+ * trailing slash is normalized away (`https://a.dev/` is `https://a.dev`).
46
+ * Anything else (a bare host, `*`, another scheme) is dropped with a warning,
47
+ * because a wildcard would hand every page's console and errors to any site
48
+ * that frames it.
49
+ *
50
+ * @param {string | undefined} raw
51
+ * @param {{ warn?: (msg: string) => void }} [opts]
52
+ * @returns {string[]} unique normalized origins, in input order
53
+ */
54
+ export function parseEmbedOrigins(raw, opts) {
55
+ if (typeof raw !== 'string' || !raw.trim()) return [];
56
+ const warn = (opts && opts.warn) || ((m) => console.warn(m));
57
+ /** @type {string[]} */
58
+ const out = [];
59
+ for (const part of raw.split(',')) {
60
+ const v = part.trim();
61
+ if (!v) continue;
62
+ let origin = '';
63
+ try {
64
+ const u = new URL(v);
65
+ if (u.protocol === 'http:' || u.protocol === 'https:') origin = u.origin;
66
+ } catch { /* not a URL */ }
67
+ if (!origin) {
68
+ warn(`[webjs] WEBJS_EMBED_ORIGINS: ignoring "${v}", expected an origin like https://example.com`);
69
+ continue;
70
+ }
71
+ if (!out.includes(origin)) out.push(origin);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * Record the allowed embed origins for this process. `createRequestHandler`
78
+ * calls it at construction: the parsed env value in dev, `[]` otherwise.
79
+ *
80
+ * @param {string[]} origins
81
+ */
82
+ export function setEmbedOrigins(origins) {
83
+ _origins = Array.isArray(origins) ? origins.slice() : [];
84
+ }
85
+
86
+ /** @returns {string[]} the allowed embed origins (empty = bridge off) */
87
+ export function embedOrigins() {
88
+ return _origins;
89
+ }
90
+
91
+ /**
92
+ * The inline bridge `<script>` for a document, or `''` when the bridge is off.
93
+ * Includes its own leading newline so the off case adds zero bytes to the
94
+ * head (the same shape as the client-router opt-out flag beside it).
95
+ *
96
+ * @param {{ dev: boolean, nonce?: string }} opts
97
+ * @returns {string}
98
+ */
99
+ export function embedScriptTag(opts) {
100
+ if (!opts.dev || !_origins.length) return '';
101
+ const n = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
102
+ return `\n<script${n} data-webjs-embed>(function(){${CLIENT_SRC}\ninstallEmbedBridge(${jsonForScriptTag(_origins)});})();</script>`;
103
+ }
104
+
105
+ /**
106
+ * Let the listed origins frame a dev response: drop `X-Frame-Options` (it has
107
+ * no way to name another origin) and add the origins to a `frame-ancestors`
108
+ * directive in either CSP header. A policy without `frame-ancestors` already
109
+ * allows any parent, so it is left alone. Mutates `headers` in place.
110
+ *
111
+ * @param {Headers} headers
112
+ * @param {string[]} origins
113
+ */
114
+ export function allowEmbedFraming(headers, origins) {
115
+ if (!origins.length) return;
116
+ headers.delete('x-frame-options');
117
+ for (const name of ['content-security-policy', 'content-security-policy-report-only']) {
118
+ const value = headers.get(name);
119
+ if (!value) continue;
120
+ const next = value.split(';').map((d) => {
121
+ const t = d.trim();
122
+ if (!/^frame-ancestors(\s|$)/i.test(t)) return d;
123
+ const sources = t.split(/\s+/).slice(1).filter((s) => s !== "'none'");
124
+ for (const o of origins) if (!sources.includes(o)) sources.push(o);
125
+ return ` frame-ancestors ${sources.join(' ')}`;
126
+ }).join(';').trim();
127
+ try { headers.set(name, next); } catch { /* keep the original policy */ }
128
+ }
129
+ }
@@ -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
+ }