@webjsdev/server 0.8.67 → 0.8.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.67",
3
+ "version": "0.8.69",
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 });
package/src/dev/config.js CHANGED
@@ -317,6 +317,28 @@ export async function readDevWatchPathsFromApp(appDir) {
317
317
  return out;
318
318
  }
319
319
 
320
+ /**
321
+ * The dev tooling switches in `webjs.dev` (#1504): `embedOrigins` (the embed
322
+ * bridge, #1498) and `sourceLocations` (#1499). Raw values; the env overrides
323
+ * and validation live with each feature (`resolveEmbedOrigins`,
324
+ * `sourceLocationsRequested`). A missing or unreadable package.json is both
325
+ * off.
326
+ *
327
+ * @param {string} appDir
328
+ * @returns {Promise<{ embedOrigins: unknown, sourceLocations: unknown }>}
329
+ */
330
+ export async function readDevToolingFromApp(appDir) {
331
+ let dev;
332
+ try {
333
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
334
+ dev = pkg && pkg.webjs && pkg.webjs.dev;
335
+ } catch {
336
+ dev = undefined;
337
+ }
338
+ if (!dev || typeof dev !== 'object') return { embedOrigins: undefined, sourceLocations: undefined };
339
+ return { embedOrigins: dev.embedOrigins, sourceLocations: dev.sourceLocations };
340
+ }
341
+
320
342
  /**
321
343
  * Resolve the node:http server timeouts (issue #237) from the app's
322
344
  * package.json `webjs.requestTimeoutMs` / `webjs.headersTimeoutMs` /
@@ -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 { resolveEmbedOrigins, setEmbedOrigins, allowEmbedFraming } from '../dev-embed.js';
42
45
  import {
43
46
  applyRedirects,
44
47
  applyTrailingSlash,
@@ -59,6 +62,7 @@ import {
59
62
  readAllowedOriginsFromApp,
60
63
  readCspConfigFromApp,
61
64
  readBodyLimitsFromApp,
65
+ readDevToolingFromApp,
62
66
  } from './config.js';
63
67
  import {
64
68
  exists, kebab, resolveRequestId, shouldAccessLog, loadAppEnv, collectRouteModules,
@@ -213,6 +217,16 @@ export async function createRequestHandler(opts) {
213
217
  // analysis pass re-reads it each rebuild so a dev toggle takes effect live.
214
218
  setClientRouterEnabled(await readClientRouterEnabled(appDir));
215
219
 
220
+ // Dev embed bridge (#1498): `WEBJS_EMBED_ORIGINS` (or `webjs.dev.embedOrigins`) lets the listed parent
221
+ // origins frame this dev server and receive the bridge's messages. Read once
222
+ // here, and only in dev, so a production handler records no origins and
223
+ // neither injects the script nor relaxes a framing header.
224
+ // `webjs.dev.embedOrigins` in package.json is the default (#1504); a set
225
+ // env var replaces it for one run.
226
+ const devTooling = dev ? await readDevToolingFromApp(appDir) : { embedOrigins: undefined, sourceLocations: undefined };
227
+ const embedOriginsValue = dev ? resolveEmbedOrigins(process.env.WEBJS_EMBED_ORIGINS, devTooling.embedOrigins) : [];
228
+ setEmbedOrigins(embedOriginsValue);
229
+
216
230
  const coreDir = locateCoreDir(appDir);
217
231
  // Switch the importmap between dist/ bundles and src/ per-file
218
232
  // URLs depending on whether the resolved @webjsdev/core install
@@ -278,6 +292,21 @@ export async function createRequestHandler(opts) {
278
292
  // on seeding would mean `webjs.seed: false` silently broke every no-JS form.
279
293
  await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
280
294
 
295
+ // Dev source locations (#1499): `WEBJS_SOURCE_LOCATIONS=1` (or
296
+ // `webjs.dev.sourceLocations: true`) under `webjs dev`
297
+ // stamps `data-webjs-src="<file>:<line>"` on the element opening tags of the
298
+ // app's `html` templates. Read once (the SSR load hook is process-global and
299
+ // cannot be uninstalled), never in prod. The browser half reads
300
+ // `state.sourceLocations` in serve.js; the SSR half is this load hook, which
301
+ // must be in place before any app module is imported.
302
+ // `webjs.dev.sourceLocations` is the default (#1504); the env var wins.
303
+ const sourceLocations = dev && sourceLocationsRequested(process.env, devTooling.sourceLocations);
304
+ if (sourceLocations) {
305
+ registerSourceLocationHook(appDir);
306
+ // Module URLs are realpaths, so a symlinked app dir registers both forms.
307
+ try { const real = realpathSync(appDir); if (real !== appDir) registerSourceLocationHook(real); } catch { /* keep appDir only */ }
308
+ }
309
+
281
310
  // When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
282
311
  // deterministic vendor map that is cheap to read (one file, no analysis, no
283
312
  // network). Resolve it AT BOOT and publish the build id immediately so the
@@ -430,6 +459,9 @@ export async function createRequestHandler(opts) {
430
459
  // settings (a multi-tenant embedder, or the differential elision test)
431
460
  // must not share it, or the second would serve the first's elided source.
432
461
  tsCache: new Map(),
462
+ // Dev source locations (#1499), fixed for the handler's lifetime, so the
463
+ // per-handler tsCache above never mixes annotated and plain bytes.
464
+ sourceLocations,
433
465
  // The most recent unresolved dev error frame (#264), or null. Pushed to the
434
466
  // SSE channel for the live overlay and replayed to a freshly-connected tab.
435
467
  // Dev-only (never populated when !dev); cleared on a successful rebuild.
@@ -996,6 +1028,12 @@ export async function createRequestHandler(opts) {
996
1028
  }
997
1029
  }
998
1030
 
1031
+ // Dev embed bridge (#1498): let the allowed origins frame the page. Runs
1032
+ // after every header source above (defaults, webjs.headers, middleware,
1033
+ // the CSP), so it overrides them all, which is the point: the variable
1034
+ // is the developer saying "this dev server is framed by these origins".
1035
+ if (embedOriginsValue.length) allowEmbedFraming(merged.headers, embedOriginsValue);
1036
+
999
1037
  // Server HTML cache write (#241): if the SSR marked this response as a
1000
1038
  // cache candidate (an opted-in `revalidate` page), store the FINAL body
1001
1039
  // 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
  );