@uniweb/runtime 0.26.7 → 0.27.0

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": "@uniweb/runtime",
3
- "version": "0.26.7",
3
+ "version": "0.27.0",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=20.19"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "^0.29.5",
39
+ "@uniweb/core": "^0.29.6",
40
40
  "@uniweb/theming": "^0.1.15"
41
41
  },
42
42
  "devDependencies": {
@@ -97,11 +97,8 @@ export const ISOLATE_API = Object.freeze({
97
97
  renderAppearanceBootScript: '0.8.30',
98
98
  // page resolution
99
99
  resolvePage: '0.9.5',
100
- // server-side prefetch — the runtime executing a page's fetches for a host
100
+ // which page a route names
101
101
  findPageForRoute: '0.14.1',
102
- resolvePageFetchConfigs: '0.14.1',
103
- executeFetchConfigs: '0.14.1',
104
- prefetchPageData: '0.14.1',
105
102
  // the render's data step — the same question asked off the graph the render uses
106
103
  loadPageData: '0.26.6',
107
104
  // the composed render entry
@@ -61,10 +61,16 @@ function blocksOf(page) {
61
61
  * the data of another, and nothing to see it.
62
62
  * @param {'always'|'author'} [options.prerender] - `'always'` asks for everything, which is what an
63
63
  * isolate rendering a visit's first page wants; `'author'` honours `prerender: false`.
64
+ * @param {Object} [options.cache] - a `DataStore` to answer from and fill, shared across calls.
65
+ * ⭐ For a caller that runs the step for MANY pages of one site — a static build — where every
66
+ * page asks for the site's and its parent's data and reading each address once is the difference
67
+ * between N reads and N×M. ⛔ Not for a host serving requests: there a page's data is asked
68
+ * fresh, and the default (a cache per call) is what does that. A shared cache changes nothing
69
+ * about what is ASKED or returned — every request is still recorded, hit or miss.
64
70
  * @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: *, meta?: Object, error?: string }>>}
65
71
  * one entry per request the render will make, keyed downstream by `deriveCacheKey(config)`
66
72
  */
67
- export async function loadPageData({ website, route, fetch, dev = false, locale = null, prerender = 'always' }) {
73
+ export async function loadPageData({ website, route, fetch, dev = false, locale = null, prerender = 'always', cache = null }) {
68
74
  if (prerender !== 'author' && prerender !== 'always') {
69
75
  throw new Error(`loadPageData: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
70
76
  }
@@ -91,10 +97,11 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
91
97
  throw new Error('loadPageData: `route` must be a route or a Page from this Website — page data is not enough to say what its render reads.')
92
98
  }
93
99
 
94
- // A cache of this request's own: the graph's belongs to whatever it is rendering, and a page's
95
- // data is asked fresh per request. The site's transports still apply (`dispatcherFor`).
100
+ // A cache of this call's own unless the caller shares one: the graph's belongs to whatever it is
101
+ // rendering, and a page's data is asked fresh per request. The site's transports still apply
102
+ // (`dispatcherFor`).
96
103
  const dispatcher = website.dispatcherFor({
97
- dataStore: new DataStore(),
104
+ dataStore: cache ?? new DataStore(),
98
105
  defaultFetcher: createDefaultFetcher({ basePath: website.config?.base || '', dev, fetch }),
99
106
  })
100
107
 
package/src/ssr.js CHANGED
@@ -63,18 +63,16 @@ export {
63
63
  // hydration.
64
64
  export { loadPageData } from './load-page-data.js'
65
65
 
66
- // Server-side prefetch from the PAYLOAD ALONE — the entry that predates the step above. It cannot
67
- // know a component's declared keys, a page's layout or a site's transports, so it asks for every
68
- // fetch on every level and still gets four cases wrong
69
- // (`kb/framework/plans/what-a-page-needs.md` §0). ⛔ Kept for callers that have no Website to hand;
70
- // prefer `loadPageData`.
71
- // [Diego, 2026-09-03]: the backend sets the records service; the fetch comes from the runtime.
72
- export {
73
- findPageForRoute,
74
- resolvePageFetchConfigs,
75
- executeFetchConfigs,
76
- prefetchPageData,
77
- } from './prefetch.js'
66
+ // Which page a route names — the rule, not a copy of it (`@uniweb/core/route-match`). A host
67
+ // resolving a route before it asks us anything imports this rather than comparing strings: the
68
+ // SPA normalizes a trailing slash, and a copy that did not made `/about/` prefetch nothing and
69
+ // then render fine (2026-09-12).
70
+ // ⛔ `prefetchPageData`, `resolvePageFetchConfigs` and `executeFetchConfigs` are GONE (2026-09-20).
71
+ // They worked from the payload alone, which cannot say which keys a component declares, which
72
+ // layout a page draws, or which transport a key is routed to — four measured cases wrong
73
+ // (`kb/framework/plans/what-a-page-needs.md` §0). `loadPageData` above is the step that replaced
74
+ // them, and the one host that called them confirmed the switch before they went.
75
+ export { findPageForRoute } from '@uniweb/core/route-match'
78
76
 
79
77
  // The composed render entry — resolve, render, inject, as one call. Built because
80
78
  // framework itself had two callers of this unshared sequence (the build's prerender
@@ -148,14 +148,18 @@ export function sliceContentForLocale(content, locale) {
148
148
  * fetched data so the dispatcher's first probe hits the cache instead
149
149
  * of refetching.
150
150
  *
151
- * The cache key MUST go through `deriveCacheKey(entry.config)` and the
152
- * value MUST be wrapped as `{ data }` — otherwise the dispatcher's
153
- * lookup at `_dataStore.get(deriveCacheKey(request))` misses every
154
- * time and `cached.data` reads `undefined`. Three call sites used to
155
- * inline this loop independently (browser SPA, Node SSG, and a server-side
156
- * renderer); one of the three was using the wrong shape, silently killing
157
- * prefetched-data reuse wherever it ran. This helper is the one canonical
158
- * implementation.
151
+ * The value MUST be wrapped as `{ data }`, or `cached.data` reads `undefined`. Three call sites
152
+ * used to inline this loop independently (browser SPA, Node SSG, and a server-side renderer); one
153
+ * of the three used the wrong shape, silently killing prefetched-data reuse wherever it ran. This
154
+ * helper is the one canonical implementation.
155
+ *
156
+ * ⭐ **The KEY is the dispatcher's, not ours** (`fetcher.hydrate`). ⛔ This filed every entry under
157
+ * `deriveCacheKey(entry.config)` until 2026-09-20, which is right for every request the framework's
158
+ * own fetcher answers and wrong for one the site routes to a transport that declares its own
159
+ * `cacheKey` — the documented case being a request whose identity depends on state its address does
160
+ * not carry. Measured: such an entry landed where no `peek` looks, so every prerendered page of that
161
+ * site rendered empty and the browser refetched it, with no error anywhere. One derivation, one
162
+ * home — the dispatcher's, which is where selection happens.
159
163
  *
160
164
  * @param {import('@uniweb/core').Website} website
161
165
  * @param {Array<{config: Object, data: any}>} fetchedData
@@ -163,12 +167,18 @@ export function sliceContentForLocale(content, locale) {
163
167
  export function hydrateDataStore(website, fetchedData) {
164
168
  if (!website?.dataStore || !fetchedData?.length) return
165
169
  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.
170
+ // A data step's list carries every declared config with an `outcome`; only what was actually
171
+ // fetched enters the store. A list without outcomes (the SSG lane's) is all fetched.
168
172
  if (entry.outcome && entry.outcome !== 'fetched') continue
169
173
  // `meta` (the depth the records were fetched at) rides along, so the store
170
174
  // files them in its record index exactly as a runtime fetch would.
171
- website.dataStore.set(deriveCacheKey(entry.config), entry.meta ? { data: entry.data, meta: entry.meta } : { data: entry.data })
175
+ const value = entry.meta ? { data: entry.data, meta: entry.meta } : { data: entry.data }
176
+ if (typeof website.fetcher?.hydrate === 'function') {
177
+ website.fetcher.hydrate(entry.config, value, { website })
178
+ } else {
179
+ // A graph with no dispatcher — a stub in a test, or a caller assembling one by hand.
180
+ website.dataStore.set(deriveCacheKey(entry.config), value)
181
+ }
172
182
  }
173
183
  }
174
184
 
package/src/prefetch.js DELETED
@@ -1,261 +0,0 @@
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 the records service; the fetch comes from the runtime.* The host now calls
14
- * this and carries no copy; the host that renders in an isolate agreed to exactly
15
- * that shape the same day.
16
- *
17
- * ⛔ Contract with the host, deliberately small:
18
- * - `content` the render payload (`site-content.json` / `__DATA__`), config included —
19
- * `config.services` and `config.base` are read from it.
20
- * - `route` the page to prefetch for; a `[slug]` template resolves through the same
21
- * matcher the SPA uses, so `/blog/post-1` finds `/blog/:slug`.
22
- * - `fetch` how to dispatch a request. The runtime composes the address; the host
23
- * decides how a site-relative one is reached (its origin, a binding).
24
- * ⛔ **Crossing an isolate boundary, this survives only as an RPC method
25
- * argument.** Through an entrypoint's `fetch(Request)` with a serialized
26
- * body it arrives `undefined` (measured by a host against a real
27
- * isolate loader, 2026-09-03) — and the fetcher then falls
28
- * back to `globalThis.fetch`, so the request leaves from the isolate,
29
- * outside whatever budget the host wrapped around it. `prefetchAndHydrate`
30
- * refuses a non-function for exactly this reason; this entry keeps the
31
- * permissive default because the build and browser lanes call it in-process.
32
- * - `prerender` whether a fetch is tried — `'always'` (default) tries every config; `'author'`
33
- * honours the author's `prerender: false`. ⛔ The default is `'always'` because this
34
- * entry has exactly one kind of caller: an isolate prerendering the page a visit
35
- * starts on, where the flag means nothing and always prerendering is the product
36
- * ([Diego, 2026-07-28 and 2026-09-03]: "`prerender: false` is not for the isolate").
37
- * ⚠️ That page ONLY: the site is a SPA, and every page after it renders in the
38
- * browser with data the entity store fetches, never through this. (Until 2026-09-19
39
- * this said "an isolate rendering per request", which read as "every page".)
40
- * The build lane, which bakes static artifacts and does honour the flag, uses its
41
- * own executor (`build/src/prerender.js`) and never calls this. `'author'` is the
42
- * explicit opt-in for a caller that bakes; omitting the option must not silently
43
- * reproduce the 2026-07-28 outcome — prefetch a no-op on a live-data template,
44
- * page still 200.
45
- * - returns one entry per DECLARED config, `{ config, outcome, data, meta?, error? }`, keyed
46
- * downstream by `deriveCacheKey(config)`. `outcome` is `fetched`, `failed`
47
- * (transport or HTTP error, `error` says which) or `skipped` (the author
48
- * deferred it to the browser with `prerender: false`). `hydrateDataStore`
49
- * takes the list as-is and hydrates only `fetched` entries — a host reads the
50
- * outcomes to tell "nothing was tried" from "everything tried failed", which
51
- * is a different cache decision (measured by a host, 2026-09-03).
52
- *
53
- * It resolves nothing the host owns and models no host route layout: every address is
54
- * `{base}/…` from the payload, or the records service the host itself
55
- * published at `config.services.records`.
56
- */
57
- import { resolveFetchConfigs, fetchEntries, pageRouteQuery, routeSelection, currentFor, othersView, siteReaches } from '@uniweb/core/fetch-config'
58
- import { deriveCacheKey } from '@uniweb/core/datastore'
59
- import { findPageForRoute, isDynamicRoute, routeBinding, parentRouteOf } from '@uniweb/core/route-match'
60
- import { buildDetailConfig } from '@uniweb/core/detail-url'
61
- import { resolveDefaultLocale } from '@uniweb/core/locale-config'
62
- import { findLayoutEntry } from '@uniweb/core/layout-name'
63
- import { createDefaultFetcher } from './default-fetcher.js'
64
-
65
- /**
66
- * ⭐ **RE-EXPORTED, NOT IMPLEMENTED HERE — the rule lives in `@uniweb/core/route-match`.**
67
- *
68
- * This module carried its own copy until 2026-09-12, composed out of core's leaves,
69
- * and it disagreed with the SPA: this copy compared raw route strings, while
70
- * `Website#getPage` normalizes a trailing slash — so `/about/` prefetched nothing
71
- * and then rendered fine, which is the shape that hides the split.
72
- *
73
- * ⇒ The leaves were importable and the RULE was not, which is what invites a caller
74
- * to compose its own. Moving it into the leaf every caller already imports leaves one
75
- * answer to *which page is this* and nowhere for a second to form. The export keeps
76
- * its name here and on `@uniweb/runtime/ssr`, so the isolate API is unchanged.
77
- */
78
- export { findPageForRoute }
79
-
80
- /**
81
- * The page a page inherits from, by the one rule every lane uses (`parentRouteOf`):
82
- * the declared `parent` when it names a page, else the route minus its last segment.
83
- * ⛔ This read the declared field only until 2026-09-11. A payload that omits it —
84
- * published payloads may — gave every page no parent here while the SPA inferred
85
- * one, so `/members/alice` prefetched nothing: not the list, not the record
86
- * (measured). The page was then filled by the browser's own request.
87
- */
88
- function parentPageOf(page, pages) {
89
- const byRoute = new Map(pages.filter((p) => p?.route).map((p) => [p.route, p]))
90
- const route = parentRouteOf(page.route, { declared: page.parent ?? null, has: (r) => byRoute.has(r) })
91
- return route ? byRoute.get(route) : null
92
- }
93
-
94
- /**
95
- * The layout sections a page may render — every section of the layout areas it can
96
- * use, subsections included. A page that names a layout uses that set (matched as the
97
- * runtime matches it, `@uniweb/core/layout-name`) and nothing else; a page that names
98
- * none uses its foundation's default layout, which a payload does not say, so every
99
- * set's sections are included — a superset, which a prefetch can afford.
100
- */
101
- function layoutSectionsFor(content, page) {
102
- const sets = content?.layouts && typeof content.layouts === 'object' ? content.layouts : {}
103
- const name = typeof page?.layout?.name === 'string' && page.layout.name ? page.layout.name : null
104
- const named = name ? findLayoutEntry(sets, name) : undefined
105
- const chosen = name ? (named ? [named] : []) : Object.values(sets)
106
- const out = []
107
- const walk = (sections) => {
108
- for (const s of sections || []) {
109
- if (!s || typeof s !== 'object') continue
110
- out.push(s)
111
- walk(s.subsections)
112
- }
113
- }
114
- for (const set of chosen) for (const area of Object.values(set || {})) walk(area?.sections)
115
- return out
116
- }
117
-
118
- /**
119
- * Every fetch config a page will need at render time, resolved once and de-duplicated by
120
- * cache key: the page's, its parent's, each section's own (including nested sections),
121
- * its layout sections', and the site's where it reaches (`siteReaches`) — each through
122
- * `resolveFetchConfigs`, the same resolver the entity store uses, so a host prefetches
123
- * exactly what the render will ask for.
124
- *
125
- * @returns {Object[]} resolved fetch configs
126
- */
127
- export function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
128
- const { page, params } = findPageForRoute(content, route)
129
- if (!page) return []
130
- const pages = content?.pages || []
131
- const parent = parentPageOf(page, pages)
132
- // The route's binding — the SPA's (`routeBinding`, `@uniweb/core/route-match`).
133
- const binding = isDynamicRoute(page.route) && Object.keys(params).length
134
- ? routeBinding(page.route, params, page.paramName ?? null)
135
- : null
136
- const options = {
137
- locale,
138
- defaultLocale: resolveDefaultLocale(content?.config) ?? null,
139
- queries: content?.config?.queries ?? null,
140
- services: content?.config?.services ?? null,
141
- variables: binding?.variables ?? null,
142
- }
143
- const siteFetch = content?.config?.fetch ?? null
144
- // The route query: the key this page's URL names one record of, chosen at the page
145
- // that captured its variable — by the rule the entity store reads it with
146
- // (`pageRouteQuery`) — never a payload field.
147
- const chosen = binding
148
- ? pageRouteQuery(page, {
149
- routeOf: (p) => p.route,
150
- parentOf: (p) => parentPageOf(p, pages),
151
- fetchOf: (p) => p.fetch ?? null,
152
- sectionsOf: (p) => p.sections,
153
- site: siteFetch,
154
- })
155
- : null
156
- // A nested page's sections receive its capturing page's route binding, which the
157
- // one-parent cascade may not reach — the entity store adds it the same way.
158
- const routeSource = chosen?.nested ? chosen.config : null
159
-
160
- const out = new Map()
161
- const put = (cfg) => {
162
- const key = deriveCacheKey(cfg)
163
- if (!out.has(key)) out.set(key, cfg)
164
- }
165
- // ⭐ EVERY fetch of every level, each resolved on its own — not the first per key. A section
166
- // receives the keys its component declares, and two fetches of one `as` at two levels can
167
- // fill two of them (`fillDeclaredKeys`, 2026-09-14), which a prefetch holding no
168
- // component's meta cannot rule out. A superset, which a prefetch can afford.
169
- const add = (sources) => {
170
- const resolved = sources.flatMap((source) => fetchEntries(source).map((entry) => resolveFetchConfigs([entry], options).get(entry.as)))
171
- for (const cfg of resolved.filter(Boolean)) {
172
- // how the fetch uses the page's record — by the query it names, as the entity store asks
173
- const current = currentFor(cfg, chosen)
174
- if (current === 'exclude') {
175
- // the list one longer, from which the render removes the page's record
176
- put(othersView(cfg))
177
- continue
178
- }
179
- if (current !== 'only') {
180
- // no part for the page's record, or `current: include` — the list as the fetch describes it
181
- put(cfg)
182
- continue
183
- }
184
- // ⭐ A parametric page is ABOUT one record, and on a lane with a per-record
185
- // source (the records service, a `deferred:` query's per-record file) that
186
- // record is a request of its own. Built for EVERY config that takes the page's
187
- // record, by the one rule the entity store uses (`buildDetailConfig`), so
188
- // the question prefetched is the question the render asks — and since that
189
- // question drops a fetch's own narrowing, sections that narrow differently ask
190
- // one record question between them.
191
- if (cfg.detail && binding.paramValue !== undefined) {
192
- const detailCfg = buildDetailConfig(cfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) })
193
- if (detailCfg) put(detailCfg)
194
- }
195
- // ⭐ On the records service the record question checks the query's set on its
196
- // own, so nothing else is asked. Off it, the record is FOUND in the set —
197
- // the query as saved, without the fetch's `narrow` (`routeSelection`) — which is
198
- // all the render reads for it. ⛔ Until 2026-09-14 the service's list was asked
199
- // beside the record, and the page embedded the whole set to show one record.
200
- if (!cfg.ask) put(routeSelection(cfg))
201
- }
202
- }
203
- // The cascade a block sees: its own fetch, page, parent, a nested page's route
204
- // binding, and the site's — which reaches a top-level page's sections only.
205
- const siteForPage = siteReaches(parent) ? siteFetch : null
206
- add([page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
207
- const walk = (sections) => {
208
- for (const s of sections || []) {
209
- if (s?.fetch) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
210
- if (s?.subsections) walk(s.subsections)
211
- }
212
- }
213
- walk(page.sections)
214
- // ⭐ Layout sections belong to the site: the site's binding reaches them on every
215
- // page, beside each one's own fetch. A layout area has no parent page.
216
- for (const s of layoutSectionsFor(content, page)) add([s.fetch ?? null, siteFetch])
217
- return [...out.values()]
218
- }
219
-
220
- /**
221
- * Execute resolved fetch configs through the runtime's default fetcher.
222
- *
223
- * @param {Object[]} configs resolved configs (from `resolvePageFetchConfigs` or the host's own
224
- * call to `resolveFetchConfigs`)
225
- * @param {Object} opts
226
- * @param {Object} opts.content the payload — `config.base`, `config.services`
227
- * @param {Function} [opts.fetch] the transport; defaults to the global `fetch`
228
- * @param {boolean} [opts.dev]
229
- * @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
230
- */
231
- export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always', locale = null } = {}) {
232
- if (prerender !== 'author' && prerender !== 'always') {
233
- throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
234
- }
235
- const fetcher = createDefaultFetcher({
236
- basePath: content?.config?.base || '',
237
- dev,
238
- fetch,
239
- })
240
- // The page's locale, which a list's texts are collated in when the runtime sorts it.
241
- const ctx = { website: null, locale: locale ?? content?.config?.activeLocale ?? resolveDefaultLocale(content?.config) ?? null }
242
- // Dispatched together, not one after another: the records service batches the
243
- // requests issued in one tick into one POST, and a page's configs are
244
- // independent of each other. Order is preserved in the result.
245
- return Promise.all((configs || []).filter(Boolean).map(async (config) => {
246
- if (prerender === 'author' && config.prerender === false) {
247
- // The author deferred this one to the browser and the caller honours that. Present, so a
248
- // host can count what was declared against what was tried; not hydrated.
249
- return { config, outcome: 'skipped', data: null }
250
- }
251
- const result = await fetcher.resolve(config, ctx)
252
- if (result?.error) return { config, outcome: 'failed', data: null, error: result.error }
253
- return { config, outcome: 'fetched', data: result?.data ?? null, ...(result?.meta ? { meta: result.meta } : {}) }
254
- }))
255
- }
256
-
257
- /** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
258
- export async function prefetchPageData({ content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
259
- const configs = resolvePageFetchConfigs(content, route, { locale })
260
- return executeFetchConfigs(configs, { content, fetch, dev, prerender, locale })
261
- }