@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/dist/ssr.js +70 -64
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/default-fetcher.js +83 -44
- package/src/prefetch.js +49 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
47
|
+
"@uniweb/build": "0.45.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/default-fetcher.js
CHANGED
|
@@ -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
|
|
116
|
-
* records service, the QUESTION (`deriveCacheKey`
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
189
|
-
|
|
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
|
-
|
|
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`, `
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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,
|
|
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
|
|
64
|
-
*
|
|
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.
|
|
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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
117
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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,
|
|
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,
|
|
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
|
|