@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.68",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
|
|
6
6
|
"main": "index.js",
|
package/src/auth.js
CHANGED
|
@@ -15,6 +15,10 @@ const enc = new TextEncoder();
|
|
|
15
15
|
const dec = new TextDecoder();
|
|
16
16
|
const AUTH_COOKIE = 'webjs.auth';
|
|
17
17
|
const STATE_COOKIE = 'webjs.auth.state';
|
|
18
|
+
// Carries the post-sign-in target across the OAuth round trip (#1495). Signed
|
|
19
|
+
// with the auth secret and short-lived (the same 10 minutes as the state
|
|
20
|
+
// cookie), so the callback only ever lands on a path this app chose.
|
|
21
|
+
const REDIRECT_COOKIE = 'webjs.auth.redirect';
|
|
18
22
|
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60; // 30 days (seconds)
|
|
19
23
|
|
|
20
24
|
// -- Web Crypto helpers -----------------------------------------------------
|
|
@@ -65,6 +69,27 @@ function randomId() {
|
|
|
65
69
|
return b64url(bytes.buffer);
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Accept a post-sign-in target only when it is a same-origin local path: one
|
|
74
|
+
* leading `/`, the next character neither `/` nor `\` (both make a
|
|
75
|
+
* protocol-relative URL a browser follows off-site), and no control
|
|
76
|
+
* characters or whitespace a browser might strip before resolving. Anything
|
|
77
|
+
* else returns `null` and the caller falls back to its default. A bad value is
|
|
78
|
+
* DROPPED, never repaired, since a repaired open redirect is still one.
|
|
79
|
+
*
|
|
80
|
+
* @param {unknown} value
|
|
81
|
+
* @returns {string | null}
|
|
82
|
+
*/
|
|
83
|
+
function safeRedirectPath(value) {
|
|
84
|
+
if (typeof value !== 'string') return null;
|
|
85
|
+
if (value.length === 0 || value.length > 2048) return null;
|
|
86
|
+
if (value[0] !== '/') return null;
|
|
87
|
+
if (value[1] === '/' || value[1] === '\\') return null;
|
|
88
|
+
// eslint-disable-next-line no-control-regex
|
|
89
|
+
if (/[\u0000-\u001f\u007f\s\\]/.test(value)) return null;
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
68
93
|
// -- Cookie helpers ---------------------------------------------------------
|
|
69
94
|
|
|
70
95
|
/** @param {string} header @returns {Record<string,string>} */
|
|
@@ -290,11 +315,16 @@ export function createAuth(config) {
|
|
|
290
315
|
if (ok === false) return new Response(null, { status: 302, headers: { location: pages.error || '/?error=AccessDenied' } });
|
|
291
316
|
}
|
|
292
317
|
const cookie = await writeSession(user);
|
|
293
|
-
|
|
318
|
+
// `opts.redirectTo` is the app's own server-side choice and is trusted.
|
|
319
|
+
// `data.redirectTo` usually arrives from a submitted form, so it goes
|
|
320
|
+
// through the same local-path check the OAuth flow uses (#1495).
|
|
321
|
+
const redirectTo = opts.redirectTo || safeRedirectPath(data && data.redirectTo) || '/';
|
|
294
322
|
return new Response(null, { status: 302, headers: { location: /** @type {string} */ (redirectTo), 'set-cookie': cookie } });
|
|
295
323
|
}
|
|
296
324
|
|
|
297
|
-
if (provider.type === 'oauth')
|
|
325
|
+
if (provider.type === 'oauth') {
|
|
326
|
+
return oauthRedirect(provider, { ...opts, redirectTo: opts.redirectTo || (data && data.redirectTo) });
|
|
327
|
+
}
|
|
298
328
|
return new Response('Unsupported provider type', { status: 400 });
|
|
299
329
|
}
|
|
300
330
|
|
|
@@ -332,9 +362,30 @@ export function createAuth(config) {
|
|
|
332
362
|
const hdrs = new Headers();
|
|
333
363
|
hdrs.set('location', url.toString());
|
|
334
364
|
hdrs.append('set-cookie', setCookie(STATE_COOKIE, await sign(state, secret), 600_000, secure()));
|
|
365
|
+
// The post-sign-in target rides in its own signed cookie (#1495). With no
|
|
366
|
+
// valid target, any leftover cookie from an abandoned earlier attempt is
|
|
367
|
+
// cleared so it cannot steer this sign-in somewhere stale.
|
|
368
|
+
const target = safeRedirectPath(opts.redirectTo);
|
|
369
|
+
hdrs.append('set-cookie', target
|
|
370
|
+
? setCookie(REDIRECT_COOKIE, await sign(target, secret), 600_000, secure())
|
|
371
|
+
: clearCookie(REDIRECT_COOKIE));
|
|
335
372
|
return new Response(null, { status: 302, headers: hdrs });
|
|
336
373
|
}
|
|
337
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Read the signed post-sign-in target the redirect leg stored, re-checking it
|
|
377
|
+
* as a local path (defence in depth: the signature proves this app wrote it,
|
|
378
|
+
* the check proves it is still a safe target). Null when absent or invalid.
|
|
379
|
+
*
|
|
380
|
+
* @param {Request} req
|
|
381
|
+
* @returns {Promise<string | null>}
|
|
382
|
+
*/
|
|
383
|
+
async function readRedirectCookie(req) {
|
|
384
|
+
const raw = parseCookies(req.headers.get('cookie') || '')[REDIRECT_COOKIE];
|
|
385
|
+
if (!raw) return null;
|
|
386
|
+
return safeRedirectPath(await unsign(raw, secret));
|
|
387
|
+
}
|
|
388
|
+
|
|
338
389
|
async function oauthCallback(req, provider) {
|
|
339
390
|
const url = new URL(req.url);
|
|
340
391
|
const code = url.searchParams.get('code');
|
|
@@ -376,13 +427,20 @@ export function createAuth(config) {
|
|
|
376
427
|
|
|
377
428
|
if (cb.signIn) {
|
|
378
429
|
const ok = await cb.signIn({ user, account: { provider: provider.id, accessToken } });
|
|
379
|
-
if (ok === false)
|
|
430
|
+
if (ok === false) {
|
|
431
|
+
const denied = new Headers();
|
|
432
|
+
denied.set('location', pages.error || '/?error=AccessDenied');
|
|
433
|
+
denied.append('set-cookie', clearCookie(STATE_COOKIE));
|
|
434
|
+
denied.append('set-cookie', clearCookie(REDIRECT_COOKIE));
|
|
435
|
+
return new Response(null, { status: 302, headers: denied });
|
|
436
|
+
}
|
|
380
437
|
}
|
|
381
438
|
|
|
382
439
|
const hdrs = new Headers();
|
|
383
|
-
hdrs.set('location', '/');
|
|
440
|
+
hdrs.set('location', (await readRedirectCookie(req)) || '/');
|
|
384
441
|
hdrs.append('set-cookie', await writeSession(user));
|
|
385
442
|
hdrs.append('set-cookie', clearCookie(STATE_COOKIE));
|
|
443
|
+
hdrs.append('set-cookie', clearCookie(REDIRECT_COOKIE));
|
|
386
444
|
return new Response(null, { status: 302, headers: hdrs });
|
|
387
445
|
}
|
|
388
446
|
|
|
@@ -401,7 +459,7 @@ export function createAuth(config) {
|
|
|
401
459
|
if (seg[0] === 'signin' && seg[1]) {
|
|
402
460
|
const p = providers.get(seg[1]);
|
|
403
461
|
if (!p || p.type !== 'oauth') return new Response('Unknown OAuth provider', { status: 404 });
|
|
404
|
-
return oauthRedirect(p, { req });
|
|
462
|
+
return oauthRedirect(p, { req, redirectTo: new URL(req.url).searchParams.get('redirectTo') });
|
|
405
463
|
}
|
|
406
464
|
if (seg[0] === 'callback' && seg[1]) {
|
|
407
465
|
const p = providers.get(seg[1]);
|
|
@@ -423,28 +481,31 @@ export function createAuth(config) {
|
|
|
423
481
|
if (seg[0] === 'signin' && seg[1]) {
|
|
424
482
|
const provider = providers.get(seg[1]);
|
|
425
483
|
if (!provider) return new Response('Unknown provider', { status: 404 });
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
484
|
+
// Bounded body read (issue #237): the sign-in endpoint is public and
|
|
485
|
+
// unauthenticated, so cap its body to defend against memory exhaustion.
|
|
486
|
+
// Credentials (and an OAuth form's `redirectTo`, #1495) are small
|
|
487
|
+
// fixed-shape JSON / form data, so the JSON / RPC limit applies. An
|
|
488
|
+
// over-limit body is a 413 before any parse, and is never buffered
|
|
489
|
+
// whole (see body-limit.js).
|
|
490
|
+
const limits = getBodyLimits();
|
|
491
|
+
const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
|
|
492
|
+
/** @type {Record<string, unknown>} */
|
|
493
|
+
let body = {};
|
|
494
|
+
const ct = req.headers.get('content-type') || '';
|
|
495
|
+
if (ct.includes('json')) {
|
|
496
|
+
const { tooLarge, text } = await readTextBounded(req, limit);
|
|
497
|
+
if (tooLarge) return payloadTooLarge();
|
|
498
|
+
body = text ? JSON.parse(text) : {};
|
|
499
|
+
} else if (ct.includes('form')) {
|
|
500
|
+
const { tooLarge, formData } = await readFormDataBounded(req, limit);
|
|
501
|
+
if (tooLarge) return payloadTooLarge();
|
|
502
|
+
for (const [k, v] of formData.entries()) body[k] = v;
|
|
503
|
+
}
|
|
504
|
+
if (provider.type === 'credentials') return signInFn('credentials', body, { req });
|
|
505
|
+
if (provider.type === 'oauth') {
|
|
506
|
+
const redirectTo = body.redirectTo ?? new URL(req.url).searchParams.get('redirectTo');
|
|
507
|
+
return oauthRedirect(provider, { req, redirectTo });
|
|
446
508
|
}
|
|
447
|
-
if (provider.type === 'oauth') return oauthRedirect(provider, { req });
|
|
448
509
|
}
|
|
449
510
|
if (seg[0] === 'signout') return signOutFn({ req });
|
|
450
511
|
return new Response('Not found', { status: 404 });
|
package/src/dev/handler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { join, resolve, sep } from 'node:path';
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
buildActionIndex,
|
|
17
17
|
} from '../actions.js';
|
|
18
18
|
import { registerActionHooks } from '../action-seed.js';
|
|
19
|
+
import { sourceLocationsRequested } from '../dev-source-locations.js';
|
|
20
|
+
import { registerSourceLocationHook } from '../dev-source-locations-hook.js';
|
|
19
21
|
import { readRegenerateRules, isRegenerateOutputPath } from '../dev-regenerate.js';
|
|
20
22
|
import { classifyChangedPath } from '../dev-classify.js';
|
|
21
23
|
import { ensureStripper } from '../ts-strip.js';
|
|
@@ -39,6 +41,7 @@ import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
|
|
|
39
41
|
import { resolveActionIdentity } from '../form-action-identity.js';
|
|
40
42
|
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, resolveAssetUrl } from '../asset-hash.js';
|
|
41
43
|
import { applySecurityHeaders, webRequestIsHttps } from '../headers.js';
|
|
44
|
+
import { parseEmbedOrigins, setEmbedOrigins, allowEmbedFraming } from '../dev-embed.js';
|
|
42
45
|
import {
|
|
43
46
|
applyRedirects,
|
|
44
47
|
applyTrailingSlash,
|
|
@@ -213,6 +216,13 @@ export async function createRequestHandler(opts) {
|
|
|
213
216
|
// analysis pass re-reads it each rebuild so a dev toggle takes effect live.
|
|
214
217
|
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
215
218
|
|
|
219
|
+
// Dev embed bridge (#1498): `WEBJS_EMBED_ORIGINS` lets the listed parent
|
|
220
|
+
// origins frame this dev server and receive the bridge's messages. Read once
|
|
221
|
+
// here, and only in dev, so a production handler records no origins and
|
|
222
|
+
// neither injects the script nor relaxes a framing header.
|
|
223
|
+
const embedOriginsValue = dev ? parseEmbedOrigins(process.env.WEBJS_EMBED_ORIGINS) : [];
|
|
224
|
+
setEmbedOrigins(embedOriginsValue);
|
|
225
|
+
|
|
216
226
|
const coreDir = locateCoreDir(appDir);
|
|
217
227
|
// Switch the importmap between dist/ bundles and src/ per-file
|
|
218
228
|
// URLs depending on whether the resolved @webjsdev/core install
|
|
@@ -278,6 +288,19 @@ export async function createRequestHandler(opts) {
|
|
|
278
288
|
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
279
289
|
await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
|
|
280
290
|
|
|
291
|
+
// Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` under `webjs dev`
|
|
292
|
+
// stamps `data-webjs-src="<file>:<line>"` on the element opening tags of the
|
|
293
|
+
// app's `html` templates. Read once (the SSR load hook is process-global and
|
|
294
|
+
// cannot be uninstalled), never in prod. The browser half reads
|
|
295
|
+
// `state.sourceLocations` in serve.js; the SSR half is this load hook, which
|
|
296
|
+
// must be in place before any app module is imported.
|
|
297
|
+
const sourceLocations = dev && sourceLocationsRequested();
|
|
298
|
+
if (sourceLocations) {
|
|
299
|
+
registerSourceLocationHook(appDir);
|
|
300
|
+
// Module URLs are realpaths, so a symlinked app dir registers both forms.
|
|
301
|
+
try { const real = realpathSync(appDir); if (real !== appDir) registerSourceLocationHook(real); } catch { /* keep appDir only */ }
|
|
302
|
+
}
|
|
303
|
+
|
|
281
304
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
282
305
|
// deterministic vendor map that is cheap to read (one file, no analysis, no
|
|
283
306
|
// network). Resolve it AT BOOT and publish the build id immediately so the
|
|
@@ -430,6 +453,9 @@ export async function createRequestHandler(opts) {
|
|
|
430
453
|
// settings (a multi-tenant embedder, or the differential elision test)
|
|
431
454
|
// must not share it, or the second would serve the first's elided source.
|
|
432
455
|
tsCache: new Map(),
|
|
456
|
+
// Dev source locations (#1499), fixed for the handler's lifetime, so the
|
|
457
|
+
// per-handler tsCache above never mixes annotated and plain bytes.
|
|
458
|
+
sourceLocations,
|
|
433
459
|
// The most recent unresolved dev error frame (#264), or null. Pushed to the
|
|
434
460
|
// SSE channel for the live overlay and replayed to a freshly-connected tab.
|
|
435
461
|
// Dev-only (never populated when !dev); cleared on a successful rebuild.
|
|
@@ -996,6 +1022,12 @@ export async function createRequestHandler(opts) {
|
|
|
996
1022
|
}
|
|
997
1023
|
}
|
|
998
1024
|
|
|
1025
|
+
// Dev embed bridge (#1498): let the allowed origins frame the page. Runs
|
|
1026
|
+
// after every header source above (defaults, webjs.headers, middleware,
|
|
1027
|
+
// the CSP), so it overrides them all, which is the point: the variable
|
|
1028
|
+
// is the developer saying "this dev server is framed by these origins".
|
|
1029
|
+
if (embedOriginsValue.length) allowEmbedFraming(merged.headers, embedOriginsValue);
|
|
1030
|
+
|
|
999
1031
|
// Server HTML cache write (#241): if the SSR marked this response as a
|
|
1000
1032
|
// cache candidate (an opted-in `revalidate` page), store the FINAL body
|
|
1001
1033
|
// now, after segment middleware has added any per-user Set-Cookie (which
|
package/src/dev/serve.js
CHANGED
|
@@ -28,6 +28,7 @@ import { stripTypeScript } from '../ts-strip.js';
|
|
|
28
28
|
import { serveDownloadedBundle } from '../vendor.js';
|
|
29
29
|
import { resolveImport } from '../module-graph.js';
|
|
30
30
|
import { elideImportsFromSource } from '../component-elision.js';
|
|
31
|
+
import { annotateSourceLocations, isSourceLocationCandidate, sourceLocationFile } from '../dev-source-locations.js';
|
|
31
32
|
import { basePath } from '../importmap.js';
|
|
32
33
|
import { withBasePath } from '../base-path.js';
|
|
33
34
|
import { versionModuleImports } from '../asset-hash.js';
|
|
@@ -322,6 +323,9 @@ export async function handleCore(req, ctx) {
|
|
|
322
323
|
moduleGraph: state.moduleGraph,
|
|
323
324
|
elidableComponents: state.elidableComponents,
|
|
324
325
|
appDir,
|
|
326
|
+
// Dev source locations (#1499): annotate the served module's `html`
|
|
327
|
+
// templates exactly as the SSR load hook annotates the server's copy.
|
|
328
|
+
sourceLocations: state.sourceLocations === true,
|
|
325
329
|
};
|
|
326
330
|
// A `?v=<hash>` app-module request is content-addressed -> immutable
|
|
327
331
|
// (#243); an un-fingerprinted request keeps the 1h fallback.
|
|
@@ -691,7 +695,7 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
691
695
|
try { source = await readFile(abs, 'utf8'); }
|
|
692
696
|
catch { return new Response('Not found', { status: 404 }); }
|
|
693
697
|
let code = elideImportsFromSource(
|
|
694
|
-
source, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
698
|
+
annotateServedModule(source, abs, elideOpts), abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
695
699
|
);
|
|
696
700
|
// Version same-origin relative import specifiers so the URL the browser
|
|
697
701
|
// fetches matches the `?v=`-versioned modulepreload + boot specifier (#369).
|
|
@@ -707,6 +711,24 @@ export async function jsModuleResponse(abs, dev, elideOpts, immutable) {
|
|
|
707
711
|
return new Response(code, { status: 200, headers });
|
|
708
712
|
}
|
|
709
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Dev source locations (#1499): stamp `data-webjs-src` on the element opening
|
|
716
|
+
* tags of a served app module's `html` templates, the same transform the SSR
|
|
717
|
+
* load hook applies to the server's copy, so the two renders agree. A no-op
|
|
718
|
+
* unless the handler was built with `WEBJS_SOURCE_LOCATIONS=1` under dev, and
|
|
719
|
+
* for any module outside the app or under `node_modules`. Runs on the stripped
|
|
720
|
+
* code, which keeps every line where the source had it.
|
|
721
|
+
*
|
|
722
|
+
* @param {string} code
|
|
723
|
+
* @param {string} abs
|
|
724
|
+
* @param {{ appDir: string, sourceLocations?: boolean } | undefined} opts
|
|
725
|
+
* @returns {string}
|
|
726
|
+
*/
|
|
727
|
+
function annotateServedModule(code, abs, opts) {
|
|
728
|
+
if (!opts || !opts.sourceLocations || !isSourceLocationCandidate(abs, opts.appDir)) return code;
|
|
729
|
+
return annotateSourceLocations(code, sourceLocationFile(abs, opts.appDir));
|
|
730
|
+
}
|
|
731
|
+
|
|
710
732
|
/**
|
|
711
733
|
* Strip TypeScript types from `source` via Node's built-in
|
|
712
734
|
* `module.stripTypeScriptTypes`. Position-preserving whitespace
|
|
@@ -815,6 +837,7 @@ export async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDe
|
|
|
815
837
|
throw err;
|
|
816
838
|
}
|
|
817
839
|
if (elideOpts) {
|
|
840
|
+
code = annotateServedModule(code, abs, elideOpts);
|
|
818
841
|
code = elideImportsFromSource(
|
|
819
842
|
code, abs, elideOpts.moduleGraph, elideOpts.elidableComponents, resolveImport, elideOpts.appDir,
|
|
820
843
|
);
|
|
@@ -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
|
+
}
|