@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.67",
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
- const redirectTo = opts.redirectTo || (data && data.redirectTo) || '/';
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') return oauthRedirect(provider, opts);
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) return new Response(null, { status: 302, headers: { location: pages.error || '/?error=AccessDenied' } });
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
- if (provider.type === 'credentials') {
427
- // Bounded body read (issue #237): the credentials sign-in endpoint is
428
- // public and unauthenticated, so cap its body to defend against
429
- // memory exhaustion. Credentials are small fixed-shape JSON / form
430
- // data, so the JSON / RPC limit applies. An over-limit body is a 413
431
- // before any parse, and is never buffered whole (see body-limit.js).
432
- const limits = getBodyLimits();
433
- const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
434
- let body = {};
435
- const ct = req.headers.get('content-type') || '';
436
- if (ct.includes('json')) {
437
- const { tooLarge, text } = await readTextBounded(req, limit);
438
- if (tooLarge) return payloadTooLarge();
439
- body = text ? JSON.parse(text) : {};
440
- } else if (ct.includes('form')) {
441
- const { tooLarge, formData } = await readFormDataBounded(req, limit);
442
- if (tooLarge) return payloadTooLarge();
443
- for (const [k, v] of formData.entries()) body[k] = v;
444
- }
445
- return signInFn('credentials', body, { req });
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 });
@@ -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
+ }