@webjsdev/server 0.8.66 → 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.
- package/package.json +1 -1
- package/src/auth.js +87 -26
- package/src/dev/handler.js +33 -1
- package/src/dev/serve.js +24 -1
- package/src/dev-embed-client.js +331 -0
- package/src/dev-embed.js +129 -0
- package/src/dev-overlay.js +8 -0
- package/src/dev-source-locations-hook.js +139 -0
- package/src/dev-source-locations.js +447 -0
- package/src/ssr/head.js +2 -1
- package/src/webjs-config-validate.js +3 -3
- package/webjs-config.schema.json +95 -0
package/src/dev-embed.js
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|