@uniweb/runtime 0.16.0 → 0.17.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 +115 -43
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/background-shared.js +72 -0
- package/src/collect-records.js +99 -0
- package/src/components/Background.jsx +4 -39
- package/src/default-fetcher.js +124 -52
- package/src/isolate-api.js +48 -1
- package/src/prefetch.js +7 -7
- package/src/ssr-renderer.js +2 -32
- package/src/ssr.js +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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.23.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.40.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two helpers the background renderers share — one home, because they are
|
|
3
|
+
* not rendering.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this module exists
|
|
6
|
+
*
|
|
7
|
+
* Backgrounds are an L3 pair by design: `components/Background.jsx` renders
|
|
8
|
+
* with hooks and DOM, `ssr-renderer.js` renders with `React.createElement`
|
|
9
|
+
* only, and the runtime-layer rule (framework `CLAUDE.md` gotcha #2) says that
|
|
10
|
+
* pair is twinned on purpose. ⛔ **What it also says is that only the RENDERING
|
|
11
|
+
* is twinned — "pure data → L1, one home" — and two pure helpers had been
|
|
12
|
+
* dragged across the seam with it**: a base-path joiner and a colour-opacity
|
|
13
|
+
* converter, byte-identical in both files.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ **And the copies had already drifted from the rule they were copying.**
|
|
16
|
+
* The base-path joiner is `@uniweb/core`'s `applyBasePath`, whose own docblock
|
|
17
|
+
* says it moved down to core *"rather than grow a second copy (the failure
|
|
18
|
+
* `@uniweb/core/route-match` was created to end, after one matcher was
|
|
19
|
+
* implemented twice and the copies diverged)"*. Two copies grew anyway, in the
|
|
20
|
+
* runtime, and **both were missing its protocol-relative guard** — so an author
|
|
21
|
+
* writing `//cdn.example.com/hero.jpg` on a site with `base: /docs/` got
|
|
22
|
+
* `/docs//cdn.example.com/hero.jpg`. Measured 2026-09-06; the exact garbage
|
|
23
|
+
* `applyBasePath` documents itself as existing to prevent.
|
|
24
|
+
*
|
|
25
|
+
* ⇒ The lesson is narrower than "don't duplicate": **when a twinned pair needs a
|
|
26
|
+
* helper, the helper is the thing that must not be twinned.** A twin is a
|
|
27
|
+
* commitment to keep two renderers in step; every pure function pulled inside it
|
|
28
|
+
* silently joins that commitment.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { applyBasePath } from '@uniweb/core/base-path'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A site-root-relative URL with the deployment base applied.
|
|
35
|
+
*
|
|
36
|
+
* The base comes off the active website rather than being passed in, because
|
|
37
|
+
* both callers render from the singleton and neither has it to hand. The join
|
|
38
|
+
* itself is core's — protocol-relative and absolute URLs pass through, and an
|
|
39
|
+
* already-based path is not based twice.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} url
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function siteUrl(url) {
|
|
45
|
+
return applyBasePath(url, globalThis.uniweb?.activeWebsite?.basePath || '')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A colour with an alpha applied, for an overlay drawn over a background.
|
|
50
|
+
*
|
|
51
|
+
* Hex and `rgb()` / `rgba()` are converted; anything else — a named colour, a
|
|
52
|
+
* `var(--token)`, `oklch(…)` — is returned unchanged, because a wrong guess
|
|
53
|
+
* here paints the wrong colour rather than failing.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} color
|
|
56
|
+
* @param {number} opacity
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function withOpacity(color, opacity) {
|
|
60
|
+
if (typeof color !== 'string' || !color) return color
|
|
61
|
+
if (color.startsWith('#')) {
|
|
62
|
+
const r = parseInt(color.slice(1, 3), 16)
|
|
63
|
+
const g = parseInt(color.slice(3, 5), 16)
|
|
64
|
+
const b = parseInt(color.slice(5, 7), 16)
|
|
65
|
+
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
|
66
|
+
}
|
|
67
|
+
if (color.startsWith('rgb')) {
|
|
68
|
+
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
|
69
|
+
if (match) return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`
|
|
70
|
+
}
|
|
71
|
+
return color
|
|
72
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every record a site's declared queries return — for a host building something
|
|
3
|
+
* over the whole corpus rather than rendering one page.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is framework's and not the caller's
|
|
6
|
+
*
|
|
7
|
+
* A host that indexes a site has to ask the same questions the site's pages ask.
|
|
8
|
+
* ⛔ **If it composes its own, the index and the page can disagree**, and the way
|
|
9
|
+
* they disagree is the worst one available: the index offers a result whose page
|
|
10
|
+
* then renders empty. That is the two-answers-to-one-question failure the GET
|
|
11
|
+
* lane was retired for, reintroduced one lane over.
|
|
12
|
+
*
|
|
13
|
+
* ⇒ So the composition is not duplicated here either. This walks the site's
|
|
14
|
+
* `config.queries`, hands each one to `resolveFetchConfigs` — **the same rule a
|
|
15
|
+
* page render uses**, applying each saved query's own `scope` / `where` / `sort`
|
|
16
|
+
* / `limit` — and asks through the same client. What differs from a page is two
|
|
17
|
+
* fields and nothing else: `depth: 'brief'` (an index wants what a list shows)
|
|
18
|
+
* and `exhaustive: true` (a corpus is not a page, so it follows `cursors` to the
|
|
19
|
+
* end; the records contract bounds a single answer at 100).
|
|
20
|
+
*
|
|
21
|
+
* ## What the caller supplies
|
|
22
|
+
*
|
|
23
|
+
* ⭐ **The transport, and nothing else.** A host outside a browser decides how a
|
|
24
|
+
* site-relative address is dispatched — through its own origin, a service
|
|
25
|
+
* binding, a forwarding table that turns `/_…` into an upstream — and hands that
|
|
26
|
+
* in as `fetch`. **Framework decides what to ask and how to read the answer;
|
|
27
|
+
* the host decides how the bytes travel.** That split is the whole of the
|
|
28
|
+
* boundary here.
|
|
29
|
+
*
|
|
30
|
+
* ⛔ **A site with no records service is not an error.** It has no live records,
|
|
31
|
+
* so this returns empty rather than throwing: a static site's corpus comes from
|
|
32
|
+
* its own compiled artifacts, which the caller already has.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
|
|
36
|
+
import { resolveRecordsService } from '@uniweb/core/records-service'
|
|
37
|
+
import { createDefaultFetcher } from './default-fetcher.js'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ask a site's records service for every record its declared queries return.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} content - the render payload (`config.services`, `config.queries`)
|
|
43
|
+
* @param {Object} options
|
|
44
|
+
* @param {string} options.locale - the locale to ask in; it is a route segment
|
|
45
|
+
* of the service's address, so there is no asking without one
|
|
46
|
+
* @param {Function} options.fetch - the transport, `(url, init) => Response`
|
|
47
|
+
* @param {AbortSignal} [options.signal]
|
|
48
|
+
* @param {string[]} [options.only] - restrict to these query names
|
|
49
|
+
* @returns {Promise<{records: Object, errors: Object|null, meta: Object}>}
|
|
50
|
+
* `records` is keyed by query NAME, each a flat array; `errors` is keyed the
|
|
51
|
+
* same and is null when nothing failed; `meta[name]` carries `{ depth, pages,
|
|
52
|
+
* truncated? }` — `truncated` meaning the loop hit its own bound, never that
|
|
53
|
+
* the site has more.
|
|
54
|
+
*/
|
|
55
|
+
export async function collectSiteRecords(content, { locale, fetch, signal, only = null } = {}) {
|
|
56
|
+
const config = content?.config
|
|
57
|
+
const services = config?.services ?? null
|
|
58
|
+
const queries = config?.queries ?? null
|
|
59
|
+
|
|
60
|
+
// No service, no queries, or no locale to ask in ⇒ nothing to collect. Each is
|
|
61
|
+
// an ordinary state of a site, not a fault: the caller reads its own artifacts.
|
|
62
|
+
if (!queries || typeof queries !== 'object') return empty()
|
|
63
|
+
if (!resolveRecordsService(services, locale)) return empty()
|
|
64
|
+
|
|
65
|
+
const names = Object.keys(queries).filter((n) => (only ? only.includes(n) : true))
|
|
66
|
+
if (names.length === 0) return empty()
|
|
67
|
+
|
|
68
|
+
// One synthetic source per query — `as` is the query's own name, so the answer
|
|
69
|
+
// comes back under the name the caller asked by.
|
|
70
|
+
const configs = resolveFetchConfigs(
|
|
71
|
+
names.map((name) => ({ query: name, as: name })),
|
|
72
|
+
{ services, queries, locale, defaultLocale: config?.defaultLanguage ?? locale },
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const fetcher = createDefaultFetcher({ fetch, basePath: config?.base ?? '' })
|
|
76
|
+
const records = {}
|
|
77
|
+
const errors = {}
|
|
78
|
+
const meta = {}
|
|
79
|
+
|
|
80
|
+
await Promise.all([...configs].map(async ([name, cfg]) => {
|
|
81
|
+
// ⛔ Only a config the service answers. A query that resolved to a compiled
|
|
82
|
+
// `path` has no live lane, and reading that file is the caller's business,
|
|
83
|
+
// not ours — it is in the site's own URL space and they already serve it.
|
|
84
|
+
if (!cfg.ask) return
|
|
85
|
+
const result = await fetcher.resolve({ ...cfg, depth: 'brief', exhaustive: true }, { signal })
|
|
86
|
+
if (result?.error) {
|
|
87
|
+
errors[name] = result.error
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
records[name] = Array.isArray(result?.data) ? result.data : []
|
|
91
|
+
if (result?.meta) meta[name] = result.meta
|
|
92
|
+
}))
|
|
93
|
+
|
|
94
|
+
return { records, errors: Object.keys(errors).length ? errors : null, meta }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function empty() {
|
|
98
|
+
return { records: {}, errors: null, meta: {} }
|
|
99
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import React from 'react'
|
|
11
|
+
import { siteUrl, withOpacity } from '../background-shared.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Background modes
|
|
@@ -27,20 +28,6 @@ const OVERLAY_COLORS = {
|
|
|
27
28
|
dark: 'rgba(0, 0, 0, 0.5)',
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
-
* Resolve a URL against the site's base path
|
|
32
|
-
* Prepends basePath to absolute URLs (starting with /) so they work
|
|
33
|
-
* under subdirectory deployments (e.g., /templates/international/)
|
|
34
|
-
*/
|
|
35
|
-
function resolveUrl(url) {
|
|
36
|
-
if (!url || !url.startsWith('/')) return url
|
|
37
|
-
const basePath = globalThis.uniweb?.activeWebsite?.basePath || ''
|
|
38
|
-
if (!basePath) return url
|
|
39
|
-
// Avoid double-prepending
|
|
40
|
-
if (url.startsWith(basePath + '/') || url === basePath) return url
|
|
41
|
-
return basePath + url
|
|
42
|
-
}
|
|
43
|
-
|
|
44
31
|
/**
|
|
45
32
|
* Render gradient overlay
|
|
46
33
|
*/
|
|
@@ -143,28 +130,6 @@ function GradientBackground({ gradient }) {
|
|
|
143
130
|
return <div className="background-gradient" style={style} aria-hidden="true" />
|
|
144
131
|
}
|
|
145
132
|
|
|
146
|
-
/**
|
|
147
|
-
* Convert hex color to rgba with opacity
|
|
148
|
-
*/
|
|
149
|
-
function withOpacity(color, opacity) {
|
|
150
|
-
// Handle hex colors
|
|
151
|
-
if (color.startsWith('#')) {
|
|
152
|
-
const r = parseInt(color.slice(1, 3), 16)
|
|
153
|
-
const g = parseInt(color.slice(3, 5), 16)
|
|
154
|
-
const b = parseInt(color.slice(5, 7), 16)
|
|
155
|
-
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
|
156
|
-
}
|
|
157
|
-
// Handle rgb/rgba
|
|
158
|
-
if (color.startsWith('rgb')) {
|
|
159
|
-
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
|
160
|
-
if (match) {
|
|
161
|
-
return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
// Fallback - return as is
|
|
165
|
-
return color
|
|
166
|
-
}
|
|
167
|
-
|
|
168
133
|
/**
|
|
169
134
|
* Image background
|
|
170
135
|
*/
|
|
@@ -181,7 +146,7 @@ function ImageBackground({ image }) {
|
|
|
181
146
|
const style = {
|
|
182
147
|
position: 'absolute',
|
|
183
148
|
inset: 0,
|
|
184
|
-
backgroundImage: `url(${
|
|
149
|
+
backgroundImage: `url(${siteUrl(src)})`,
|
|
185
150
|
backgroundPosition: position,
|
|
186
151
|
backgroundSize: size,
|
|
187
152
|
backgroundRepeat: 'no-repeat',
|
|
@@ -235,7 +200,7 @@ function VideoBackground({ video }) {
|
|
|
235
200
|
// Build source list: explicit sources array, or infer from src
|
|
236
201
|
const sourceList = (sources || inferSources(src)).map(s => ({
|
|
237
202
|
...s,
|
|
238
|
-
src:
|
|
203
|
+
src: siteUrl(s.src)
|
|
239
204
|
}))
|
|
240
205
|
|
|
241
206
|
return (
|
|
@@ -246,7 +211,7 @@ function VideoBackground({ video }) {
|
|
|
246
211
|
loop={loop}
|
|
247
212
|
muted={muted}
|
|
248
213
|
playsInline
|
|
249
|
-
poster={
|
|
214
|
+
poster={siteUrl(poster)}
|
|
250
215
|
aria-hidden="true"
|
|
251
216
|
>
|
|
252
217
|
{sourceList.map(({ src: sourceSrc, type }, index) => (
|
package/src/default-fetcher.js
CHANGED
|
@@ -11,21 +11,15 @@
|
|
|
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
|
|
15
|
-
* question the page asked, answered per key (the records
|
|
16
|
-
*
|
|
14
|
+
* - the host's RECORDS SERVICE — `ask:`, one POST per tick carrying every
|
|
15
|
+
* question the page asked, answered per key (the records contract, as this
|
|
16
|
+
* client reads it).
|
|
17
17
|
*
|
|
18
18
|
* `where:` / `sort:` / `limit:` are evaluated HERE, locally, over what the
|
|
19
19
|
* first lane returns — with `@uniweb/core`'s one evaluator, the same the build
|
|
20
|
-
* uses to materialize a file — and
|
|
20
|
+
* uses to materialize a file — and at the source when asked. Nothing decides
|
|
21
21
|
* that per site: the LANE decides.
|
|
22
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.
|
|
28
|
-
*
|
|
29
23
|
* ⛔ RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
|
|
30
24
|
* `supports`, `request.style` / `request.rename` and the `json-body`
|
|
31
25
|
* request-style registry. *"3rd party endpoints must be supported at the
|
|
@@ -82,34 +76,34 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
82
76
|
const pathPrefix = basePath && basePath !== '/' ? basePath.replace(/\/$/, '') : ''
|
|
83
77
|
|
|
84
78
|
|
|
85
|
-
// ⭐ THE
|
|
79
|
+
// ⭐ THE RECORDS SERVICE — a batch of the misses, one POST, merged per key.
|
|
86
80
|
//
|
|
87
81
|
// The entity store dispatches every config a page needs in one synchronous
|
|
88
|
-
// loop before awaiting any of them, so a
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
// ⛔ A
|
|
95
|
-
//
|
|
96
|
-
// is a producer defect and it is said here, per key, with no request
|
|
82
|
+
// loop before awaiting any of them, so a question enqueued here and flushed
|
|
83
|
+
// on the next microtask carries every miss of that page in one body. The
|
|
84
|
+
// batch response is never cached as one: each request gets its own answer,
|
|
85
|
+
// keyed by its own question.
|
|
86
|
+
const askQueues = new Map()
|
|
87
|
+
const askRecords = (request, ctx) => {
|
|
88
|
+
// ⛔ A question needs the query's Model ref. A payload that offers the
|
|
89
|
+
// service and carries no `config.queries` entry for the query cannot ask;
|
|
90
|
+
// that is a producer defect and it is said here, per key, with no request.
|
|
97
91
|
if (typeof request.schema !== 'string' || !request.schema) {
|
|
98
92
|
return Promise.resolve({
|
|
99
93
|
data: null,
|
|
100
|
-
error: `the payload
|
|
101
|
-
`"${request.query ?? request.as}" (config.queries) —
|
|
94
|
+
error: `the payload offers the records service but carries no Model ref for query ` +
|
|
95
|
+
`"${request.query ?? request.as}" (config.queries) — it cannot be asked`,
|
|
102
96
|
})
|
|
103
97
|
}
|
|
104
98
|
return new Promise((resolve) => {
|
|
105
|
-
const url = resolveServiceUrl(request.
|
|
106
|
-
let queue =
|
|
99
|
+
const url = resolveServiceUrl(request.ask, pathPrefix)
|
|
100
|
+
let queue = askQueues.get(url)
|
|
107
101
|
if (!queue) {
|
|
108
102
|
queue = []
|
|
109
|
-
|
|
103
|
+
askQueues.set(url, queue)
|
|
110
104
|
queueMicrotask(() => {
|
|
111
|
-
|
|
112
|
-
|
|
105
|
+
askQueues.delete(url)
|
|
106
|
+
flushAsked(url, queue, doFetch)
|
|
113
107
|
})
|
|
114
108
|
}
|
|
115
109
|
queue.push({ request, ctx, resolve })
|
|
@@ -118,8 +112,8 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
118
112
|
|
|
119
113
|
return {
|
|
120
114
|
/**
|
|
121
|
-
* The cache identity is the request's ADDRESS — or,
|
|
122
|
-
* the QUESTION (`deriveCacheKey` hashes every operator of an address-less
|
|
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
|
|
123
117
|
* request). Operators evaluated here run over a shared cached value and
|
|
124
118
|
* must NOT split the cache: two pages declaring different `where:` clauses
|
|
125
119
|
* against the same path share one entry — the file is fetched once and
|
|
@@ -131,7 +125,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
131
125
|
|
|
132
126
|
async resolve(request, ctx = {}) {
|
|
133
127
|
if (!request) return { data: null }
|
|
134
|
-
if (request.
|
|
128
|
+
if (request.ask) return askRecords(request, ctx)
|
|
135
129
|
const { path, url, transform, body: rawBody } = request
|
|
136
130
|
|
|
137
131
|
// Normalize method. Only GET and POST are supported by the default
|
|
@@ -153,7 +147,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
153
147
|
// A URL the author wrote, sent exactly as written.
|
|
154
148
|
target = url
|
|
155
149
|
} else {
|
|
156
|
-
return { data: [], error: 'No path, url or
|
|
150
|
+
return { data: [], error: 'No path, url or ask specified' }
|
|
157
151
|
}
|
|
158
152
|
|
|
159
153
|
const init = { signal: ctx.signal, method }
|
|
@@ -238,7 +232,7 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
238
232
|
|
|
239
233
|
// ⭐ Say what depth was delivered, so the record index can file it — what
|
|
240
234
|
// the config asked for, echoed: a list at brief depth when the query has
|
|
241
|
-
// a per-record source, a record in full. (
|
|
235
|
+
// a per-record source, a record in full. (The service reports `depths` per
|
|
242
236
|
// key and overrides this with what it actually served.)
|
|
243
237
|
const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
|
|
244
238
|
return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
|
|
@@ -253,16 +247,16 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
253
247
|
}
|
|
254
248
|
|
|
255
249
|
/**
|
|
256
|
-
* One question of a
|
|
257
|
-
* (the records
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
250
|
+
* One question of a batch, in the records service's own vocabulary
|
|
251
|
+
* (the records contract, §2): `schema` required, `scope` a bare path, `sort`
|
|
252
|
+
* one key spelled `date` / `-date`, `depth` brief or full. The where-object
|
|
253
|
+
* crosses as authored except for the two spellings the language settled
|
|
254
|
+
* differently from the evaluator's: `nin` is `not_in` there, and a top-level
|
|
255
|
+
* `path: { under }` — the file lane's way of naming a folder branch — is
|
|
256
|
+
* `scope`. Anything the service does not accept (`like`, a dotted path) is
|
|
257
|
+
* sent as written and refused there by name: loud, never approximated.
|
|
264
258
|
*/
|
|
265
|
-
function
|
|
259
|
+
function toQuestion(request) {
|
|
266
260
|
const q = { schema: request.schema }
|
|
267
261
|
let where = request.where && typeof request.where === 'object' ? request.where : null
|
|
268
262
|
let scope = typeof request.scope === 'string' && request.scope ? request.scope : null
|
|
@@ -277,34 +271,61 @@ function doorQuestion(request) {
|
|
|
277
271
|
if (sort) q.sort = sort
|
|
278
272
|
if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
|
|
279
273
|
if (request.depth === 'brief' || request.depth === 'full') q.depth = request.depth
|
|
274
|
+
// ⭐ `cursor` is the ONLY field here that is not the author's: it is opaque and
|
|
275
|
+
// it comes from a previous answer's `cursors` (the records contract §2). ⛔ And
|
|
276
|
+
// `exhaustive` deliberately does NOT cross — it is a client instruction about
|
|
277
|
+
// how many times to ask, not part of the question being asked.
|
|
278
|
+
if (typeof request.cursor === 'string' && request.cursor) q.cursor = request.cursor
|
|
280
279
|
return q
|
|
281
280
|
}
|
|
282
281
|
|
|
283
|
-
const
|
|
282
|
+
const OPERATOR_ALIAS = { nin: 'not_in' }
|
|
284
283
|
function renameOperators(where) {
|
|
285
284
|
if (Array.isArray(where)) return where.map(renameOperators)
|
|
286
285
|
if (!where || typeof where !== 'object') return where
|
|
287
286
|
const out = {}
|
|
288
287
|
for (const [key, value] of Object.entries(where)) {
|
|
289
|
-
out[
|
|
288
|
+
out[OPERATOR_ALIAS[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
|
|
290
289
|
}
|
|
291
290
|
return out
|
|
292
291
|
}
|
|
293
292
|
|
|
294
293
|
/**
|
|
295
|
-
* Send one batch to
|
|
294
|
+
* Send one batch to the records service and hand each question its own answer.
|
|
296
295
|
*
|
|
297
296
|
* The response is `{ data, depths?, errors?, cursors?, limits? }` (contract §5):
|
|
298
297
|
* `data` answers exactly the keys sent, `[]` when nothing matched; a key that
|
|
299
298
|
* ERRORED is absent from `data` and present in `errors`; `depths` says what was
|
|
300
299
|
* actually served, which the record index files rather than what was asked for.
|
|
301
300
|
* A key missing from both is a protocol violation and is reported as an error,
|
|
302
|
-
* never as silence.
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
301
|
+
* never as silence.
|
|
302
|
+
*
|
|
303
|
+
* ⭐ `cursors` AND `limits` ARE READ — 2026-09-06 [Diego], reversing the ruling
|
|
304
|
+
* that had them "received and IGNORED, because framework has no paging concept."
|
|
305
|
+
* ⛔ **That ruling described our client and was silently wrong about our USERS:**
|
|
306
|
+
* the service bounds every answer at 100 (the records contract §4.2/§5), and a
|
|
307
|
+
* `cursors` entry is how it says there is more. Discarding both meant a hosted
|
|
308
|
+
* list of 500 rendered 100 — no error, no warning, no way for an author to tell
|
|
309
|
+
* a bound from the end of the data. **The silent class, on a visitor's page.**
|
|
310
|
+
*
|
|
311
|
+
* Two behaviours, deliberately not one:
|
|
312
|
+
*
|
|
313
|
+
* - **a page render REPORTS** — `meta.truncated` and `meta.bound` ride the
|
|
314
|
+
* answer, and nothing pages automatically. Auto-paging here would put
|
|
315
|
+
* unbounded round trips in front of paint for a section that may only show
|
|
316
|
+
* ten rows.
|
|
317
|
+
* - **an exhaustive caller PAGES** — `request.exhaustive` follows `cursors`
|
|
318
|
+
* until the service stops issuing them. `collectSiteRecords` is the caller
|
|
319
|
+
* that wants it (a corpus is not a page), and `maxPages` bounds the loop so
|
|
320
|
+
* a service that always answers with a cursor cannot spin.
|
|
306
321
|
*/
|
|
307
|
-
|
|
322
|
+
|
|
323
|
+
/** Pages an exhaustive request will follow before giving up and reporting truncation. */
|
|
324
|
+
const MAX_PAGES = 50
|
|
325
|
+
async function flushAsked(url, queue, doFetch) {
|
|
326
|
+
// One shared page loop: the batch is sent, and any entry that asked to be
|
|
327
|
+
// exhaustive and came back with a cursor is re-sent alone until it is done.
|
|
328
|
+
const pending = new Map()
|
|
308
329
|
const body = {}
|
|
309
330
|
const keys = []
|
|
310
331
|
for (const entry of queue) {
|
|
@@ -312,7 +333,7 @@ async function flushDoor(url, queue, doFetch) {
|
|
|
312
333
|
let key = base
|
|
313
334
|
for (let n = 2; key in body; n += 1) key = `${base}#${n}`
|
|
314
335
|
keys.push(key)
|
|
315
|
-
body[key] =
|
|
336
|
+
body[key] = toQuestion(entry.request)
|
|
316
337
|
}
|
|
317
338
|
let parsed
|
|
318
339
|
try {
|
|
@@ -347,6 +368,9 @@ async function flushDoor(url, queue, doFetch) {
|
|
|
347
368
|
const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
|
|
348
369
|
const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
|
|
349
370
|
const depths = parsed && typeof parsed.depths === 'object' && parsed.depths ? parsed.depths : {}
|
|
371
|
+
// Both absent when empty, never `{}` (the records contract §5).
|
|
372
|
+
const cursors = parsed && typeof parsed.cursors === 'object' && parsed.cursors ? parsed.cursors : {}
|
|
373
|
+
const limits = parsed && typeof parsed.limits === 'object' && parsed.limits ? parsed.limits : {}
|
|
350
374
|
queue.forEach((entry, i) => {
|
|
351
375
|
const key = keys[i]
|
|
352
376
|
if (key in errors) {
|
|
@@ -361,14 +385,62 @@ async function flushDoor(url, queue, doFetch) {
|
|
|
361
385
|
return
|
|
362
386
|
}
|
|
363
387
|
if (!(key in data)) {
|
|
364
|
-
entry.resolve({ data: null, error: `the records
|
|
388
|
+
entry.resolve({ data: null, error: `the records service answered without the key "${key}"` })
|
|
365
389
|
return
|
|
366
390
|
}
|
|
367
391
|
const depth = depths[key] === 'brief' || depths[key] === 'full'
|
|
368
392
|
? depths[key]
|
|
369
393
|
: (entry.request.depth === 'brief' || entry.request.depth === 'full' ? entry.request.depth : undefined)
|
|
370
|
-
|
|
394
|
+
|
|
395
|
+
const cursor = typeof cursors[key] === 'string' && cursors[key] ? cursors[key] : null
|
|
396
|
+
const bound = typeof limits[key] === 'number' ? limits[key] : undefined
|
|
397
|
+
const rows = Array.isArray(data[key]) ? data[key] : data[key]
|
|
398
|
+
|
|
399
|
+
// An exhaustive caller collects the page and asks for the next one.
|
|
400
|
+
if (cursor && entry.request.exhaustive && Array.isArray(rows)) {
|
|
401
|
+
const acc = entry.collected ? entry.collected.concat(rows) : rows.slice()
|
|
402
|
+
const page = (entry.page || 1) + 1
|
|
403
|
+
if (page <= MAX_PAGES) {
|
|
404
|
+
pending.set(entry, { cursor, collected: acc, page, depth, bound })
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
// The loop's own bound, not the service's: report rather than spin.
|
|
408
|
+
entry.resolve({ data: acc, meta: withMeta({ depth, bound, truncated: true, pages: MAX_PAGES }) })
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows
|
|
413
|
+
const meta = withMeta({
|
|
414
|
+
depth,
|
|
415
|
+
bound,
|
|
416
|
+
// ⭐ A cursor IS the truncation signal, and it is the only one for a query
|
|
417
|
+
// that declared no `limit`: `limits` is reported only when an author's own
|
|
418
|
+
// limit was clamped (the records contract §5).
|
|
419
|
+
truncated: cursor ? true : undefined,
|
|
420
|
+
pages: entry.page && entry.page > 1 ? entry.page : undefined,
|
|
421
|
+
})
|
|
422
|
+
entry.resolve(meta ? { data: collected, meta } : { data: collected })
|
|
371
423
|
})
|
|
424
|
+
|
|
425
|
+
if (pending.size === 0) return
|
|
426
|
+
// Re-ask each unfinished key on its own — the cursor is per key, so a batch
|
|
427
|
+
// would have to correlate several independent positions through one body.
|
|
428
|
+
await Promise.all([...pending].map(([entry, state]) => {
|
|
429
|
+
const next = {
|
|
430
|
+
...entry,
|
|
431
|
+
request: { ...entry.request, cursor: state.cursor },
|
|
432
|
+
collected: state.collected,
|
|
433
|
+
page: state.page,
|
|
434
|
+
}
|
|
435
|
+
return flushAsked(url, [next], doFetch)
|
|
436
|
+
}))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Build a `meta` from the fields that are actually present, or `undefined`. */
|
|
440
|
+
function withMeta(fields) {
|
|
441
|
+
const out = {}
|
|
442
|
+
for (const [k, v] of Object.entries(fields)) if (v !== undefined) out[k] = v
|
|
443
|
+
return Object.keys(out).length ? out : undefined
|
|
372
444
|
}
|
|
373
445
|
|
|
374
446
|
/**
|
package/src/isolate-api.js
CHANGED
|
@@ -28,8 +28,46 @@
|
|
|
28
28
|
* "since" is the first PUBLISHED version (git tag) whose `@uniweb/runtime/ssr`
|
|
29
29
|
* exported the name — measured with `git log --reverse -S<name> -- src/ssr.js` and
|
|
30
30
|
* `git tag --contains`, 2026-09-04.
|
|
31
|
+
*
|
|
32
|
+
* ## ⭐ `UNRELEASED` — and why the mechanism needed it
|
|
33
|
+
*
|
|
34
|
+
* ⛔ **This file had no way to stamp an export that has not shipped, and the gap
|
|
35
|
+
* was invisible because it was written retrospectively** (2026-09-04, at 0.14.2,
|
|
36
|
+
* stamping names that had all already published). The first genuinely new export
|
|
37
|
+
* hit it immediately: the test demands every export be stamped, a stamp must be
|
|
38
|
+
* `<=` package.json's version, and the version an export will ship in **does not
|
|
39
|
+
* exist until the publish that creates it** — versions are derived from commits
|
|
40
|
+
* at publish time and never hand-edited.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ **The tempting fix is the dangerous one.** Stamping the current version
|
|
43
|
+
* (`0.16.0`) would satisfy every check and be **false**: backend reads
|
|
44
|
+
* `isolateApiFloor` from the channel index and refuses to serve a site below it,
|
|
45
|
+
* so a floor of 0.16.0 would promise an export that 0.16.0 does not contain —
|
|
46
|
+
* and a host at the floor is entitled to skip feature detection. That is a
|
|
47
|
+
* guarantee broken in the one direction the floor exists to prevent.
|
|
48
|
+
*
|
|
49
|
+
* ⇒ **`UNRELEASED` says the true thing: this export exists in the tree and in no
|
|
50
|
+
* published version.** It is stamped like any other name, so nothing rides out
|
|
51
|
+
* unnoticed, and it is EXCLUDED from the floor — which therefore never promises
|
|
52
|
+
* more than a published artifact delivers. **The floor rises one step after the
|
|
53
|
+
* publish, not one step before it**, and the sequence is:
|
|
54
|
+
*
|
|
55
|
+
* 1. land the export stamped `UNRELEASED` — the floor does not move;
|
|
56
|
+
* 2. publish (Diego; agents never publish);
|
|
57
|
+
* 3. replace `UNRELEASED` with the version that publish produced — the floor
|
|
58
|
+
* moves here, and the runtime channel's `isolateApiFloor` follows at the
|
|
59
|
+
* next channel publish;
|
|
60
|
+
* 4. tell backend, which holds the number and must ratchet it.
|
|
61
|
+
*
|
|
62
|
+
* ⚖️ **Step 3 is a real obligation, not bookkeeping**: an export left
|
|
63
|
+
* `UNRELEASED` after it ships keeps the floor below its own API forever, so a
|
|
64
|
+
* host feature-detects something it could have relied on. The guard is the
|
|
65
|
+
* test's own message.
|
|
31
66
|
*/
|
|
32
67
|
|
|
68
|
+
/** An export present in the tree and in no published version. Excluded from the floor. */
|
|
69
|
+
export const UNRELEASED = 'UNRELEASED'
|
|
70
|
+
|
|
33
71
|
/** Every export of `@uniweb/runtime/ssr`, with the version it first shipped in. */
|
|
34
72
|
export const ISOLATE_API = Object.freeze({
|
|
35
73
|
// props preparation
|
|
@@ -67,13 +105,22 @@ export const ISOLATE_API = Object.freeze({
|
|
|
67
105
|
// the composed render entry
|
|
68
106
|
createPageRenderer: '0.14.2',
|
|
69
107
|
prefetchAndHydrate: '0.14.2',
|
|
108
|
+
// the whole corpus, for a host that indexes rather than renders
|
|
109
|
+
collectSiteRecords: '0.17.0',
|
|
70
110
|
})
|
|
71
111
|
|
|
72
112
|
/**
|
|
73
113
|
* The runtime version at or above which EVERY name in `ISOLATE_API` is exported —
|
|
74
114
|
* the absolute floor a host may rely on with no feature detection.
|
|
75
115
|
*/
|
|
76
|
-
export const ISOLATE_API_FLOOR = Object.values(ISOLATE_API)
|
|
116
|
+
export const ISOLATE_API_FLOOR = Object.values(ISOLATE_API)
|
|
117
|
+
.filter((v) => v !== UNRELEASED)
|
|
118
|
+
.reduce((max, v) => (compareVersions(v, max) > 0 ? v : max), '0.0.0')
|
|
119
|
+
|
|
120
|
+
/** The exports that exist here and in no published version — empty is the steady state. */
|
|
121
|
+
export const UNRELEASED_EXPORTS = Object.freeze(
|
|
122
|
+
Object.entries(ISOLATE_API).filter(([, v]) => v === UNRELEASED).map(([name]) => name),
|
|
123
|
+
)
|
|
77
124
|
|
|
78
125
|
/** Compare two `x.y.z` versions numerically. Returns <0, 0 or >0. */
|
|
79
126
|
export function compareVersions(a, b) {
|