@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 +1 -1
- package/src/auth.js +37 -16
- package/src/dev/helpers.js +10 -4
- package/src/dev-styles.js +67 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/server",
|
|
3
|
-
"version": "0.8.
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
package/src/dev/helpers.js
CHANGED
|
@@ -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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
*
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
47
|
+
export function preloadStyles(root, now, timeoutMs) {
|
|
46
48
|
const scope = root || (typeof document !== 'undefined' ? document : null);
|
|
47
|
-
|
|
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
|
|
52
|
-
for (const el of
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
/**
|