@uniweb/runtime 0.15.0 → 0.16.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 +36 -28
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/default-fetcher.js +63 -63
- package/src/isolate-api.js +2 -1
- package/src/page-renderer.js +2 -2
- package/src/prefetch.js +2 -2
- package/src/setup.js +1 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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.21.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
|
+
"@uniweb/build": "0.38.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/default-fetcher.js
CHANGED
|
@@ -6,22 +6,25 @@
|
|
|
6
6
|
* starter/docs/marketing templates hitting /data/*.json — ride on this path
|
|
7
7
|
* with zero config, and so does a site a host serves live.
|
|
8
8
|
*
|
|
9
|
-
* ⭐ It speaks exactly
|
|
9
|
+
* ⭐ It speaks exactly TWO lanes, and takes NO site-level vocabulary for a
|
|
10
10
|
* backend of the author's own:
|
|
11
11
|
*
|
|
12
12
|
* - a compiled file — `path:` under the site's base (`/data/<query>.json`,
|
|
13
13
|
* a per-record file), or a plain JSON `url:` the author wrote;
|
|
14
|
-
* - the host's ADDRESS DOOR — `endpoint:`, resolved upstream from the
|
|
15
|
-
* `config.records` stamp (`@uniweb/core/query-address`), unwrapped with
|
|
16
|
-
* the stamp's own `envelope.records`;
|
|
17
14
|
* - the host's QUESTION DOOR — `door:`, one POST per tick carrying every
|
|
18
15
|
* question the page asked, answered per key (the records door's contract,
|
|
19
16
|
* as this client reads it).
|
|
20
17
|
*
|
|
21
18
|
* `where:` / `sort:` / `limit:` are evaluated HERE, locally, over what the
|
|
22
|
-
* first
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* first lane returns — with `@uniweb/core`'s one evaluator, the same the build
|
|
20
|
+
* uses to materialize a file — and by the source on the door. Nothing decides
|
|
21
|
+
* that per site: the LANE decides.
|
|
22
|
+
*
|
|
23
|
+
* ⛔ A third lane — the host's ADDRESS door, a GET per query with the query
|
|
24
|
+
* evaluated locally over the whole set — was retired 2026-09-04 by ruling,
|
|
25
|
+
* with no hosted site to protect: one host answering one query two ways, and a
|
|
26
|
+
* precedence between the two, was where the failure lived. The stamp's `list`,
|
|
27
|
+
* `record` and `envelope` keys are not read.
|
|
25
28
|
*
|
|
26
29
|
* ⛔ RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
|
|
27
30
|
* `supports`, `request.style` / `request.rename` and the `json-body`
|
|
@@ -67,9 +70,6 @@ import {
|
|
|
67
70
|
* for subpath deployments. Remote URLs pass through unchanged.
|
|
68
71
|
* @param {boolean} [options.dev=false] - Dev-mode diagnostics: a bad `sort:`
|
|
69
72
|
* throws instead of delivering the records unsorted.
|
|
70
|
-
* @param {Object|null} [options.records=null] - The host's `config.records`
|
|
71
|
-
* stamp; its `envelope.records` names the key a list sits under on the
|
|
72
|
-
* address door.
|
|
73
73
|
* @param {Function|null} [options.fetch=null] - The transport. A host executing
|
|
74
74
|
* fetches outside a browser (an SSR isolate) decides how a site-relative
|
|
75
75
|
* address is dispatched — through its own origin or a service binding — and
|
|
@@ -77,23 +77,10 @@ import {
|
|
|
77
77
|
* test stub installed later is honoured.
|
|
78
78
|
* @returns {{ cacheKey: (req: Object) => string, resolve: (req: Object, ctx: Object) => Promise<{ data, error?, meta? }> }}
|
|
79
79
|
*/
|
|
80
|
-
export function createDefaultFetcher({ basePath = '', dev = false,
|
|
80
|
+
export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchImpl = null } = {}) {
|
|
81
81
|
const doFetch = (input, init) => (fetchImpl || globalThis.fetch)(input, init)
|
|
82
82
|
const pathPrefix = basePath && basePath !== '/' ? basePath.replace(/\/$/, '') : ''
|
|
83
83
|
|
|
84
|
-
// ⭐ The LIVE LANE's envelope is the backend's. `config.records` is stamped by the backend
|
|
85
|
-
// that answers a records request, so where the array sits in ITS response is its to
|
|
86
|
-
// declare: `records.envelope.records` — the KEY says what it holds, the VALUE is the JSON
|
|
87
|
-
// key the array sits under (`{ records: "entries" }` ⇒ body.entries). That spelling is the
|
|
88
|
-
// agreed one (2026-08-30: `collection` retired; ⛔ not `list`, which is a URL pattern on the
|
|
89
|
-
// same stamp). It applies only to a request that resolved to that lane (`endpoint` set).
|
|
90
|
-
// Ruled 2026-09-03 [Diego]: the backend sets `config.records`; the fetch comes from the
|
|
91
|
-
// runtime.
|
|
92
|
-
const stampedArrayKey = (records?.envelope && typeof records.envelope === 'object'
|
|
93
|
-
&& typeof records.envelope.records === 'string' && records.envelope.records.length)
|
|
94
|
-
? records.envelope.records
|
|
95
|
-
: null
|
|
96
|
-
const laneEnvelope = stampedArrayKey ? { list: stampedArrayKey } : null
|
|
97
84
|
|
|
98
85
|
// ⭐ THE QUESTION DOOR — a batch of the misses, one POST, merged per key.
|
|
99
86
|
//
|
|
@@ -103,8 +90,18 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
|
|
|
103
90
|
// The batch response is never cached as
|
|
104
91
|
// one: each request gets its own answer, keyed by its own question.
|
|
105
92
|
const doorQueues = new Map()
|
|
106
|
-
const askDoor = (request, ctx) =>
|
|
107
|
-
|
|
93
|
+
const askDoor = (request, ctx) => {
|
|
94
|
+
// ⛔ A door question needs the query's Model ref. A payload that stamps the
|
|
95
|
+
// door and carries no `config.queries` entry for the query cannot ask; that
|
|
96
|
+
// is a producer defect and it is said here, per key, with no request made.
|
|
97
|
+
if (typeof request.schema !== 'string' || !request.schema) {
|
|
98
|
+
return Promise.resolve({
|
|
99
|
+
data: null,
|
|
100
|
+
error: `the payload stamps a records door but carries no Model ref for query ` +
|
|
101
|
+
`"${request.query ?? request.as}" (config.queries) — the door cannot be asked`,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
return new Promise((resolve) => {
|
|
108
105
|
const url = resolveServiceUrl(request.door, pathPrefix)
|
|
109
106
|
let queue = doorQueues.get(url)
|
|
110
107
|
if (!queue) {
|
|
@@ -117,6 +114,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
|
|
|
117
114
|
}
|
|
118
115
|
queue.push({ request, ctx, resolve })
|
|
119
116
|
})
|
|
117
|
+
}
|
|
120
118
|
|
|
121
119
|
return {
|
|
122
120
|
/**
|
|
@@ -134,7 +132,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
|
|
|
134
132
|
async resolve(request, ctx = {}) {
|
|
135
133
|
if (!request) return { data: null }
|
|
136
134
|
if (request.door) return askDoor(request, ctx)
|
|
137
|
-
const { path, url,
|
|
135
|
+
const { path, url, transform, body: rawBody } = request
|
|
138
136
|
|
|
139
137
|
// Normalize method. Only GET and POST are supported by the default
|
|
140
138
|
// fetcher — mutations (PUT/PATCH/DELETE) are a different feature
|
|
@@ -146,24 +144,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
|
|
|
146
144
|
}
|
|
147
145
|
|
|
148
146
|
let target
|
|
149
|
-
if (
|
|
150
|
-
// The host's address door, resolved upstream from the pattern it
|
|
151
|
-
// published. FINAL ON ARRIVAL: the site `base` is applied to a rooted
|
|
152
|
-
// address — the same rule every other site-relative address follows,
|
|
153
|
-
// shared with `resolveServiceUrl` rather than spelled a second time —
|
|
154
|
-
// and nothing else is joined onto it. A pattern may carry a site id,
|
|
155
|
-
// its own root, any layout at all; none of it is ours.
|
|
156
|
-
target = resolveServiceUrl(endpoint, pathPrefix)
|
|
157
|
-
// ⭐ The locale rides as a query param on the address door — the config
|
|
158
|
-
// carries one only on a non-default-locale live request (F1). Until
|
|
159
|
-
// 2026-09-04 nothing put it on the wire, so localized fields arrived as
|
|
160
|
-
// `{lang: …}` maps; hosting confirmed that day that an appended `?locale=`
|
|
161
|
-
// passes through their reshape verbatim, on both the browser and the
|
|
162
|
-
// isolate path, and backend answers it with the locale's strings.
|
|
163
|
-
if (typeof request.locale === 'string' && request.locale) {
|
|
164
|
-
target += (target.includes('?') ? '&' : '?') + 'locale=' + encodeURIComponent(request.locale)
|
|
165
|
-
}
|
|
166
|
-
} else if (path) {
|
|
147
|
+
if (path) {
|
|
167
148
|
// Local file under public/ — basePath applies for subpath deploys.
|
|
168
149
|
target = pathPrefix && path.startsWith('/') && !path.startsWith('//')
|
|
169
150
|
? pathPrefix + path
|
|
@@ -172,7 +153,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
|
|
|
172
153
|
// A URL the author wrote, sent exactly as written.
|
|
173
154
|
target = url
|
|
174
155
|
} else {
|
|
175
|
-
return { data: [], error: 'No path, url or
|
|
156
|
+
return { data: [], error: 'No path, url or door specified' }
|
|
176
157
|
}
|
|
177
158
|
|
|
178
159
|
const init = { signal: ctx.signal, method }
|
|
@@ -196,11 +177,10 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
|
|
|
196
177
|
const response = await doFetch(target, init)
|
|
197
178
|
|
|
198
179
|
// A per-request envelope (set by the object form of `detail:`) describes
|
|
199
|
-
// this one response
|
|
200
|
-
const
|
|
180
|
+
// this one response.
|
|
181
|
+
const envelope = (request.envelope && typeof request.envelope === 'object')
|
|
201
182
|
? request.envelope
|
|
202
|
-
:
|
|
203
|
-
const envelope = requestEnvelope ?? (endpoint && laneEnvelope ? laneEnvelope : {})
|
|
183
|
+
: {}
|
|
204
184
|
|
|
205
185
|
if (!response.ok) {
|
|
206
186
|
// If `envelope.error` names a path, try to extract a human message
|
|
@@ -256,11 +236,10 @@ export function createDefaultFetcher({ basePath = '', dev = false, records = nul
|
|
|
256
236
|
// it returned.
|
|
257
237
|
data = applyOperators(data, request, { dev })
|
|
258
238
|
|
|
259
|
-
// ⭐ Say what depth was delivered, so the record index can file it
|
|
260
|
-
// the
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
// will override this with what it actually served.
|
|
239
|
+
// ⭐ Say what depth was delivered, so the record index can file it — what
|
|
240
|
+
// the config asked for, echoed: a list at brief depth when the query has
|
|
241
|
+
// a per-record source, a record in full. (A door reports `depths` per
|
|
242
|
+
// key and overrides this with what it actually served.)
|
|
264
243
|
const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
|
|
265
244
|
return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
|
|
266
245
|
} catch (error) {
|
|
@@ -315,12 +294,15 @@ function renameOperators(where) {
|
|
|
315
294
|
/**
|
|
316
295
|
* Send one batch to a door and hand each question its own answer.
|
|
317
296
|
*
|
|
318
|
-
* The response is `{ data, depths?, errors? }` (contract §5):
|
|
319
|
-
* exactly the keys sent, `[]` when nothing matched; a key that
|
|
320
|
-
* from `data` and present in `errors`; `depths` says what was
|
|
321
|
-
* which the record index files rather than what was asked for.
|
|
322
|
-
* from both is a protocol violation and is reported as an error,
|
|
323
|
-
* silence.
|
|
297
|
+
* The response is `{ data, depths?, errors?, cursors?, limits? }` (contract §5):
|
|
298
|
+
* `data` answers exactly the keys sent, `[]` when nothing matched; a key that
|
|
299
|
+
* ERRORED is absent from `data` and present in `errors`; `depths` says what was
|
|
300
|
+
* actually served, which the record index files rather than what was asked for.
|
|
301
|
+
* A key missing from both is a protocol violation and is reported as an error,
|
|
302
|
+
* never as silence. `cursors` (a next page per key) and `limits` (a `limit` the
|
|
303
|
+
* door bounded) are received and IGNORED, by ruling: framework has no paging
|
|
304
|
+
* concept and is not this door's only client, so whether either is consumed is
|
|
305
|
+
* a product decision, not a client default.
|
|
324
306
|
*/
|
|
325
307
|
async function flushDoor(url, queue, doFetch) {
|
|
326
308
|
const body = {}
|
|
@@ -340,7 +322,19 @@ async function flushDoor(url, queue, doFetch) {
|
|
|
340
322
|
body: JSON.stringify(body),
|
|
341
323
|
})
|
|
342
324
|
if (!response.ok) {
|
|
343
|
-
|
|
325
|
+
// A protocol violation is refused for the WHOLE request with a problem body
|
|
326
|
+
// whose `detail` names the key and the fault (an unknown operator, an empty
|
|
327
|
+
// binding key, a non-BCP-47 locale segment…). Surface that sentence on every
|
|
328
|
+
// key of the batch rather than the bare status: the author reads
|
|
329
|
+
// `block.dataError` and the status alone says nothing they can act on.
|
|
330
|
+
let detail = null
|
|
331
|
+
try {
|
|
332
|
+
const problem = safeParseJSON(await response.text())
|
|
333
|
+
if (problem && typeof problem.detail === 'string' && problem.detail) detail = problem.detail
|
|
334
|
+
} catch { /* an unreadable body falls back to the status line */ }
|
|
335
|
+
const error = detail
|
|
336
|
+
? `HTTP ${response.status}: ${detail}`
|
|
337
|
+
: `HTTP ${response.status}: ${response.statusText}`
|
|
344
338
|
for (const entry of queue) entry.resolve({ data: null, error })
|
|
345
339
|
return
|
|
346
340
|
}
|
|
@@ -356,8 +350,14 @@ async function flushDoor(url, queue, doFetch) {
|
|
|
356
350
|
queue.forEach((entry, i) => {
|
|
357
351
|
const key = keys[i]
|
|
358
352
|
if (key in errors) {
|
|
353
|
+
// A per-key error is `{ code, detail }` — `schema_not_found`,
|
|
354
|
+
// `field_not_in_brief`, `scope_not_found`… The sentence is `detail`; `code`
|
|
355
|
+
// rides beside it for a reader that wants to branch on it.
|
|
359
356
|
const e = errors[key]
|
|
360
|
-
|
|
357
|
+
const detail = typeof e === 'string' ? e : (e?.detail || e?.message || JSON.stringify(e))
|
|
358
|
+
const out = { data: null, error: detail }
|
|
359
|
+
if (e && typeof e === 'object' && typeof e.code === 'string') out.code = e.code
|
|
360
|
+
entry.resolve(out)
|
|
361
361
|
return
|
|
362
362
|
}
|
|
363
363
|
if (!(key in data)) {
|
package/src/isolate-api.js
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* ⛔ A floor is a promise about a VERSION, not a rename guard. A site on a newer
|
|
18
18
|
* runtime with a renamed export is still a missing symbol; the test above is what
|
|
19
19
|
* makes that fail before it ships, and the announcement to the consumer is still
|
|
20
|
-
* ours to send
|
|
20
|
+
* ours to send, because a consumer that bundles this package by workspace link
|
|
21
|
+
* gets the change at commit time, with no version to pin against.
|
|
21
22
|
*
|
|
22
23
|
* ⛔ Not `runtime-pin.json`. That file is emitted per FOUNDATION build and records an
|
|
23
24
|
* observed fact ("built against"), never a guarantee; an isolate-API floor is a
|
package/src/page-renderer.js
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*
|
|
19
19
|
* - **Shell assembly.** The shell arrives built. The import map, the CDN base and
|
|
20
20
|
* cache headers are host layout, and a runtime that assembled them would be
|
|
21
|
-
* modelling a deployment it cannot see (
|
|
22
|
-
* 2026-09-03
|
|
21
|
+
* modelling a deployment it cannot see (the host drew this line itself,
|
|
22
|
+
* 2026-09-03: a serve location is read from the payload, never constructed here).
|
|
23
23
|
* - **Init and hydration.** The two lanes differ REALLY here, not incidentally: a
|
|
24
24
|
* build initializes once and hydrates every collection up front, an isolate
|
|
25
25
|
* initializes per locale and prefetches per route. Folding either in would fit
|
package/src/prefetch.js
CHANGED
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
* is a different cache decision (hosting, 2026-09-03).
|
|
47
47
|
*
|
|
48
48
|
* It resolves nothing the host owns and models no host route layout: every address is
|
|
49
|
-
* `{base}/…` from the payload, or
|
|
49
|
+
* `{base}/…` from the payload, or the question door the host itself published at
|
|
50
|
+
* `config.records.query`.
|
|
50
51
|
*/
|
|
51
52
|
import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
|
|
52
53
|
import { deriveCacheKey } from '@uniweb/core/datastore'
|
|
@@ -173,7 +174,6 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
|
|
|
173
174
|
}
|
|
174
175
|
const fetcher = createDefaultFetcher({
|
|
175
176
|
basePath: content?.config?.base || '',
|
|
176
|
-
records: content?.config?.records ?? null,
|
|
177
177
|
dev,
|
|
178
178
|
fetch,
|
|
179
179
|
})
|
package/src/setup.js
CHANGED
|
@@ -246,11 +246,8 @@ function buildDefaultFetcher(content) {
|
|
|
246
246
|
// transports (`fetcher.transports`) plus a transport's own binding config,
|
|
247
247
|
// which a transport reads through ctx.website.config.fetcher.
|
|
248
248
|
//
|
|
249
|
-
// The host's live-records stamp (`config.records`), when a backend set one: the fetcher
|
|
250
|
-
// reads its `envelope` for requests that resolved to that lane.
|
|
251
|
-
const records = content?.config?.records ?? null
|
|
252
249
|
const dev = !!(import.meta.env && import.meta.env.DEV)
|
|
253
|
-
return createDefaultFetcher({ basePath, dev
|
|
250
|
+
return createDefaultFetcher({ basePath, dev })
|
|
254
251
|
}
|
|
255
252
|
|
|
256
253
|
/**
|