@uniweb/runtime 0.19.5 → 0.20.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.19.5",
3
+ "version": "0.20.1",
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.24.4",
39
+ "@uniweb/core": "^0.25.1",
40
40
  "@uniweb/theming": "^0.1.15"
41
41
  },
42
42
  "devDependencies": {
@@ -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.44.3"
47
+ "@uniweb/build": "0.45.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -52,6 +52,7 @@
52
52
  import {
53
53
  substitutePlaceholders,
54
54
  matchWhere,
55
+ applyScope,
55
56
  sortRecords,
56
57
  sortToWire,
57
58
  deriveCacheKey,
@@ -110,14 +111,34 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
110
111
  })
111
112
  }
112
113
 
114
+ // ⭐ VIEWS OF ONE FILE SHARE ONE READ IN FLIGHT. Each view — its own `scope`,
115
+ // `where`, `sort` or `limit` — is its own cache entry (`deriveCacheKey` hashes
116
+ // the view) and is cut from the read; views asked together (a page's sections,
117
+ // a host's prefetch) share one request. ⛔ Until 2026-09-11 the view was applied
118
+ // before the dispatcher stored the answer under a key that left the view out, so
119
+ // one file had ONE entry and whoever asked first decided what everyone got
120
+ // (measured). A settled read is not kept: a view asked later reads again (a
121
+ // static host's HTTP cache answers it), so dropping an entry and asking again
122
+ // still reaches the source. The read carries no caller's abort signal — several
123
+ // views may be waiting on it, and one navigating away must not cancel it for
124
+ // the others.
125
+ const reads = new Map()
126
+ const readOnce = (target, init) => {
127
+ const key = `${init.method} ${target} ${init.body ?? ''}`
128
+ let pending = reads.get(key)
129
+ if (!pending) {
130
+ pending = readResponse(doFetch, target, init).finally(() => reads.delete(key))
131
+ reads.set(key, pending)
132
+ }
133
+ return pending
134
+ }
135
+
113
136
  return {
114
137
  /**
115
- * The cache identity is the request's ADDRESS or, when asked of the
116
- * records service, the QUESTION (`deriveCacheKey` hashes every operator of an address-less
117
- * request). Operators evaluated here run over a shared cached value and
118
- * must NOT split the cache: two pages declaring different `where:` clauses
119
- * against the same path share one entry — the file is fetched once and
120
- * each page filters its own copy.
138
+ * The cache identity is the request's ADDRESS and the view it takes of it —
139
+ * or, when asked of the records service, the QUESTION (`deriveCacheKey`).
140
+ * Two pages declaring different `where:` clauses against one path are two
141
+ * entries, each cut from a read of the file (`readOnce`, above).
121
142
  */
122
143
  cacheKey(request) {
123
144
  return deriveCacheKey(request)
@@ -150,7 +171,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
150
171
  return { data: [], error: 'No path, url or ask specified' }
151
172
  }
152
173
 
153
- const init = { signal: ctx.signal, method }
174
+ const init = { method }
154
175
 
155
176
  if (method === 'POST') {
156
177
  // Substitute {paramName} placeholders in body strings using the
@@ -168,7 +189,8 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
168
189
  }
169
190
 
170
191
  try {
171
- const response = await doFetch(target, init)
192
+ const response = await readOnce(target, init)
193
+ if (ctx.signal?.aborted) return { data: [], error: 'aborted' }
172
194
 
173
195
  // A per-request envelope (set by the object form of `detail:`) describes
174
196
  // this one response.
@@ -181,18 +203,13 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
181
203
  // from the parsed body; fall back to status text if the path is
182
204
  // missing or the body isn't JSON.
183
205
  let extracted
184
- if (envelope.error) {
185
- try {
186
- const text = await response.text()
187
- const body = safeParseJSON(text)
188
- if (body !== undefined) {
189
- const candidate = getNestedValue(body, envelope.error)
190
- if (typeof candidate === 'string' && candidate.length) {
191
- extracted = candidate
192
- }
206
+ if (envelope.error && typeof response.text === 'string') {
207
+ const body = safeParseJSON(response.text)
208
+ if (body !== undefined) {
209
+ const candidate = getNestedValue(body, envelope.error)
210
+ if (typeof candidate === 'string' && candidate.length) {
211
+ extracted = candidate
193
212
  }
194
- } catch {
195
- // Body not readable — fall through to status-text fallback.
196
213
  }
197
214
  }
198
215
  return {
@@ -201,18 +218,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
201
218
  }
202
219
  }
203
220
 
204
- const contentType = response.headers.get('content-type') || ''
205
- let data
206
- if (contentType.includes('application/json')) {
207
- data = await response.json()
208
- } else {
209
- const text = await response.text()
210
- try {
211
- data = JSON.parse(text)
212
- } catch {
213
- data = text
214
- }
215
- }
221
+ let data = response.body
216
222
 
217
223
  // Unwrap the response. Per-fetch `transform:` wins; otherwise the
218
224
  // envelope's `item` path on a single-record request, `list` on a list.
@@ -250,24 +256,26 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
250
256
  /**
251
257
  * One question of a batch, in the records service's own vocabulary
252
258
  * (the records contract, §2): `schema` required, `scope` a bare path, `sort`
253
- * one key spelled `date` / `-date`, `depth` brief or full. The where-object
254
- * crosses as authored except for the two spellings the language settled
255
- * differently from the evaluator's: `nin` is `not_in` there, and a top-level
256
- * `path: { under }` the file lane's way of naming a folder branch — is
257
- * `scope`. Anything the service does not accept (`like`, a dotted path) is
258
- * sent as written and refused there by name: loud, never approximated.
259
+ * one key spelled `date` / `-date`, `whole` when the whole record is wanted,
260
+ * `match` on a parametric page's record. The where-object crosses as authored
261
+ * except for the one spelling the language settled differently from the
262
+ * evaluator's: `nin` is `not_in` there. Anything the service does not accept
263
+ * (`like`, a dotted path) is sent as written and refused there by name: loud,
264
+ * never approximated.
259
265
  */
260
266
  function toQuestion(request) {
261
267
  const q = { schema: request.schema }
262
- let where = request.where && typeof request.where === 'object' ? request.where : null
263
- let scope = typeof request.scope === 'string' && request.scope ? request.scope : null
264
- if (where && !scope && where.path && typeof where.path === 'object' && typeof where.path.under === 'string' && where.path.under) {
265
- const { path, ...rest } = where
266
- scope = path.under
267
- where = Object.keys(rest).length ? rest : null
268
- }
268
+ const where = request.where && typeof request.where === 'object' ? request.where : null
269
+ // ⭐ `scope` crosses as authored. A top-level `where.path.under` was respelled
270
+ // into it until 2026-09-11, when that form was retired in favour of `scope:`
271
+ // [Diego] — the build refuses it now, so there is nothing left to respell.
272
+ const scope = typeof request.scope === 'string' && request.scope ? request.scope : null
269
273
  if (scope) q.scope = scope
270
274
  if (where) q.where = renameOperators(where)
275
+ // ⭐ The record of a parametric page: the question unchanged plus `match` — one
276
+ // key and the URL's value, beside the author's `where`, never merged into it
277
+ // (`buildDetailConfig`; the records contract as we read it, §1d).
278
+ if (request.match && typeof request.match === 'object') q.match = request.match
271
279
  const sort = sortToWire(request.sort)
272
280
  if (sort) q.sort = sort
273
281
  if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
@@ -480,6 +488,33 @@ async function flushAsked(url, queue, doFetch) {
480
488
  }))
481
489
  }
482
490
 
491
+ /**
492
+ * Read one response and parse its body — the part of a fetch every view of an
493
+ * address shares. A JSON body is parsed as JSON; anything else is tried as JSON
494
+ * and kept as text when it is not. A failed response keeps its text, so a view
495
+ * whose envelope names an error path can read a message out of it.
496
+ */
497
+ async function readResponse(doFetch, target, init) {
498
+ const response = await doFetch(target, init)
499
+ if (!response.ok) {
500
+ let text = null
501
+ try {
502
+ text = await response.text()
503
+ } catch {
504
+ // Body not readable — the status line is all there is.
505
+ }
506
+ return { ok: false, status: response.status, statusText: response.statusText, text }
507
+ }
508
+ const contentType = response.headers.get('content-type') || ''
509
+ if (contentType.includes('application/json')) return { ok: true, body: await response.json() }
510
+ const text = await response.text()
511
+ try {
512
+ return { ok: true, body: JSON.parse(text) }
513
+ } catch {
514
+ return { ok: true, body: text }
515
+ }
516
+ }
517
+
483
518
  /** Build a `meta` from the fields that are actually present, or `undefined`. */
484
519
  function withMeta(fields) {
485
520
  const out = {}
@@ -495,6 +530,10 @@ function withMeta(fields) {
495
530
  function applyOperators(data, request, { dev = false } = {}) {
496
531
  if (!Array.isArray(data)) return data
497
532
  let result = data
533
+ // `scope` first: it names the branch the rest of the query reads. On this lane
534
+ // it is evaluated over each record's placement (`path`), as the build did when it
535
+ // wrote the file — `scope: :dir` bound per page reaches here (2026-09-11).
536
+ if (typeof request.scope === 'string' && request.scope) result = applyScope(result, request.scope)
498
537
  if (request.where) result = matchWhere(request.where, result)
499
538
  if (request.sort) result = applySort(result, request.sort, dev)
500
539
  if (typeof request.limit === 'number' && request.limit > 0) result = result.slice(0, request.limit)
package/src/prefetch.js CHANGED
@@ -50,9 +50,9 @@
50
50
  * `{base}/…` from the payload, or the records service the host itself
51
51
  * published at `config.services.records`.
52
52
  */
53
- import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
53
+ import { resolveFetchConfigs, routeQuery, sectionFetches } from '@uniweb/core/fetch-config'
54
54
  import { deriveCacheKey } from '@uniweb/core/datastore'
55
- import { routePatternToRegex, decodeRouteValue, splitPathCapture } from '@uniweb/core/route-match'
55
+ import { routePatternToRegex, decodeRouteValue, isDynamicRoute, routeBinding, parentRouteOf } from '@uniweb/core/route-match'
56
56
  import { buildDetailConfig } from '@uniweb/core/detail-url'
57
57
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
58
58
  import { createDefaultFetcher } from './default-fetcher.js'
@@ -60,16 +60,21 @@ import { createDefaultFetcher } from './default-fetcher.js'
60
60
  const isRefinement = (f) => f && typeof f === 'object' && f.refine === true
61
61
 
62
62
  /**
63
- * The page a route names — exact first, then the `[slug]` / `[...path]` templates, like
64
- * the SPA. Captured params are decoded the way `matchDynamicRoute` decodes them (a
63
+ * The page a route names — exact first, then the parametric pages, like the SPA.
64
+ * Captured params are decoded the way `matchDynamicRoute` decodes them (a
65
65
  * catch-all per segment), so the values are what the site's query is bound against.
66
+ *
67
+ * ⭐ A page is parametric when its ROUTE has a parameter — the test the SPA uses
68
+ * (`Website.getPage`). ⛔ This tested the payload's `isDynamic` flag until
69
+ * 2026-09-11, which our build sets only on a bracket folder: a page nested inside
70
+ * one (`/members/:slug/cv`) routed in the browser and was never found here.
66
71
  */
67
72
  export function findPageForRoute(content, route) {
68
73
  const pages = content?.pages || []
69
74
  const exact = pages.find((p) => p.route === route)
70
75
  if (exact) return { page: exact, params: {} }
71
76
  for (const page of pages) {
72
- if (!page.isDynamic || !page.route) continue
77
+ if (!page.route || !isDynamicRoute(page.route)) continue
73
78
  const compiled = routePatternToRegex(page.route)
74
79
  const m = compiled?.regex ? compiled.regex.exec(route) : null
75
80
  if (m) {
@@ -85,20 +90,17 @@ export function findPageForRoute(content, route) {
85
90
  }
86
91
 
87
92
  /**
88
- * The route's variables and the delivery param for a matched template page the same
89
- * binding the SPA makes in `Website._createDynamicPage`: `[slug]` binds the one capture
90
- * under the folder's own name; `[...path]` splits its capture into `path` / `dir` /
91
- * `slug` and delivers by `slug`, the record's handle.
93
+ * The page a page inherits from, by the one rule every lane uses (`parentRouteOf`):
94
+ * the declared `parent` when it names a page, else the route minus its last segment.
95
+ * This read the declared field only until 2026-09-11. A payload that omits it
96
+ * published payloads may gave every page no parent here while the SPA inferred
97
+ * one, so `/members/alice` prefetched nothing: not the list, not the record
98
+ * (measured). The page was then filled by the browser's own request.
92
99
  */
93
- function routeBinding(page, params) {
94
- const { catchAll } = routePatternToRegex(page.route)
95
- if (catchAll && params[catchAll] !== undefined) {
96
- const parts = splitPathCapture(params[catchAll])
97
- const paramName = page.paramName || 'slug'
98
- return { paramName, paramValue: parts.slug, variables: { ...params, ...parts } }
99
- }
100
- const paramName = page.paramName || Object.keys(params)[0]
101
- return { paramName, paramValue: params[paramName], variables: { ...params } }
100
+ function parentPageOf(page, pages) {
101
+ const byRoute = new Map(pages.filter((p) => p?.route).map((p) => [p.route, p]))
102
+ const route = parentRouteOf(page.route, { declared: page.parent ?? null, has: (r) => byRoute.has(r) })
103
+ return route ? byRoute.get(route) : null
102
104
  }
103
105
 
104
106
  /**
@@ -113,8 +115,11 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
113
115
  const { page, params } = findPageForRoute(content, route)
114
116
  if (!page) return []
115
117
  const pages = content?.pages || []
116
- const parent = page.parent ? pages.find((p) => p.route === page.parent) : null
117
- const binding = page.isDynamic && Object.keys(params).length ? routeBinding(page, params) : null
118
+ const parent = parentPageOf(page, pages)
119
+ // The route's binding the SPA's (`routeBinding`, `@uniweb/core/route-match`).
120
+ const binding = isDynamicRoute(page.route) && Object.keys(params).length
121
+ ? routeBinding(page.route, params, page.paramName ?? null)
122
+ : null
118
123
  const options = {
119
124
  locale,
120
125
  defaultLocale: resolveDefaultLocale(content?.config) ?? null,
@@ -122,39 +127,42 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
122
127
  services: content?.config?.services ?? null,
123
128
  variables: binding?.variables ?? null,
124
129
  }
130
+ const siteFetch = content?.config?.fetch ?? null
131
+ // The route query: the key this page's URL names one record of, by the rule the
132
+ // entity store reads it with (`routeQuery`) — never a payload field.
133
+ const routeKey = binding
134
+ ? routeQuery({ page: page.fetch, parent: parent?.fetch, site: siteFetch, sections: sectionFetches(page.sections) })?.key ?? null
135
+ : null
136
+
125
137
  const out = new Map()
138
+ const put = (cfg) => {
139
+ const key = deriveCacheKey(cfg)
140
+ if (!out.has(key)) out.set(key, cfg)
141
+ }
126
142
  const add = (sources) => {
127
143
  for (const cfg of resolveFetchConfigs(sources, options).values()) {
128
- const key = deriveCacheKey(cfg)
129
- if (!out.has(key)) out.set(key, cfg)
144
+ put(cfg)
145
+ // A parametric page is ABOUT one record, and on a lane with a per-record
146
+ // source (the records service, a `deferred:` query's per-record file) that
147
+ // record is a request of its own. Built for EVERY config the route key
148
+ // resolves to — a section re-declaring the route query asks its own record
149
+ // question — by the one rule the entity store uses (`buildDetailConfig`), so
150
+ // the question prefetched is the question the render asks.
151
+ if (routeKey && cfg.as === routeKey && cfg.detail && binding.paramValue !== undefined) {
152
+ const detailCfg = buildDetailConfig(cfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) })
153
+ if (detailCfg) put(detailCfg)
154
+ }
130
155
  }
131
156
  }
132
157
  // The cascade a block sees: its own fetch (unless a refinement), page, parent, site.
133
- add([page.fetch ?? null, parent?.fetch ?? null, content?.config?.fetch ?? null])
158
+ add([page.fetch ?? null, parent?.fetch ?? null, siteFetch])
134
159
  const walk = (sections) => {
135
160
  for (const s of sections || []) {
136
- if (s?.fetch && !isRefinement(s.fetch)) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, content?.config?.fetch ?? null])
161
+ if (s?.fetch && !isRefinement(s.fetch)) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, siteFetch])
137
162
  if (s?.subsections) walk(s.subsections)
138
163
  }
139
164
  }
140
165
  walk(page.sections)
141
-
142
- // ⭐ A template page is ABOUT one record, and the record is a fetch of its own.
143
- // The list the page inherits is what the entity store matches the route param
144
- // against; when that query has a per-record source (a live lane's record address,
145
- // a `deferred:` query's per-record file), the record itself comes from a second
146
- // request — which this helper never built, so a host prerendering a template page
147
- // got the BRIEF and the body arrived after hydration as a client fetch. The
148
- // detail config is built by the one rule the
149
- // entity store uses (`buildDetailConfig`), keyed by the route's param.
150
- if (binding && binding.paramValue !== undefined && page.parentSchema) {
151
- const listCfg = [...out.values()].find((cfg) => cfg.as === page.parentSchema && cfg.detail)
152
- const detailCfg = listCfg ? buildDetailConfig(listCfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) }) : null
153
- if (detailCfg) {
154
- const key = deriveCacheKey(detailCfg)
155
- if (!out.has(key)) out.set(key, detailCfg)
156
- }
157
- }
158
166
  return [...out.values()]
159
167
  }
160
168