@webjsdev/server 0.8.15 → 0.8.17
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/index.d.ts +91 -1
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/actions.js +12 -1
- package/src/component-elision.js +8 -0
- package/src/dev-error.js +129 -0
- package/src/dev-overlay.js +75 -0
- package/src/dev.js +114 -7
- package/src/file-storage.js +466 -0
- package/src/frame-render.js +172 -0
- package/src/html-cache.js +46 -3
- package/src/page-action.js +7 -0
- package/src/ssr.js +31 -0
- package/src/stream.js +95 -0
package/src/html-cache.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { getStore } from './cache.js';
|
|
28
28
|
import { STREAM_MARKER } from './conditional-get.js';
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
29
30
|
import { publishedBuildId } from './importmap.js';
|
|
30
31
|
import { dynamicAccessed } from './context.js';
|
|
31
32
|
|
|
@@ -53,6 +54,39 @@ export const HTML_CACHE_MARKER = 'x-webjs-html-cache';
|
|
|
53
54
|
*/
|
|
54
55
|
let _generation = 0;
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A fingerprint of the served app source, folded into every cache key (#318).
|
|
59
|
+
* The published build id keys on the IMPORTMAP (core + vendor) only, so a deploy
|
|
60
|
+
* that changes ONLY an app module's bytes does not move it, and a Redis-cached
|
|
61
|
+
* `revalidate` page survives that deploy still serving its baked-in OLD `?v`
|
|
62
|
+
* boot URLs (under #243 immutable caching those then content-address to stale
|
|
63
|
+
* bytes). dev.js sets this to a deterministic, location-independent digest of
|
|
64
|
+
* the browser-bound file set's content hashes (PROD only; '' in dev, where
|
|
65
|
+
* fs.watch handles staleness), so an app-source-changing deploy re-keys (a fresh
|
|
66
|
+
* render) while a no-change redeploy keeps the key (a warm cache survives). The
|
|
67
|
+
* client-router build id deliberately stays importmap-only: a partial-swap
|
|
68
|
+
* fragment already carries the new `?v` boot URLs, so app updates propagate on
|
|
69
|
+
* the next navigation without a destructive hard reload.
|
|
70
|
+
*
|
|
71
|
+
* @type {string}
|
|
72
|
+
*/
|
|
73
|
+
let _appSourceFp = '';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Set the app-source fingerprint folded into the HTML cache key (see
|
|
77
|
+
* `_appSourceFp`). Called by dev.js from `ensureReady` after the browser-bound
|
|
78
|
+
* file set + asset hashes are known, and again on each rebuild. The input is a
|
|
79
|
+
* deterministic, location-independent STRING (sorted `relpath:contentHash`
|
|
80
|
+
* lines); it is digested to a short hex so the key stays compact. An empty
|
|
81
|
+
* input clears the fingerprint (the key collapses to its prior shape).
|
|
82
|
+
*
|
|
83
|
+
* @param {string} raw
|
|
84
|
+
* @returns {void}
|
|
85
|
+
*/
|
|
86
|
+
export function setAppSourceFingerprint(raw) {
|
|
87
|
+
_appSourceFp = raw ? createHash('sha256').update(raw).digest('hex').slice(0, 16) : '';
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
/**
|
|
57
91
|
* Read the revalidation window (seconds) a page module opted into via
|
|
58
92
|
* `export const revalidate = N` (the Next idiom). Returns a positive finite
|
|
@@ -86,8 +120,13 @@ export function readRevalidate(pageModule) {
|
|
|
86
120
|
* naturally writes and reads under fresh keys. The cached HTML bakes the
|
|
87
121
|
* deploy's `data-webjs-build` importmap into its boot script, so a Redis
|
|
88
122
|
* store that survives a deploy must NOT let a v2 process serve a v1-body
|
|
89
|
-
* (resolving modules against stale vendor URLs)
|
|
90
|
-
*
|
|
123
|
+
* (resolving modules against stale vendor URLs);
|
|
124
|
+
* - the app-source fingerprint (#318), so a deploy that changes ONLY an app
|
|
125
|
+
* module's bytes (which does not move the importmap build id) also re-keys,
|
|
126
|
+
* instead of serving a cached body with stale `?v` boot URLs. Together the
|
|
127
|
+
* build id (vendor) and the app-source fingerprint (app modules) mean ANY
|
|
128
|
+
* deploy that changes the served output re-keys, while a no-change redeploy
|
|
129
|
+
* keeps every key so a warm cache survives.
|
|
91
130
|
*
|
|
92
131
|
* @param {URL} url
|
|
93
132
|
* @returns {string}
|
|
@@ -100,7 +139,11 @@ export function htmlCacheKey(url) {
|
|
|
100
139
|
? '?' + params.map(([k, v]) => `${k}=${v}`).join('&')
|
|
101
140
|
: '';
|
|
102
141
|
const build = publishedBuildId() || 'nobuild';
|
|
103
|
-
|
|
142
|
+
// The app-source fingerprint (#318) re-keys on an app-module-only deploy that
|
|
143
|
+
// the importmap-only build id misses. Empty (dev / no fingerprint) collapses
|
|
144
|
+
// to the prior key shape, so an unconfigured app is byte-identical.
|
|
145
|
+
const appfp = _appSourceFp ? `${_appSourceFp}:` : '';
|
|
146
|
+
return `${KEY_PREFIX}${build}:${appfp}${_generation}:${url.pathname}${search}`;
|
|
104
147
|
}
|
|
105
148
|
|
|
106
149
|
/**
|
package/src/page-action.js
CHANGED
|
@@ -207,6 +207,13 @@ export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
|
|
|
207
207
|
throw err;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// A page action MAY return a `Response` directly (e.g. a content-negotiated
|
|
211
|
+
// `streamResponse`, #248). Honor it verbatim, so the action owns the status +
|
|
212
|
+
// content type and the router applies it (a stream body surgically). With JS
|
|
213
|
+
// off the same action returns a normal ActionResult instead, so the PRG /
|
|
214
|
+
// re-render paths below still drive the no-JS form.
|
|
215
|
+
if (result instanceof Response) return result;
|
|
216
|
+
|
|
210
217
|
if (!isFailureResult(result)) {
|
|
211
218
|
// SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
|
|
212
219
|
// honored when it is a same-site local path; otherwise fall back to the
|
package/src/ssr.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
readHtmlCache,
|
|
14
14
|
HTML_CACHE_MARKER,
|
|
15
15
|
} from './html-cache.js';
|
|
16
|
+
import { requestedFrameId, extractFrameSubtree } from './frame-render.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* SSR a matched page route to a Response.
|
|
@@ -94,6 +95,31 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
94
95
|
? new Set(haveHeader.split(',').map((s) => s.trim()).filter(Boolean))
|
|
95
96
|
: null;
|
|
96
97
|
const body = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
|
|
98
|
+
|
|
99
|
+
// Frame subtree render (#253). A `<webjs-frame src>` self-load (or a
|
|
100
|
+
// click-driven frame nav) sends `x-webjs-frame: <id>` and applies ONLY the
|
|
101
|
+
// matching `<webjs-frame id>` subtree from the response, discarding the rest
|
|
102
|
+
// of the page. So when that header is present AND the requested frame is in
|
|
103
|
+
// the rendered output (the "isolable" case), return JUST that subtree: the
|
|
104
|
+
// bytes are extracted verbatim from the same full render, so the result is
|
|
105
|
+
// BYTE-EQUIVALENT to what the client would slice from a full-page response,
|
|
106
|
+
// but the full document shell + all the other regions never go on the wire.
|
|
107
|
+
// The frame swap path (applySwap in router-client.js) parses this body and
|
|
108
|
+
// does `doc.querySelector('webjs-frame#<id>')`, which finds the lone
|
|
109
|
+
// subtree exactly as it would in the full page. A streamed (Suspense)
|
|
110
|
+
// render is skipped (its bytes are not yet final). When the frame id is NOT
|
|
111
|
+
// found (an auth redirect to a login page, a route that dropped the frame),
|
|
112
|
+
// we fall through to the normal full-page render, where the client's
|
|
113
|
+
// existing `webjs:frame-missing` fallback handles the absence. A request
|
|
114
|
+
// with NO `x-webjs-frame` header never reaches this branch, so a normal
|
|
115
|
+
// page request is byte-identical to before this feature.
|
|
116
|
+
const frameId = requestedFrameId(opts.req);
|
|
117
|
+
if (frameId && suspenseCtx.pending.length === 0) {
|
|
118
|
+
const subtree = extractFrameSubtree(body, frameId);
|
|
119
|
+
if (subtree !== null) {
|
|
120
|
+
return htmlResponse(subtree, opts.status || 200, opts.req, url);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
97
123
|
// Module URLs for the page + every layout in its chain. These ride
|
|
98
124
|
// the importmap; the browser fetches each file as it walks the
|
|
99
125
|
// import graph. Combined with the modulepreload hints below, this
|
|
@@ -187,6 +213,11 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
187
213
|
if (typeof opts.onError === 'function') {
|
|
188
214
|
try { opts.onError(err); } catch { /* a throwing sink must not affect the response */ }
|
|
189
215
|
}
|
|
216
|
+
// Dev error overlay (#264): push a rich frame to the open tab so the
|
|
217
|
+
// overlay appears live. Dev-only + best-effort; never affects the response.
|
|
218
|
+
if (typeof opts.onDevError === 'function') {
|
|
219
|
+
try { opts.onDevError(err); } catch { /* a throwing sink must not affect the response */ }
|
|
220
|
+
}
|
|
190
221
|
// Error paths still need to honor the request's CSP nonce so the
|
|
191
222
|
// error page's boot scripts (when moduleUrls is non-empty) and
|
|
192
223
|
// the meta csp-nonce tag both pass strict-CSP enforcement.
|
package/src/stream.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side stream-action helpers (#248) that build the `<webjs-stream>` HTML
|
|
3
|
+
* the client applier (`renderStream` / the `webjs-stream` element) applies
|
|
4
|
+
* surgically.
|
|
5
|
+
*
|
|
6
|
+
* The payload is plain HTML, a `<webjs-stream action target>` wrapping one
|
|
7
|
+
* `<template>`. These helpers compose that string for the two delivery paths,
|
|
8
|
+
* a content-negotiated HTTP form response and a `broadcast()` / WS message, so
|
|
9
|
+
* an app never hand-writes the markup. A page `action` or a `route` handler
|
|
10
|
+
* branches on `acceptsStream(request)`, returning `streamResponse(...)` when the
|
|
11
|
+
* router asked for a stream and a normal render otherwise (the JS-off degrade).
|
|
12
|
+
*
|
|
13
|
+
* The helpers do NOT escape the CONTENT (it is server-authored HTML, like a
|
|
14
|
+
* template result). Escape any user-supplied substring yourself, the same way
|
|
15
|
+
* you would inside an `html` template hole.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The content type that negotiates and carries a stream-action response. */
|
|
19
|
+
export const STREAM_MIME = 'text/vnd.webjs-stream.html';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Report whether a request opted into a stream-action response. The client
|
|
23
|
+
* router adds the stream MIME to `Accept` on a form submission. With JS off the
|
|
24
|
+
* browser sends no such Accept, so this returns false and the app returns a
|
|
25
|
+
* normal render (the progressive-enhancement degrade).
|
|
26
|
+
*
|
|
27
|
+
* @param {Request | { headers?: Headers }} req
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function acceptsStream(req) {
|
|
31
|
+
const accept = req && req.headers && req.headers.get ? (req.headers.get('accept') || '') : '';
|
|
32
|
+
return accept.toLowerCase().includes(STREAM_MIME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Escape the target id for safe placement in a double-quoted attribute. */
|
|
36
|
+
function escapeAttr(s) {
|
|
37
|
+
return String(s == null ? '' : s)
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/"/g, '"')
|
|
40
|
+
.replace(/</g, '<')
|
|
41
|
+
.replace(/>/g, '>');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build one `<webjs-stream>` action element.
|
|
46
|
+
* @param {string} action One of append, prepend, before, after, replace, update, remove.
|
|
47
|
+
* @param {string} target The target element id.
|
|
48
|
+
* @param {string} [content] Inner HTML for the insert actions (ignored by remove).
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function build(action, target, content) {
|
|
52
|
+
const t = escapeAttr(target);
|
|
53
|
+
if (action === 'remove') {
|
|
54
|
+
return `<webjs-stream action="remove" target="${t}"></webjs-stream>`;
|
|
55
|
+
}
|
|
56
|
+
const inner = content == null ? '' : String(content);
|
|
57
|
+
return `<webjs-stream action="${action}" target="${t}"><template>${inner}</template></webjs-stream>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The stream-action builder. Each method returns one `<webjs-stream>` HTML
|
|
62
|
+
* string. Concatenate several and pass them to `streamResponse` or a
|
|
63
|
+
* `broadcast()`.
|
|
64
|
+
*/
|
|
65
|
+
export const stream = {
|
|
66
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
67
|
+
append: (target, content) => build('append', target, content),
|
|
68
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
69
|
+
prepend: (target, content) => build('prepend', target, content),
|
|
70
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
71
|
+
before: (target, content) => build('before', target, content),
|
|
72
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
73
|
+
after: (target, content) => build('after', target, content),
|
|
74
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
75
|
+
replace: (target, content) => build('replace', target, content),
|
|
76
|
+
/** @param {string} target @param {string} content @returns {string} */
|
|
77
|
+
update: (target, content) => build('update', target, content),
|
|
78
|
+
/** @param {string} target @returns {string} */
|
|
79
|
+
remove: (target) => build('remove', target),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Wrap one or more stream-action strings in a `Response` carrying the stream
|
|
84
|
+
* content type, so the client router applies it surgically. A non-200 status is
|
|
85
|
+
* fine (the router applies a stream body of any status).
|
|
86
|
+
*
|
|
87
|
+
* @param {...string} parts `<webjs-stream>` HTML strings from `stream.*`.
|
|
88
|
+
* @returns {Response}
|
|
89
|
+
*/
|
|
90
|
+
export function streamResponse(...parts) {
|
|
91
|
+
return new Response(parts.join('\n'), {
|
|
92
|
+
status: 200,
|
|
93
|
+
headers: { 'content-type': STREAM_MIME + '; charset=utf-8' },
|
|
94
|
+
});
|
|
95
|
+
}
|