@webjsdev/server 0.8.73 → 0.8.75

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.73",
3
+ "version": "0.8.75",
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
@@ -13,6 +13,8 @@ import { readTextBounded, readFormDataBounded, payloadTooLarge, DEFAULT_MAX_BODY
13
13
 
14
14
  const enc = new TextEncoder();
15
15
  const dec = new TextDecoder();
16
+ // Base names. In production each is sent with the `__Host-` prefix
17
+ // (cookieName below); development over http keeps the plain name.
16
18
  const AUTH_COOKIE = 'webjs.auth';
17
19
  const STATE_COOKIE = 'webjs.auth.state';
18
20
  // Carries the post-sign-in target across the OAuth round trip (#1495). Signed
@@ -104,14 +106,33 @@ function parseCookies(header) {
104
106
  return out;
105
107
  }
106
108
 
109
+ /**
110
+ * The name a cookie is sent under. In production it carries the `__Host-`
111
+ * prefix (#1538): a browser stores a `__Host-` cookie only when it is
112
+ * `Secure`, has `Path=/` and has NO `Domain` attribute, so an app on a sibling
113
+ * subdomain (two apps on `a.example.app` and `b.example.app`, with
114
+ * `example.app` not on the Public Suffix List) can no longer plant its own
115
+ * `webjs.auth` with `Domain=.example.app` and sign a visitor in to the wrong
116
+ * account (cookie tossing, session fixation). Production never reads the
117
+ * unprefixed name: a fallback would reopen exactly that hole.
118
+ * Development over http keeps the plain name, since `__Host-` needs `Secure`.
119
+ *
120
+ * @param {string} base @param {boolean} secure
121
+ */
122
+ export function cookieName(base, secure) {
123
+ return secure ? `__Host-${base}` : base;
124
+ }
125
+
107
126
  function setCookie(name, value, maxAge, secure) {
108
127
  let s = `${name}=${encodeURIComponent(value)}; Max-Age=${Math.floor(maxAge / 1000)}; Path=/; HttpOnly; SameSite=Lax`;
109
128
  if (secure) s += '; Secure';
110
129
  return s;
111
130
  }
112
131
 
113
- function clearCookie(name) {
114
- return `${name}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`;
132
+ // A `__Host-` cookie is cleared only by a Set-Cookie that also says Secure;
133
+ // a browser ignores one without it, and the cookie would stay.
134
+ function clearCookie(name, secure = false) {
135
+ return `${name}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`;
115
136
  }
116
137
 
117
138
  // -- JWT --------------------------------------------------------------------
@@ -260,7 +281,7 @@ export function createAuth(config) {
260
281
  // page cacheable so a logged-in body could be served to the next visitor.
261
282
  markDynamicAccess();
262
283
  const cookies = parseCookies(req.headers.get('cookie') || '');
263
- const raw = cookies[AUTH_COOKIE];
284
+ const raw = cookies[cookieName(AUTH_COOKIE, secure())];
264
285
  if (!raw) return null;
265
286
 
266
287
  if (strategy === 'jwt') {
@@ -284,11 +305,11 @@ export function createAuth(config) {
284
305
  if (strategy === 'jwt') {
285
306
  let token = { sub: user.id, name: user.name, email: user.email, image: user.image, role: user.role, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + maxAge };
286
307
  if (cb.jwt) token = await cb.jwt({ token, user });
287
- return setCookie(AUTH_COOKIE, await encodeJwt(token, secret), maxAgeMs, secure());
308
+ return setCookie(cookieName(AUTH_COOKIE, secure()), await encodeJwt(token, secret), maxAgeMs, secure());
288
309
  }
289
310
  const sid = randomId();
290
311
  await dbStore.save(sid, user, maxAgeMs);
291
- return setCookie(AUTH_COOKIE, await sign(sid, secret), maxAgeMs, secure());
312
+ return setCookie(cookieName(AUTH_COOKIE, secure()), await sign(sid, secret), maxAgeMs, secure());
292
313
  }
293
314
 
294
315
  // -- auth() ---------------------------------------------------------------
@@ -333,12 +354,12 @@ export function createAuth(config) {
333
354
  async function signOutFn(opts = {}) {
334
355
  const hdrs = new Headers();
335
356
  hdrs.set('location', opts.redirectTo || pages.signOut || '/');
336
- hdrs.append('set-cookie', clearCookie(AUTH_COOKIE));
357
+ hdrs.append('set-cookie', clearCookie(cookieName(AUTH_COOKIE, secure()), secure()));
337
358
 
338
359
  if (strategy === 'database' && dbStore) {
339
360
  const r = opts.req || getRequest();
340
361
  if (r) {
341
- const raw = parseCookies(r.headers.get('cookie') || '')[AUTH_COOKIE];
362
+ const raw = parseCookies(r.headers.get('cookie') || '')[cookieName(AUTH_COOKIE, secure())];
342
363
  if (raw) { const sid = await unsign(raw, secret); if (sid) await dbStore.destroy(sid); }
343
364
  }
344
365
  }
@@ -361,14 +382,14 @@ export function createAuth(config) {
361
382
 
362
383
  const hdrs = new Headers();
363
384
  hdrs.set('location', url.toString());
364
- hdrs.append('set-cookie', setCookie(STATE_COOKIE, await sign(state, secret), 600_000, secure()));
385
+ hdrs.append('set-cookie', setCookie(cookieName(STATE_COOKIE, secure()), await sign(state, secret), 600_000, secure()));
365
386
  // The post-sign-in target rides in its own signed cookie (#1495). With no
366
387
  // valid target, any leftover cookie from an abandoned earlier attempt is
367
388
  // cleared so it cannot steer this sign-in somewhere stale.
368
389
  const target = safeRedirectPath(opts.redirectTo);
369
390
  hdrs.append('set-cookie', target
370
- ? setCookie(REDIRECT_COOKIE, await sign(target, secret), 600_000, secure())
371
- : clearCookie(REDIRECT_COOKIE));
391
+ ? setCookie(cookieName(REDIRECT_COOKIE, secure()), await sign(target, secret), 600_000, secure())
392
+ : clearCookie(cookieName(REDIRECT_COOKIE, secure()), secure()));
372
393
  return new Response(null, { status: 302, headers: hdrs });
373
394
  }
374
395
 
@@ -381,7 +402,7 @@ export function createAuth(config) {
381
402
  * @returns {Promise<string | null>}
382
403
  */
383
404
  async function readRedirectCookie(req) {
384
- const raw = parseCookies(req.headers.get('cookie') || '')[REDIRECT_COOKIE];
405
+ const raw = parseCookies(req.headers.get('cookie') || '')[cookieName(REDIRECT_COOKIE, secure())];
385
406
  if (!raw) return null;
386
407
  return safeRedirectPath(await unsign(raw, secret));
387
408
  }
@@ -392,7 +413,7 @@ export function createAuth(config) {
392
413
  const state = url.searchParams.get('state');
393
414
  if (!code || !state) return new Response('Missing code or state', { status: 400 });
394
415
 
395
- const rawState = parseCookies(req.headers.get('cookie') || '')[STATE_COOKIE];
416
+ const rawState = parseCookies(req.headers.get('cookie') || '')[cookieName(STATE_COOKIE, secure())];
396
417
  if (!rawState) return new Response('Missing state cookie', { status: 403 });
397
418
  const verified = await unsign(rawState, secret);
398
419
  if (!verified || verified !== state) return new Response('Invalid state', { status: 403 });
@@ -430,8 +451,8 @@ export function createAuth(config) {
430
451
  if (ok === false) {
431
452
  const denied = new Headers();
432
453
  denied.set('location', pages.error || '/?error=AccessDenied');
433
- denied.append('set-cookie', clearCookie(STATE_COOKIE));
434
- denied.append('set-cookie', clearCookie(REDIRECT_COOKIE));
454
+ denied.append('set-cookie', clearCookie(cookieName(STATE_COOKIE, secure()), secure()));
455
+ denied.append('set-cookie', clearCookie(cookieName(REDIRECT_COOKIE, secure()), secure()));
435
456
  return new Response(null, { status: 302, headers: denied });
436
457
  }
437
458
  }
@@ -439,8 +460,8 @@ export function createAuth(config) {
439
460
  const hdrs = new Headers();
440
461
  hdrs.set('location', (await readRedirectCookie(req)) || '/');
441
462
  hdrs.append('set-cookie', await writeSession(user));
442
- hdrs.append('set-cookie', clearCookie(STATE_COOKIE));
443
- hdrs.append('set-cookie', clearCookie(REDIRECT_COOKIE));
463
+ hdrs.append('set-cookie', clearCookie(cookieName(STATE_COOKIE, secure()), secure()));
464
+ hdrs.append('set-cookie', clearCookie(cookieName(REDIRECT_COOKIE, secure()), secure()));
444
465
  return new Response(null, { status: 302, headers: hdrs });
445
466
  }
446
467
 
@@ -492,10 +492,16 @@ function __webjsApplyReload(verdict) {
492
492
  // overlay comes back describing the CURRENT error. Dismissing afterwards
493
493
  // would race that push and wipe a legitimately new overlay.
494
494
  dismissDevOverlay();
495
- refresh(verdict).then(function (ok) {
496
- if (!ok) { location.reload(); return; }
497
- refreshStyles();
498
- }, function () { location.reload(); });
495
+ // Load the rebuilt stylesheets FIRST and swap the markup in only once
496
+ // they are in (#1535): a \`webjs.dev.regenerate\` compile takes 100ms to
497
+ // seconds, and markup swapped in before it lands paints a new class with
498
+ // no rule. The old sheets stay applied until the swap, then go.
499
+ preloadStyles().then(function (styles) {
500
+ return refresh(verdict).then(function (ok) {
501
+ if (!ok) { location.reload(); return; }
502
+ styles.commit();
503
+ });
504
+ }).then(null, function () { location.reload(); });
499
505
  return;
500
506
  }
501
507
  location.reload();
package/src/dev-styles.js CHANGED
@@ -17,39 +17,42 @@
17
17
  */
18
18
 
19
19
  /**
20
- * Re-request every same-origin stylesheet under `root`, cache-busted.
20
+ * Load the rebuilt stylesheets BEFORE the in-place refresh swaps the markup in
21
+ * (#1535), so a new class never paints without its rule.
21
22
  *
22
- * The replacement is inserted BESIDE the old link, and which of the two
23
- * survives depends on how the request went. That asymmetry is the whole point
24
- * and must not be collapsed into one shared handler:
23
+ * The re-request used to run AFTER the swap, and that order was the flash: the new
24
+ * markup is on screen while its stylesheet is still being rebuilt and fetched
25
+ * (a `webjs.dev.regenerate` compile is 100ms to seconds), so an element that
26
+ * gained a utility class shows with no rule for it until the sheet lands.
27
+ * Measured on a fixture with an 800ms compile: 56 animation frames (about
28
+ * 0.9s) painted the edited element unstyled. A full reload never had the
29
+ * problem, because a `<head>` stylesheet is render-blocking and the browser
30
+ * holds the old page until it loads.
25
31
  *
26
- * - On `load` the OLD link is dropped, so the page is never briefly unstyled.
27
- * - On `error` the NEW link is dropped and the old one kept. A re-request can
28
- * fail (the server is mid-restart, the file was renamed or deleted), and
29
- * removing the last working sheet there would leave the page permanently
30
- * unstyled, which is exactly the failure #936 and #1400 exist to prevent.
31
- *
32
- * Duplicates are collapsed to one link per IDENTITY, where the identity is
33
- * every attribute except the href's query. The duplicate being collapsed is the
34
- * one the head merge re-appends (the incoming bare-href link beside the busted
35
- * one), so the head would otherwise gain a link on every refresh. Keying on the
36
- * path alone would be wrong in the other direction: it would delete an author's
37
- * second, legitimately distinct link to the same file, such as a `media="print"`
38
- * sheet.
32
+ * So the cache-busted replacements go in first, BESIDE the current links
33
+ * (both apply meanwhile, the same rules twice), and the returned promise
34
+ * resolves once each has loaded or failed, or after `timeoutMs` for one that
35
+ * hangs. The caller swaps the markup in and then calls `commit()`, which drops
36
+ * each old link whose replacement loaded, plus any same-identity copy the head
37
+ * merge appended, keeping the replacement. A replacement that failed is
38
+ * removed and the old link kept (#936, #1400: never take the last working
39
+ * sheet down). One still pending at commit finishes the same way when it
40
+ * settles.
39
41
  *
40
42
  * @param {ParentNode} [root] where to scan, defaulting to the whole document.
41
- * Injectable so a browser test can scope itself to its own container.
42
43
  * @param {() => number} [now] cache-buster source, injectable for tests.
43
- * @returns {Element[]} the replacement links, so a caller (a test) can await them.
44
+ * @param {number} [timeoutMs] the longest to hold the swap for a stylesheet.
45
+ * @returns {Promise<{ links: Element[], commit: () => void }>}
44
46
  */
45
- export function refreshStyles(root, now) {
47
+ export function preloadStyles(root, now, timeoutMs) {
46
48
  const scope = root || (typeof document !== 'undefined' ? document : null);
47
- if (!scope) return [];
49
+ const limit = typeof timeoutMs === 'number' ? timeoutMs : 10000;
50
+ if (!scope) return Promise.resolve({ links: [], commit: function () {} });
48
51
  const stamp = now || Date.now;
49
52
  /** @type {Record<string, { el: Element, url: URL }>} */
50
53
  const kept = Object.create(null);
51
- const links = [].slice.call(scope.querySelectorAll('link[rel~="stylesheet"][href]'));
52
- for (const el of links) {
54
+ const current = [].slice.call(scope.querySelectorAll('link[rel~="stylesheet"][href]'));
55
+ for (const el of current) {
53
56
  let url;
54
57
  try { url = new URL(el.getAttribute('href'), location.href); } catch (_) { continue; }
55
58
  if (url.origin !== location.origin) continue;
@@ -57,7 +60,21 @@ export function refreshStyles(root, now) {
57
60
  if (kept[key]) { if (el.parentNode) el.parentNode.removeChild(el); continue; }
58
61
  kept[key] = { el, url };
59
62
  }
60
- const added = [];
63
+ let committed = false;
64
+ /** @type {Array<{ key: string, old: Element, next: Element, state: string }>} */
65
+ const pairs = [];
66
+ // Drop every other copy of this sheet (the old link, a copy the head merge
67
+ // re-appended) so exactly the replacement is left.
68
+ const settleLoaded = function (pair) {
69
+ const links = [].slice.call(scope.querySelectorAll('link[rel~="stylesheet"][href]'));
70
+ for (const el of links) {
71
+ if (el === pair.next) continue;
72
+ let url;
73
+ try { url = new URL(el.getAttribute('href'), location.href); } catch (_) { continue; }
74
+ if (url.origin === location.origin && identityKey(el, url) === pair.key && el.parentNode) el.parentNode.removeChild(el);
75
+ }
76
+ };
77
+ const waits = [];
61
78
  for (const key of Object.keys(kept)) {
62
79
  const old = kept[key].el;
63
80
  const url = kept[key].url;
@@ -65,17 +82,34 @@ export function refreshStyles(root, now) {
65
82
  url.searchParams.set('__webjs_dev', String(stamp()));
66
83
  const next = /** @type {Element} */ (old.cloneNode(false));
67
84
  next.setAttribute('href', url.pathname + url.search);
68
- next.addEventListener('load', function () {
69
- if (old.parentNode) old.parentNode.removeChild(old);
70
- });
71
- next.addEventListener('error', function () {
72
- // Keep the sheet that still works rather than the one that just failed.
73
- if (next.parentNode) next.parentNode.removeChild(next);
74
- });
85
+ const pair = { key, old, next, state: 'pending' };
86
+ pairs.push(pair);
87
+ waits.push(new Promise(function (resolve) {
88
+ next.addEventListener('load', function () {
89
+ pair.state = 'load';
90
+ if (committed) settleLoaded(pair);
91
+ resolve(undefined);
92
+ });
93
+ next.addEventListener('error', function () {
94
+ pair.state = 'error';
95
+ if (next.parentNode) next.parentNode.removeChild(next);
96
+ resolve(undefined);
97
+ });
98
+ }));
75
99
  old.parentNode.insertBefore(next, old.nextSibling);
76
- added.push(next);
77
100
  }
78
- return added;
101
+ const commit = function () {
102
+ committed = true;
103
+ for (const pair of pairs) if (pair.state === 'load') settleLoaded(pair);
104
+ };
105
+ const links = pairs.map(function (p) { return p.next; });
106
+ if (!waits.length) return Promise.resolve({ links, commit });
107
+ return new Promise(function (resolve) {
108
+ let done = false;
109
+ const finish = function () { if (!done) { done = true; resolve({ links, commit }); } };
110
+ Promise.all(waits).then(finish);
111
+ setTimeout(finish, limit);
112
+ });
79
113
  }
80
114
 
81
115
  /**