@uniweb/runtime 0.22.0 → 0.23.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/dist/ssr.js +46 -37
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/default-fetcher.js +22 -57
- package/src/prefetch.js +69 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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.
|
|
39
|
+
"@uniweb/core": "^0.27.0",
|
|
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.
|
|
47
|
+
"@uniweb/build": "0.47.2"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/default-fetcher.js
CHANGED
|
@@ -26,16 +26,17 @@
|
|
|
26
26
|
* foundation level… making the runtime+core lean."* A backend with its own
|
|
27
27
|
* base, headers, wire or query language is a TRANSPORT — a named
|
|
28
28
|
* `{ resolve, cacheKey? }` the foundation (or an extension) registers and
|
|
29
|
-
* the site selects per
|
|
29
|
+
* the site selects per binding key (`as`) in `fetcher.transports`. The build warns once
|
|
30
30
|
* and drops a retired key from the payload, so an author's backend does not
|
|
31
31
|
* silently stop being reached.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
33
|
+
* A request may carry `method: 'POST'` + `body:` for an endpoint that takes a
|
|
34
|
+
* query in a body (GraphQL, a search endpoint) — an external query's, or its
|
|
35
|
+
* `record:` request's — and `transform:`, a dot-path to the records in ONE
|
|
36
|
+
* response. `{paramName}` placeholders in body strings are substituted from
|
|
37
|
+
* `request.dynamicContext`, so a parametric page's record request can reference
|
|
38
|
+
* its route param. ⛔ `envelope` (the object form of `detail:`) is retired with
|
|
39
|
+
* it, 2026-09-13: a record request's own `transform` is `record.transform`.
|
|
39
40
|
*
|
|
40
41
|
* Exported from a subpath — `@uniweb/runtime/default-fetcher` — for
|
|
41
42
|
* runtime-level callers (the editor's preview iframe, custom runtime
|
|
@@ -192,42 +193,16 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
192
193
|
const response = await readOnce(target, init)
|
|
193
194
|
if (ctx.signal?.aborted) return { data: [], error: 'aborted' }
|
|
194
195
|
|
|
195
|
-
// A per-request envelope (set by the object form of `detail:`) describes
|
|
196
|
-
// this one response.
|
|
197
|
-
const envelope = (request.envelope && typeof request.envelope === 'object')
|
|
198
|
-
? request.envelope
|
|
199
|
-
: {}
|
|
200
|
-
|
|
201
196
|
if (!response.ok) {
|
|
202
|
-
|
|
203
|
-
// from the parsed body; fall back to status text if the path is
|
|
204
|
-
// missing or the body isn't JSON.
|
|
205
|
-
let extracted
|
|
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
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
return {
|
|
216
|
-
data: [],
|
|
217
|
-
error: extracted ?? `HTTP ${response.status}: ${response.statusText}`,
|
|
218
|
-
}
|
|
197
|
+
return { data: [], error: `HTTP ${response.status}: ${response.statusText}` }
|
|
219
198
|
}
|
|
220
199
|
|
|
221
200
|
let data = response.body
|
|
222
201
|
|
|
223
|
-
// Unwrap the response
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
transform
|
|
228
|
-
|| (isDetailRequest ? envelope.item : envelope.list)
|
|
229
|
-
if (effectiveTransform && data !== null && data !== undefined) {
|
|
230
|
-
data = getNestedValue(data, effectiveTransform)
|
|
202
|
+
// Unwrap the response: the request's own `transform:`, and nothing else — a
|
|
203
|
+
// record request carries its `record.transform`, never the list's.
|
|
204
|
+
if (transform && data !== null && data !== undefined) {
|
|
205
|
+
data = getNestedValue(data, transform)
|
|
231
206
|
}
|
|
232
207
|
|
|
233
208
|
// Evaluate the query locally. Only applies to array data
|
|
@@ -257,11 +232,12 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
257
232
|
* One question of a batch, in the records service's own vocabulary
|
|
258
233
|
* (the records contract, §2): `schema` required, `scope` a bare path, `sort`
|
|
259
234
|
* 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
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* never
|
|
235
|
+
* `match` on a parametric page's record. ⭐ The where-object crosses exactly as
|
|
236
|
+
* authored: the evaluator and the service speak one language (`@uniweb/core`'s where evaluator).
|
|
237
|
+
* ⛔ `nin` was respelled `not_in` here until 2026-09-13, when the evaluator took
|
|
238
|
+
* `not_in` as its only spelling. Anything the service does not answer (a dotted
|
|
239
|
+
* path, a retired operator) is sent as written and answered there: never
|
|
240
|
+
* approximated.
|
|
265
241
|
*/
|
|
266
242
|
function toQuestion(request) {
|
|
267
243
|
const q = { schema: request.schema }
|
|
@@ -271,7 +247,7 @@ function toQuestion(request) {
|
|
|
271
247
|
// [Diego] — the build refuses it now, so there is nothing left to respell.
|
|
272
248
|
const scope = typeof request.scope === 'string' && request.scope ? request.scope : null
|
|
273
249
|
if (scope) q.scope = scope
|
|
274
|
-
if (where) q.where =
|
|
250
|
+
if (where) q.where = where
|
|
275
251
|
// ⭐ The record of a parametric page: the question unchanged plus `match` — one
|
|
276
252
|
// key and the URL's value, beside the author's `where`, never merged into it
|
|
277
253
|
// (`buildDetailConfig`; the records contract as we read it, §1d).
|
|
@@ -292,17 +268,6 @@ function toQuestion(request) {
|
|
|
292
268
|
return q
|
|
293
269
|
}
|
|
294
270
|
|
|
295
|
-
const OPERATOR_ALIAS = { nin: 'not_in' }
|
|
296
|
-
function renameOperators(where) {
|
|
297
|
-
if (Array.isArray(where)) return where.map(renameOperators)
|
|
298
|
-
if (!where || typeof where !== 'object') return where
|
|
299
|
-
const out = {}
|
|
300
|
-
for (const [key, value] of Object.entries(where)) {
|
|
301
|
-
out[OPERATOR_ALIAS[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
|
|
302
|
-
}
|
|
303
|
-
return out
|
|
304
|
-
}
|
|
305
|
-
|
|
306
271
|
/**
|
|
307
272
|
* Send one batch to the records service and hand each question its own answer.
|
|
308
273
|
*
|
|
@@ -491,8 +456,8 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
491
456
|
/**
|
|
492
457
|
* Read one response and parse its body — the part of a fetch every view of an
|
|
493
458
|
* 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
|
|
495
|
-
*
|
|
459
|
+
* and kept as text when it is not. A failed response keeps its text, so a caller
|
|
460
|
+
* can read a message out of it (the records service's problem details).
|
|
496
461
|
*/
|
|
497
462
|
async function readResponse(doFetch, target, init) {
|
|
498
463
|
const response = await doFetch(target, init)
|
package/src/prefetch.js
CHANGED
|
@@ -50,15 +50,14 @@
|
|
|
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,
|
|
53
|
+
import { resolveFetchConfigs, pageRouteQuery, routeSelection, currentOf, othersView, siteReaches } from '@uniweb/core/fetch-config'
|
|
54
54
|
import { deriveCacheKey } from '@uniweb/core/datastore'
|
|
55
55
|
import { findPageForRoute, 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
|
+
import { findLayoutEntry } from '@uniweb/core/layout-name'
|
|
58
59
|
import { createDefaultFetcher } from './default-fetcher.js'
|
|
59
60
|
|
|
60
|
-
const isRefinement = (f) => f && typeof f === 'object' && f.refine === true
|
|
61
|
-
|
|
62
61
|
/**
|
|
63
62
|
* ⭐ **RE-EXPORTED, NOT IMPLEMENTED HERE — the rule lives in `@uniweb/core/route-match`.**
|
|
64
63
|
*
|
|
@@ -88,11 +87,36 @@ function parentPageOf(page, pages) {
|
|
|
88
87
|
return route ? byRoute.get(route) : null
|
|
89
88
|
}
|
|
90
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The layout sections a page may render — every section of the layout areas it can
|
|
92
|
+
* use, subsections included. A page that names a layout uses that set (matched as the
|
|
93
|
+
* runtime matches it, `@uniweb/core/layout-name`) and nothing else; a page that names
|
|
94
|
+
* none uses its foundation's default layout, which a payload does not say, so every
|
|
95
|
+
* set's sections are included — a superset, which a prefetch can afford.
|
|
96
|
+
*/
|
|
97
|
+
function layoutSectionsFor(content, page) {
|
|
98
|
+
const sets = content?.layouts && typeof content.layouts === 'object' ? content.layouts : {}
|
|
99
|
+
const name = typeof page?.layout?.name === 'string' && page.layout.name ? page.layout.name : null
|
|
100
|
+
const named = name ? findLayoutEntry(sets, name) : undefined
|
|
101
|
+
const chosen = name ? (named ? [named] : []) : Object.values(sets)
|
|
102
|
+
const out = []
|
|
103
|
+
const walk = (sections) => {
|
|
104
|
+
for (const s of sections || []) {
|
|
105
|
+
if (!s || typeof s !== 'object') continue
|
|
106
|
+
out.push(s)
|
|
107
|
+
walk(s.subsections)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const set of chosen) for (const area of Object.values(set || {})) walk(area?.sections)
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
/**
|
|
92
115
|
* Every fetch config a page will need at render time, resolved once and de-duplicated by
|
|
93
|
-
* cache key: the
|
|
94
|
-
*
|
|
95
|
-
* entity store uses, so a host prefetches
|
|
116
|
+
* cache key: the page's, its parent's, each section's own (including nested sections),
|
|
117
|
+
* its layout sections', and the site's where it reaches (`siteReaches`) — each through
|
|
118
|
+
* `resolveFetchConfigs`, the same resolver the entity store uses, so a host prefetches
|
|
119
|
+
* exactly what the render will ask for.
|
|
96
120
|
*
|
|
97
121
|
* @returns {Object[]} resolved fetch configs
|
|
98
122
|
*/
|
|
@@ -113,11 +137,22 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
113
137
|
variables: binding?.variables ?? null,
|
|
114
138
|
}
|
|
115
139
|
const siteFetch = content?.config?.fetch ?? null
|
|
116
|
-
// The route query: the key this page's URL names one record of,
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
140
|
+
// The route query: the key this page's URL names one record of, chosen at the page
|
|
141
|
+
// that captured its variable — by the rule the entity store reads it with
|
|
142
|
+
// (`pageRouteQuery`) — never a payload field.
|
|
143
|
+
const chosen = binding
|
|
144
|
+
? pageRouteQuery(page, {
|
|
145
|
+
routeOf: (p) => p.route,
|
|
146
|
+
parentOf: (p) => parentPageOf(p, pages),
|
|
147
|
+
fetchOf: (p) => p.fetch ?? null,
|
|
148
|
+
sectionsOf: (p) => p.sections,
|
|
149
|
+
site: siteFetch,
|
|
150
|
+
})
|
|
120
151
|
: null
|
|
152
|
+
const routeKey = chosen?.key ?? null
|
|
153
|
+
// A nested page's sections receive its capturing page's route binding, which the
|
|
154
|
+
// one-parent cascade may not reach — the entity store adds it the same way.
|
|
155
|
+
const routeSource = chosen?.nested ? chosen.config : null
|
|
121
156
|
|
|
122
157
|
const out = new Map()
|
|
123
158
|
const put = (cfg) => {
|
|
@@ -126,28 +161,47 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
126
161
|
}
|
|
127
162
|
const add = (sources) => {
|
|
128
163
|
for (const cfg of resolveFetchConfigs(sources, options).values()) {
|
|
129
|
-
|
|
164
|
+
const current = routeKey && cfg.as === routeKey ? currentOf(cfg) : null
|
|
165
|
+
if (current === 'exclude') {
|
|
166
|
+
// the list one longer, from which the render removes the page's record
|
|
167
|
+
put(othersView(cfg))
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (current !== 'only') {
|
|
171
|
+
// any other key, or `current: include` — the list as the binding describes it
|
|
172
|
+
put(cfg)
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
130
175
|
// ⭐ A parametric page is ABOUT one record, and on a lane with a per-record
|
|
131
176
|
// source (the records service, a `deferred:` query's per-record file) that
|
|
132
177
|
// record is a request of its own. Built for EVERY config the route key
|
|
133
178
|
// resolves to — a section re-declaring the route query asks its own record
|
|
134
179
|
// question — by the one rule the entity store uses (`buildDetailConfig`), so
|
|
135
180
|
// the question prefetched is the question the render asks.
|
|
136
|
-
if (
|
|
181
|
+
if (cfg.detail && binding.paramValue !== undefined) {
|
|
137
182
|
const detailCfg = buildDetailConfig(cfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) })
|
|
138
183
|
if (detailCfg) put(detailCfg)
|
|
139
184
|
}
|
|
185
|
+
// On the records service the list is asked beside the record; off it, the
|
|
186
|
+
// record is FOUND in the route query's whole selection — the list without its
|
|
187
|
+
// `limit` (`routeSelection`) — which is all the render reads for it.
|
|
188
|
+
put(cfg.ask ? cfg : routeSelection(cfg))
|
|
140
189
|
}
|
|
141
190
|
}
|
|
142
|
-
// The cascade a block sees: its own fetch
|
|
143
|
-
|
|
191
|
+
// The cascade a block sees: its own fetch, page, parent, a nested page's route
|
|
192
|
+
// binding, and the site's — which reaches a top-level page's sections only.
|
|
193
|
+
const siteForPage = siteReaches(parent) ? siteFetch : null
|
|
194
|
+
add([page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
|
|
144
195
|
const walk = (sections) => {
|
|
145
196
|
for (const s of sections || []) {
|
|
146
|
-
if (s?.fetch
|
|
197
|
+
if (s?.fetch) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
|
|
147
198
|
if (s?.subsections) walk(s.subsections)
|
|
148
199
|
}
|
|
149
200
|
}
|
|
150
201
|
walk(page.sections)
|
|
202
|
+
// ⭐ Layout sections belong to the site: the site's binding reaches them on every
|
|
203
|
+
// page, beside each one's own fetch. A layout area has no parent page.
|
|
204
|
+
for (const s of layoutSectionsFor(content, page)) add([s.fetch ?? null, siteFetch])
|
|
151
205
|
return [...out.values()]
|
|
152
206
|
}
|
|
153
207
|
|