@uniweb/runtime 0.14.0 → 0.14.1

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.14.0",
3
+ "version": "0.14.1",
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.35.0"
47
+ "@uniweb/build": "0.36.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -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 fetch(target, init)
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 ?? envelope
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,146 @@
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
+ * - `prerender` whether a fetch is tried — `'always'` (default) tries every config; `'author'`
24
+ * honours the author's `prerender: false`. ⛔ The default is `'always'` because this
25
+ * entry has exactly one kind of caller: an isolate rendering per request, where the
26
+ * flag means nothing and always prerendering is the product ([Diego, 2026-07-28 and
27
+ * 2026-09-03]: "`prerender: false` is not for the isolate"). The build lane, which
28
+ * bakes static artifacts and does honour the flag, uses its own executor
29
+ * (`build/src/prerender.js`) and never calls this. `'author'` is the explicit opt-in
30
+ * for a caller that bakes; omitting the option must not silently reproduce the
31
+ * 2026-07-28 outcome — prefetch a no-op on a live-data template, page still 200.
32
+ * - returns one entry per DECLARED config, `{ config, outcome, data, error? }`, keyed
33
+ * downstream by `deriveCacheKey(config)`. `outcome` is `fetched`, `failed`
34
+ * (transport or HTTP error, `error` says which) or `skipped` (the author
35
+ * deferred it to the browser with `prerender: false`). `hydrateDataStore`
36
+ * takes the list as-is and hydrates only `fetched` entries — a host reads the
37
+ * outcomes to tell "nothing was tried" from "everything tried failed", which
38
+ * is a different cache decision (hosting, 2026-09-03).
39
+ *
40
+ * It resolves nothing the host owns and models no host route layout: every address is
41
+ * `{base}/…` from the payload, or an endpoint the host itself published in `config.records`.
42
+ */
43
+ import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
44
+ import { deriveCacheKey } from '@uniweb/core/datastore'
45
+ import { routePatternToRegex } from '@uniweb/core/route-match'
46
+ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
47
+ import { createDefaultFetcher } from './default-fetcher.js'
48
+
49
+ const isRefinement = (f) => f && typeof f === 'object' && f.refine === true
50
+
51
+ /** The page a route names — exact first, then the `[slug]` templates, like the SPA. */
52
+ export function findPageForRoute(content, route) {
53
+ const pages = content?.pages || []
54
+ const exact = pages.find((p) => p.route === route)
55
+ if (exact) return { page: exact, params: {} }
56
+ for (const page of pages) {
57
+ if (!page.isDynamic || !page.route) continue
58
+ const compiled = routePatternToRegex(page.route)
59
+ const m = compiled?.regex ? compiled.regex.exec(route) : null
60
+ if (m) return { page, params: Object.fromEntries((compiled.paramNames || []).map((n, i) => [n, m[i + 1]])) }
61
+ }
62
+ return { page: null, params: {} }
63
+ }
64
+
65
+ /**
66
+ * Every fetch config a page will need at render time, resolved once and de-duplicated by
67
+ * cache key: the site-level fetch, the page's, its parent's, and each section's own
68
+ * (including nested sections), each through `resolveFetchConfigs` — the same resolver the
69
+ * entity store uses, so a host prefetches exactly what the render will ask for.
70
+ *
71
+ * @returns {Object[]} resolved fetch configs
72
+ */
73
+ export function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
74
+ const { page } = findPageForRoute(content, route)
75
+ if (!page) return []
76
+ const pages = content?.pages || []
77
+ const parent = page.parent ? pages.find((p) => p.route === page.parent) : null
78
+ const options = {
79
+ locale,
80
+ defaultLocale: resolveDefaultLocale(content?.config) ?? null,
81
+ queries: content?.config?.queries ?? null,
82
+ records: content?.config?.records ?? null,
83
+ }
84
+ const out = new Map()
85
+ const add = (sources) => {
86
+ for (const cfg of resolveFetchConfigs(sources, options).values()) {
87
+ const key = deriveCacheKey(cfg)
88
+ if (!out.has(key)) out.set(key, cfg)
89
+ }
90
+ }
91
+ // The cascade a block sees: its own fetch (unless a refinement), page, parent, site.
92
+ add([page.fetch ?? null, parent?.fetch ?? null, content?.config?.fetch ?? null])
93
+ const walk = (sections) => {
94
+ for (const s of sections || []) {
95
+ if (s?.fetch && !isRefinement(s.fetch)) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, content?.config?.fetch ?? null])
96
+ if (s?.subsections) walk(s.subsections)
97
+ }
98
+ }
99
+ walk(page.sections)
100
+ return [...out.values()]
101
+ }
102
+
103
+ /**
104
+ * Execute resolved fetch configs through the runtime's default fetcher.
105
+ *
106
+ * @param {Object[]} configs resolved configs (from `resolvePageFetchConfigs` or the host's own
107
+ * call to `resolveFetchConfigs`)
108
+ * @param {Object} opts
109
+ * @param {Object} opts.content the payload — `config.base`, `config.fetcher`, `config.records`
110
+ * @param {Function} [opts.fetch] the transport; defaults to the global `fetch`
111
+ * @param {boolean} [opts.dev]
112
+ * @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
113
+ */
114
+ export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always' } = {}) {
115
+ if (prerender !== 'author' && prerender !== 'always') {
116
+ throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
117
+ }
118
+ const fetcher = createDefaultFetcher({
119
+ basePath: content?.config?.base || '',
120
+ config: content?.config?.fetcher ?? {},
121
+ records: content?.config?.records ?? null,
122
+ dev,
123
+ fetch,
124
+ })
125
+ const ctx = { website: null }
126
+ const out = []
127
+ for (const config of configs || []) {
128
+ if (!config) continue
129
+ if (prerender === 'author' && config.prerender === false) {
130
+ // The author deferred this one to the browser and the caller honours that. Present, so a
131
+ // host can count what was declared against what was tried; not hydrated.
132
+ out.push({ config, outcome: 'skipped', data: null })
133
+ continue
134
+ }
135
+ const result = await fetcher.resolve(config, ctx)
136
+ if (result?.error) out.push({ config, outcome: 'failed', data: null, error: result.error })
137
+ else out.push({ config, outcome: 'fetched', data: result?.data ?? null })
138
+ }
139
+ return out
140
+ }
141
+
142
+ /** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
143
+ export async function prefetchPageData({ content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
144
+ const configs = resolvePageFetchConfigs(content, route, { locale })
145
+ return executeFetchConfigs(configs, { content, fetch, dev, prerender })
146
+ }
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,16 @@ 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
+
59
69
  // Appearance. injectPageContent() already emits this for every prerendered
60
70
  // page; exported for lanes that assemble a shell without a per-page render.
61
71
  export { renderAppearanceBootScript } from './appearance.js'
@@ -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
  }