@uniweb/runtime 0.14.0 → 0.14.2
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/dist/ssr.js +377 -22
- package/dist/ssr.js.map +1 -1
- package/package.json +2 -2
- package/src/default-fetcher.js +25 -3
- package/src/page-renderer.js +158 -0
- package/src/prefetch.js +154 -0
- package/src/setup.js +4 -1
- package/src/ssr.js +16 -0
- package/src/wire-foundation.js +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
|
|
45
45
|
"vite": "^7.3.1",
|
|
46
46
|
"vitest": "^4.1.7",
|
|
47
|
-
"@uniweb/build": "0.
|
|
47
|
+
"@uniweb/build": "0.37.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/default-fetcher.js
CHANGED
|
@@ -92,7 +92,12 @@ const KNOWN_OPERATORS = new Set(['where', 'limit', 'sort'])
|
|
|
92
92
|
* does not carry warns.
|
|
93
93
|
* @returns {{ resolve: (req: Object, ctx: Object) => Promise<{ data, error? }> }}
|
|
94
94
|
*/
|
|
95
|
-
export function createDefaultFetcher({ basePath = '', config = {}, dev = false } = {}) {
|
|
95
|
+
export function createDefaultFetcher({ basePath = '', config = {}, dev = false, records = null, fetch: fetchImpl = null } = {}) {
|
|
96
|
+
// The transport is injectable: a host executing fetches outside a browser (an SSR isolate)
|
|
97
|
+
// decides how a site-relative address such as `/_records/members` is dispatched — through its
|
|
98
|
+
// own origin or a service binding — and hands that in. Defaults to the global `fetch`, resolved
|
|
99
|
+
// at call time so a test stub installed later is honoured.
|
|
100
|
+
const doFetch = (input, init) => (fetchImpl || globalThis.fetch)(input, init)
|
|
96
101
|
const pathPrefix = basePath && basePath !== '/' ? basePath.replace(/\/$/, '') : ''
|
|
97
102
|
|
|
98
103
|
const baseUrl = typeof config?.baseUrl === 'string'
|
|
@@ -140,6 +145,22 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false }
|
|
|
140
145
|
: null
|
|
141
146
|
const envelope = { ...(style.defaultEnvelope || {}), ...(siteEnvelope || {}) }
|
|
142
147
|
|
|
148
|
+
// ⭐ The LIVE LANE's envelope is the backend's. `config.records` is stamped by the backend
|
|
149
|
+
// that answers a records request, so where the array sits in ITS response is its to
|
|
150
|
+
// declare: `records.envelope.records` — the KEY says what it holds, the VALUE is the JSON
|
|
151
|
+
// key the array sits under (`{ records: "entries" }` ⇒ body.entries). That spelling is the
|
|
152
|
+
// agreed one (2026-08-30: `collection` retired; ⛔ not `list`, which is a URL pattern on the
|
|
153
|
+
// same stamp). It applies only to a request that resolved to that lane (`endpoint` set)
|
|
154
|
+
// and wins over the site's own `fetcher.envelope`, which describes the author's backend.
|
|
155
|
+
// Ruled 2026-09-03 [Diego]: the backend sets `config.records`; the fetch comes from the
|
|
156
|
+
// runtime. Until this line the runtime resolved `list`/`record` off the stamp and ignored
|
|
157
|
+
// its envelope.
|
|
158
|
+
const stampedArrayKey = (records?.envelope && typeof records.envelope === 'object'
|
|
159
|
+
&& typeof records.envelope.records === 'string' && records.envelope.records.length)
|
|
160
|
+
? records.envelope.records
|
|
161
|
+
: null
|
|
162
|
+
const laneEnvelope = stampedArrayKey ? { list: stampedArrayKey } : null
|
|
163
|
+
|
|
143
164
|
return {
|
|
144
165
|
/**
|
|
145
166
|
* Cache-key function. The default-fetcher's cache key includes only
|
|
@@ -287,7 +308,7 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false }
|
|
|
287
308
|
if (Object.keys(headers).length) init.headers = headers
|
|
288
309
|
|
|
289
310
|
try {
|
|
290
|
-
const response = await
|
|
311
|
+
const response = await doFetch(target, init)
|
|
291
312
|
|
|
292
313
|
// Per-request envelope (set by object-form `detail:`) wins over
|
|
293
314
|
// site-level envelope. This lets a detail query declare its own
|
|
@@ -295,7 +316,8 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false }
|
|
|
295
316
|
const requestEnvelope = (request.envelope && typeof request.envelope === 'object')
|
|
296
317
|
? request.envelope
|
|
297
318
|
: null
|
|
298
|
-
const effectiveEnvelope = requestEnvelope
|
|
319
|
+
const effectiveEnvelope = requestEnvelope
|
|
320
|
+
?? (endpoint && laneEnvelope ? { ...envelope, ...laneEnvelope } : envelope)
|
|
299
321
|
|
|
300
322
|
if (!response.ok) {
|
|
301
323
|
// If `envelope.error` is configured, try to extract a human message
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composed render entry — resolve a route, render it, inject it into a shell.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ **This exists because framework had two callers of one unshared sequence, not
|
|
5
|
+
* because a consumer asked.** `@uniweb/runtime/ssr` exported every step of the
|
|
6
|
+
* per-page render and never the sequence, so each host assembled it:
|
|
7
|
+
*
|
|
8
|
+
* - `@uniweb/build`'s `prerender.js` — `renderPage` → classify → `injectPageContent`,
|
|
9
|
+
* once per page in its loop.
|
|
10
|
+
* - an SSR isolate rendering per request — `resolvePage` → `renderPage` →
|
|
11
|
+
* `injectPageContent`, and it wrote the route lookup three times in three files
|
|
12
|
+
* before `resolvePage` was exported at all (see that function's header).
|
|
13
|
+
*
|
|
14
|
+
* Two unlike callers is what makes the interface honest: a build bakes files and an
|
|
15
|
+
* isolate answers a request, so anything only one of them needs stayed out.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ WHAT IS DELIBERATELY NOT IN HERE, and the boundary is the point:
|
|
18
|
+
*
|
|
19
|
+
* - **Shell assembly.** The shell arrives built. The import map, the CDN base and
|
|
20
|
+
* cache headers are host layout, and a runtime that assembled them would be
|
|
21
|
+
* modelling a deployment it cannot see (hosting drew this line themselves,
|
|
22
|
+
* 2026-09-03; `framework/CLAUDE.md` § *Serve locations are read, never constructed*).
|
|
23
|
+
* - **Init and hydration.** The two lanes differ REALLY here, not incidentally: a
|
|
24
|
+
* build initializes once and hydrates every collection up front, an isolate
|
|
25
|
+
* initializes per locale and prefetches per route. Folding either in would fit
|
|
26
|
+
* one caller and lie to the other. `initPrerender` / `initPrerenderForLocale` /
|
|
27
|
+
* `prefetchPageData` / `hydrateDataStore` stay separate exports, and
|
|
28
|
+
* `prefetchAndHydrate` below is the isolate's two-step, not a general one.
|
|
29
|
+
* - **Anything build-only.** `injectBuildData` stays in the build lane; it is the
|
|
30
|
+
* other half of the head seam and has its own parity guard.
|
|
31
|
+
*/
|
|
32
|
+
import { resolvePage, renderPage, classifyRenderError, injectPageContent } from './ssr-renderer.js'
|
|
33
|
+
import { hydrateDataStore } from './wire-foundation.js'
|
|
34
|
+
import { prefetchPageData } from './prefetch.js'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A renderer bound to one initialized Website and one shell.
|
|
38
|
+
*
|
|
39
|
+
* Create it once per locale (the Website is already locale-sliced by
|
|
40
|
+
* `initPrerenderForLocale`) and call `render` per route or per page.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} opts
|
|
43
|
+
* @param {Object} opts.website - `uniweb.activeWebsite`, already initialized
|
|
44
|
+
* @param {string} opts.shell - the HTML shell to inject into, taken as given
|
|
45
|
+
* @returns {{ website: Object, render: Function }}
|
|
46
|
+
*/
|
|
47
|
+
export function createPageRenderer({ website, shell }) {
|
|
48
|
+
if (!website) throw new Error('createPageRenderer: `website` is required')
|
|
49
|
+
if (typeof shell !== 'string') throw new Error('createPageRenderer: `shell` must be an HTML string')
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render one page.
|
|
53
|
+
*
|
|
54
|
+
* ⭐ Returns an OUTCOME rather than throwing or returning a bare string, because
|
|
55
|
+
* the two callers branch differently on the same three cases and neither wants an
|
|
56
|
+
* exception: a build logs and keeps going so one broken section cannot fail a whole
|
|
57
|
+
* site, an isolate decides a status code and a cache policy. Same reasoning as
|
|
58
|
+
* `prefetchPageData`'s per-entry outcome.
|
|
59
|
+
*
|
|
60
|
+
* @param {string|Object} target - a route (`/blog/1`, resolved through the same
|
|
61
|
+
* matcher the browser uses, so a dynamic route works) or an already-resolved
|
|
62
|
+
* Page, which the build lane already holds from its own loop.
|
|
63
|
+
* @param {Object} [options]
|
|
64
|
+
* @param {Object} [options.inject] - extra options forwarded to `injectPageContent`
|
|
65
|
+
* @returns {{ outcome: 'rendered'|'notFound'|'failed', html: string|null,
|
|
66
|
+
* page: Object|null, error: {type: string, message: string}|null }}
|
|
67
|
+
*/
|
|
68
|
+
function render(target, { inject = {} } = {}) {
|
|
69
|
+
const page = typeof target === 'string' ? resolvePage(website, target) : target
|
|
70
|
+
|
|
71
|
+
// ⛔ Not an error: nothing matched, which is a genuine 404 and the caller's to
|
|
72
|
+
// turn into one — a build skips it, an isolate serves its 404 page with a 404
|
|
73
|
+
// status. Returning `failed` here would make those indistinguishable.
|
|
74
|
+
if (!page) return { outcome: 'notFound', html: null, page: null, error: null }
|
|
75
|
+
|
|
76
|
+
let result
|
|
77
|
+
try {
|
|
78
|
+
result = renderPage(page, website)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// `renderPage` handles its own errors, but a foundation can throw from
|
|
81
|
+
// module scope in ways it does not catch. Classify rather than propagate,
|
|
82
|
+
// so one page cannot take down a build loop or an isolate's request.
|
|
83
|
+
return { outcome: 'failed', html: null, page, error: classifyRenderError(err) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (result.error) return { outcome: 'failed', html: null, page, error: result.error }
|
|
87
|
+
|
|
88
|
+
// ⛔ `sectionOverrideCSS` LAST, so a caller's `inject` cannot displace it. It is
|
|
89
|
+
// computed by `renderPage` for this page — theme pinning and component vars —
|
|
90
|
+
// and a caller passing a same-named key would silently drop it, rendering a page
|
|
91
|
+
// that looks fine and is unstyled in exactly the places the author pinned. That
|
|
92
|
+
// is the empty-success shape this module keeps refusing elsewhere; the spread was
|
|
93
|
+
// the other way round for one commit.
|
|
94
|
+
const html = injectPageContent(shell, result.renderedContent, page, {
|
|
95
|
+
...inject,
|
|
96
|
+
sectionOverrideCSS: result.sectionOverrideCSS,
|
|
97
|
+
})
|
|
98
|
+
return { outcome: 'rendered', html, page, error: null }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { website, render }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Prefetch a route's data and hydrate it onto the graph — the isolate's two-step.
|
|
106
|
+
*
|
|
107
|
+
* ⭐ Its whole purpose is that a host stops assembling our structure by hand. It
|
|
108
|
+
* returns the prefetch outcomes rather than swallowing them, because a host reads
|
|
109
|
+
* them to tell "nothing was tried" from "everything tried failed", which is a
|
|
110
|
+
* different cache decision.
|
|
111
|
+
*
|
|
112
|
+
* ⛔ The build lane does NOT call this: it hydrates every collection once, before
|
|
113
|
+
* its page loop, from its own executor that honours the author's `prerender:` flag.
|
|
114
|
+
* That difference is why this is a named isolate helper and not a step inside
|
|
115
|
+
* `render`.
|
|
116
|
+
*
|
|
117
|
+
* @returns {Promise<Array<{config: Object, outcome: string, data: any, error?: string}>>}
|
|
118
|
+
*/
|
|
119
|
+
export async function prefetchAndHydrate({ website, content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
|
|
120
|
+
// ⛔ Guarded for the same reason `createPageRenderer` is, and it was not for one
|
|
121
|
+
// commit. `hydrateDataStore` no-ops on a graph with no `dataStore`, so a caller
|
|
122
|
+
// passing the wrong object gets a successful-looking prefetch, an unhydrated graph
|
|
123
|
+
// and a page that renders empty — no error anywhere. Fail where the mistake is.
|
|
124
|
+
if (!website?.dataStore) {
|
|
125
|
+
throw new Error('prefetchAndHydrate: `website` must be an initialized Website with a dataStore')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ⛔ THE TRANSPORT IS REQUIRED HERE, unlike on `prefetchPageData`, and this is the
|
|
129
|
+
// one place the difference matters.
|
|
130
|
+
//
|
|
131
|
+
// A function does NOT survive every isolate boundary. Measured by hosting under
|
|
132
|
+
// `wrangler dev` against a real Worker Loader, 2026-09-03: passed through an
|
|
133
|
+
// entrypoint's `fetch(Request)` with a JSON body the transport arrives
|
|
134
|
+
// **`undefined`**; passed as an argument to an RPC method it arrives as a callable
|
|
135
|
+
// function and the isolate invokes it. Only the RPC shape carries it.
|
|
136
|
+
//
|
|
137
|
+
// ⚠️ And `undefined` is not where it stops, which is the part their measurement
|
|
138
|
+
// could not see from outside our code. `createDefaultFetcher` resolves the
|
|
139
|
+
// transport as `fetchImpl || globalThis.fetch`, so a transport that failed to cross
|
|
140
|
+
// silently becomes THE ISOLATE'S OWN NETWORK — outside the host's timeout, byte
|
|
141
|
+
// budget and site-relative address resolution. With an absolute address it does not
|
|
142
|
+
// even fail: the request goes out from the wrong place and comes back
|
|
143
|
+
// `outcome: 'fetched'`. A wiring mistake wearing a success.
|
|
144
|
+
//
|
|
145
|
+
// ⇒ So the entry whose only caller crosses that boundary demands a real function
|
|
146
|
+
// rather than defaulting. A Node or browser caller that genuinely wants the global
|
|
147
|
+
// passes `fetch: globalThis.fetch` — one word, and it says so.
|
|
148
|
+
if (typeof fetch !== 'function') {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'prefetchAndHydrate: `fetch` must be a function. A transport does not survive a ' +
|
|
151
|
+
'JSON-serialized isolate boundary — pass it as an RPC method argument. ' +
|
|
152
|
+
'To use the ambient fetch deliberately, pass `fetch: globalThis.fetch`.'
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
const fetched = await prefetchPageData({ content, route, locale, fetch, dev, prerender })
|
|
156
|
+
hydrateDataStore(website, fetched)
|
|
157
|
+
return fetched
|
|
158
|
+
}
|
package/src/prefetch.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side data prefetch — the runtime executing a page's fetches for a host.
|
|
3
|
+
*
|
|
4
|
+
* L2 (graph state, no React): reads a payload, resolves the fetch configs the way the
|
|
5
|
+
* entity store does at render time, executes them through the runtime's own default
|
|
6
|
+
* fetcher, and returns the `[{ config, data }]` list `hydrateDataStore` expects.
|
|
7
|
+
*
|
|
8
|
+
* ⭐ Why this exists — one implementation of the fetch, in the runtime. A host that renders
|
|
9
|
+
* pages in an isolate hands the isolate `fetchedData`. Until this module the host had to
|
|
10
|
+
* compute that itself: resolve the configs, issue the requests, unwrap the responses in the
|
|
11
|
+
* shape the datastore expects — a copy of the runtime's logic, in another repo, drifting
|
|
12
|
+
* (the records envelope went silently unread that way on 2026-09-02). [Diego, 2026-09-03]:
|
|
13
|
+
* *the backend sets `config.records`; the fetch comes from the runtime.* The host now calls
|
|
14
|
+
* this and carries no copy. Hosting agreed to exactly that shape the same day.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ Contract with the host, deliberately small:
|
|
17
|
+
* - `content` the render payload (`site-content.json` / `__DATA__`), config included —
|
|
18
|
+
* `config.records`, `config.fetcher`, `config.base` are read from it.
|
|
19
|
+
* - `route` the page to prefetch for; a `[slug]` template resolves through the same
|
|
20
|
+
* matcher the SPA uses, so `/blog/post-1` finds `/blog/:slug`.
|
|
21
|
+
* - `fetch` how to dispatch a request. The runtime composes the address; the host
|
|
22
|
+
* decides how a site-relative one is reached (its origin, a binding).
|
|
23
|
+
* ⛔ **Crossing an isolate boundary, this survives only as an RPC method
|
|
24
|
+
* argument.** Through an entrypoint's `fetch(Request)` with a serialized
|
|
25
|
+
* body it arrives `undefined` (hosting, measured under `wrangler dev`
|
|
26
|
+
* against a real Worker Loader, 2026-09-03) — and the fetcher then falls
|
|
27
|
+
* back to `globalThis.fetch`, so the request leaves from the isolate,
|
|
28
|
+
* outside whatever budget the host wrapped around it. `prefetchAndHydrate`
|
|
29
|
+
* refuses a non-function for exactly this reason; this entry keeps the
|
|
30
|
+
* permissive default because the build and browser lanes call it in-process.
|
|
31
|
+
* - `prerender` whether a fetch is tried — `'always'` (default) tries every config; `'author'`
|
|
32
|
+
* honours the author's `prerender: false`. ⛔ The default is `'always'` because this
|
|
33
|
+
* entry has exactly one kind of caller: an isolate rendering per request, where the
|
|
34
|
+
* flag means nothing and always prerendering is the product ([Diego, 2026-07-28 and
|
|
35
|
+
* 2026-09-03]: "`prerender: false` is not for the isolate"). The build lane, which
|
|
36
|
+
* bakes static artifacts and does honour the flag, uses its own executor
|
|
37
|
+
* (`build/src/prerender.js`) and never calls this. `'author'` is the explicit opt-in
|
|
38
|
+
* for a caller that bakes; omitting the option must not silently reproduce the
|
|
39
|
+
* 2026-07-28 outcome — prefetch a no-op on a live-data template, page still 200.
|
|
40
|
+
* - returns one entry per DECLARED config, `{ config, outcome, data, error? }`, keyed
|
|
41
|
+
* downstream by `deriveCacheKey(config)`. `outcome` is `fetched`, `failed`
|
|
42
|
+
* (transport or HTTP error, `error` says which) or `skipped` (the author
|
|
43
|
+
* deferred it to the browser with `prerender: false`). `hydrateDataStore`
|
|
44
|
+
* takes the list as-is and hydrates only `fetched` entries — a host reads the
|
|
45
|
+
* outcomes to tell "nothing was tried" from "everything tried failed", which
|
|
46
|
+
* is a different cache decision (hosting, 2026-09-03).
|
|
47
|
+
*
|
|
48
|
+
* It resolves nothing the host owns and models no host route layout: every address is
|
|
49
|
+
* `{base}/…` from the payload, or an endpoint the host itself published in `config.records`.
|
|
50
|
+
*/
|
|
51
|
+
import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
|
|
52
|
+
import { deriveCacheKey } from '@uniweb/core/datastore'
|
|
53
|
+
import { routePatternToRegex } from '@uniweb/core/route-match'
|
|
54
|
+
import { resolveDefaultLocale } from '@uniweb/core/locale-config'
|
|
55
|
+
import { createDefaultFetcher } from './default-fetcher.js'
|
|
56
|
+
|
|
57
|
+
const isRefinement = (f) => f && typeof f === 'object' && f.refine === true
|
|
58
|
+
|
|
59
|
+
/** The page a route names — exact first, then the `[slug]` templates, like the SPA. */
|
|
60
|
+
export function findPageForRoute(content, route) {
|
|
61
|
+
const pages = content?.pages || []
|
|
62
|
+
const exact = pages.find((p) => p.route === route)
|
|
63
|
+
if (exact) return { page: exact, params: {} }
|
|
64
|
+
for (const page of pages) {
|
|
65
|
+
if (!page.isDynamic || !page.route) continue
|
|
66
|
+
const compiled = routePatternToRegex(page.route)
|
|
67
|
+
const m = compiled?.regex ? compiled.regex.exec(route) : null
|
|
68
|
+
if (m) return { page, params: Object.fromEntries((compiled.paramNames || []).map((n, i) => [n, m[i + 1]])) }
|
|
69
|
+
}
|
|
70
|
+
return { page: null, params: {} }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every fetch config a page will need at render time, resolved once and de-duplicated by
|
|
75
|
+
* cache key: the site-level fetch, the page's, its parent's, and each section's own
|
|
76
|
+
* (including nested sections), each through `resolveFetchConfigs` — the same resolver the
|
|
77
|
+
* entity store uses, so a host prefetches exactly what the render will ask for.
|
|
78
|
+
*
|
|
79
|
+
* @returns {Object[]} resolved fetch configs
|
|
80
|
+
*/
|
|
81
|
+
export function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
|
|
82
|
+
const { page } = findPageForRoute(content, route)
|
|
83
|
+
if (!page) return []
|
|
84
|
+
const pages = content?.pages || []
|
|
85
|
+
const parent = page.parent ? pages.find((p) => p.route === page.parent) : null
|
|
86
|
+
const options = {
|
|
87
|
+
locale,
|
|
88
|
+
defaultLocale: resolveDefaultLocale(content?.config) ?? null,
|
|
89
|
+
queries: content?.config?.queries ?? null,
|
|
90
|
+
records: content?.config?.records ?? null,
|
|
91
|
+
}
|
|
92
|
+
const out = new Map()
|
|
93
|
+
const add = (sources) => {
|
|
94
|
+
for (const cfg of resolveFetchConfigs(sources, options).values()) {
|
|
95
|
+
const key = deriveCacheKey(cfg)
|
|
96
|
+
if (!out.has(key)) out.set(key, cfg)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// The cascade a block sees: its own fetch (unless a refinement), page, parent, site.
|
|
100
|
+
add([page.fetch ?? null, parent?.fetch ?? null, content?.config?.fetch ?? null])
|
|
101
|
+
const walk = (sections) => {
|
|
102
|
+
for (const s of sections || []) {
|
|
103
|
+
if (s?.fetch && !isRefinement(s.fetch)) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, content?.config?.fetch ?? null])
|
|
104
|
+
if (s?.subsections) walk(s.subsections)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
walk(page.sections)
|
|
108
|
+
return [...out.values()]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Execute resolved fetch configs through the runtime's default fetcher.
|
|
113
|
+
*
|
|
114
|
+
* @param {Object[]} configs resolved configs (from `resolvePageFetchConfigs` or the host's own
|
|
115
|
+
* call to `resolveFetchConfigs`)
|
|
116
|
+
* @param {Object} opts
|
|
117
|
+
* @param {Object} opts.content the payload — `config.base`, `config.fetcher`, `config.records`
|
|
118
|
+
* @param {Function} [opts.fetch] the transport; defaults to the global `fetch`
|
|
119
|
+
* @param {boolean} [opts.dev]
|
|
120
|
+
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
|
|
121
|
+
*/
|
|
122
|
+
export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always' } = {}) {
|
|
123
|
+
if (prerender !== 'author' && prerender !== 'always') {
|
|
124
|
+
throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
|
|
125
|
+
}
|
|
126
|
+
const fetcher = createDefaultFetcher({
|
|
127
|
+
basePath: content?.config?.base || '',
|
|
128
|
+
config: content?.config?.fetcher ?? {},
|
|
129
|
+
records: content?.config?.records ?? null,
|
|
130
|
+
dev,
|
|
131
|
+
fetch,
|
|
132
|
+
})
|
|
133
|
+
const ctx = { website: null }
|
|
134
|
+
const out = []
|
|
135
|
+
for (const config of configs || []) {
|
|
136
|
+
if (!config) continue
|
|
137
|
+
if (prerender === 'author' && config.prerender === false) {
|
|
138
|
+
// The author deferred this one to the browser and the caller honours that. Present, so a
|
|
139
|
+
// host can count what was declared against what was tried; not hydrated.
|
|
140
|
+
out.push({ config, outcome: 'skipped', data: null })
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
const result = await fetcher.resolve(config, ctx)
|
|
144
|
+
if (result?.error) out.push({ config, outcome: 'failed', data: null, error: result.error })
|
|
145
|
+
else out.push({ config, outcome: 'fetched', data: result?.data ?? null })
|
|
146
|
+
}
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
|
|
151
|
+
export async function prefetchPageData({ content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
|
|
152
|
+
const configs = resolvePageFetchConfigs(content, route, { locale })
|
|
153
|
+
return executeFetchConfigs(configs, { content, fetch, dev, prerender })
|
|
154
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -244,8 +244,11 @@ function buildDefaultFetcher(content) {
|
|
|
244
244
|
// recognizes `baseUrl` and `envelope`; foundations with their own fetchers
|
|
245
245
|
// may read additional keys from the same block via ctx.website.config.fetcher.
|
|
246
246
|
const config = content?.config?.fetcher ?? {}
|
|
247
|
+
// The host's live-records stamp (`config.records`), when a backend set one: the fetcher
|
|
248
|
+
// reads its `envelope` for requests that resolved to that lane.
|
|
249
|
+
const records = content?.config?.records ?? null
|
|
247
250
|
const dev = !!(import.meta.env && import.meta.env.DEV)
|
|
248
|
-
return createDefaultFetcher({ basePath, config, dev })
|
|
251
|
+
return createDefaultFetcher({ basePath, config, dev, records })
|
|
249
252
|
}
|
|
250
253
|
|
|
251
254
|
/**
|
package/src/ssr.js
CHANGED
|
@@ -56,6 +56,22 @@ export {
|
|
|
56
56
|
generate404Html,
|
|
57
57
|
} from './ssr-renderer.js'
|
|
58
58
|
|
|
59
|
+
// Server-side prefetch — the runtime executing a page's fetches for a host, so an isolate
|
|
60
|
+
// receives `fetchedData` computed by our fetcher and the host carries no copy of it.
|
|
61
|
+
// [Diego, 2026-09-03]: the backend sets config.records; the fetch comes from the runtime.
|
|
62
|
+
export {
|
|
63
|
+
findPageForRoute,
|
|
64
|
+
resolvePageFetchConfigs,
|
|
65
|
+
executeFetchConfigs,
|
|
66
|
+
prefetchPageData,
|
|
67
|
+
} from './prefetch.js'
|
|
68
|
+
|
|
69
|
+
// The composed render entry — resolve, render, inject, as one call. Built because
|
|
70
|
+
// framework itself had two callers of this unshared sequence (the build's prerender
|
|
71
|
+
// loop and an SSR isolate), not because a consumer asked; see page-renderer.js for
|
|
72
|
+
// what it deliberately leaves to the host.
|
|
73
|
+
export { createPageRenderer, prefetchAndHydrate } from './page-renderer.js'
|
|
74
|
+
|
|
59
75
|
// Appearance. injectPageContent() already emits this for every prerendered
|
|
60
76
|
// page; exported for lanes that assemble a shell without a per-page render.
|
|
61
77
|
export { renderAppearanceBootScript } from './appearance.js'
|
package/src/wire-foundation.js
CHANGED
|
@@ -163,6 +163,9 @@ export function sliceContentForLocale(content, locale) {
|
|
|
163
163
|
export function hydrateDataStore(website, fetchedData) {
|
|
164
164
|
if (!website?.dataStore || !fetchedData?.length) return
|
|
165
165
|
for (const entry of fetchedData) {
|
|
166
|
+
// A `prefetchPageData` list carries every declared config with an `outcome`; only what was
|
|
167
|
+
// actually fetched enters the store. A list without outcomes (the SSG lane's) is all fetched.
|
|
168
|
+
if (entry.outcome && entry.outcome !== 'fetched') continue
|
|
166
169
|
website.dataStore.set(deriveCacheKey(entry.config), { data: entry.data })
|
|
167
170
|
}
|
|
168
171
|
}
|