@uniweb/runtime 0.14.2 → 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 +177 -178
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/components/BlockRenderer.jsx +7 -0
- package/src/default-fetcher.js +250 -394
- package/src/isolate-api.js +87 -0
- package/src/page-renderer.js +2 -2
- package/src/prefetch.js +65 -19
- package/src/prepare-props.js +1 -1
- package/src/setup.js +7 -8
- package/src/wire-foundation.js +3 -1
package/src/default-fetcher.js
CHANGED
|
@@ -2,201 +2,137 @@
|
|
|
2
2
|
* Runtime default fetcher.
|
|
3
3
|
*
|
|
4
4
|
* Used as the FetcherDispatcher's terminal fallback when no foundation
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* transport claims the request. Sites that declare no transport at all —
|
|
6
|
+
* starter/docs/marketing templates hitting /data/*.json — ride on this path
|
|
7
|
+
* with zero config, and so does a site a host serves live.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* or static headers:
|
|
9
|
+
* ⭐ It speaks exactly TWO lanes, and takes NO site-level vocabulary for a
|
|
10
|
+
* backend of the author's own:
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* envelope:
|
|
19
|
-
* list: data.items
|
|
20
|
-
* item: data.article
|
|
21
|
-
* error: errors.0.message
|
|
12
|
+
* - a compiled file — `path:` under the site's base (`/data/<query>.json`,
|
|
13
|
+
* a per-record file), or a plain JSON `url:` the author wrote;
|
|
14
|
+
* - the host's QUESTION DOOR — `door:`, one POST per tick carrying every
|
|
15
|
+
* question the page asked, answered per key (the records door's contract,
|
|
16
|
+
* as this client reads it).
|
|
22
17
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
18
|
+
* `where:` / `sort:` / `limit:` are evaluated HERE, locally, over what the
|
|
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.
|
|
27
22
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
+
* ⛔ RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
|
|
30
|
+
* `supports`, `request.style` / `request.rename` and the `json-body`
|
|
31
|
+
* request-style registry. *"3rd party endpoints must be supported at the
|
|
32
|
+
* foundation level… making the runtime+core lean."* A backend with its own
|
|
33
|
+
* base, headers, wire or query language is a TRANSPORT — a named
|
|
34
|
+
* `{ resolve, cacheKey? }` the foundation (or an extension) registers and
|
|
35
|
+
* the site selects per schema in `fetcher.transports`. The build warns once
|
|
36
|
+
* and drops a retired key from the payload, so an author's backend does not
|
|
37
|
+
* silently stop being reached.
|
|
38
|
+
*
|
|
39
|
+
* Per-fetch, the request may still carry `method: 'POST'` + `body:` for a
|
|
40
|
+
* backend that takes a query in a body (GraphQL, a search endpoint);
|
|
41
|
+
* `{paramName}` placeholders in body strings are substituted from
|
|
42
|
+
* `request.dynamicContext`, so a template page's detail query can reference
|
|
43
|
+
* its route param. `transform:` and the object form of `detail:` (its own
|
|
44
|
+
* `envelope`) stay per fetch too — they describe ONE response, not a backend.
|
|
30
45
|
*
|
|
31
46
|
* Exported from a subpath — `@uniweb/runtime/default-fetcher` — for
|
|
32
47
|
* runtime-level callers (the editor's preview iframe, custom runtime
|
|
33
48
|
* harnesses). **Foundations should not import this.** A foundation that
|
|
34
|
-
* wants plain URL + JSON behavior simply omits its own
|
|
35
|
-
* runtime installs this one automatically.
|
|
36
|
-
* auth / retry / response normalization declares a named transport
|
|
37
|
-
* and composes `@uniweb/fetchers` middleware around its own `resolve()`.
|
|
38
|
-
*
|
|
39
|
-
* There is intentionally no "reuse the default and wrap it" path for
|
|
40
|
-
* foundations — doing so would duplicate this code into every foundation
|
|
41
|
-
* bundle. The subpath export exists specifically for preview-mode shells
|
|
42
|
-
* that need to delegate *non-authenticated* requests to a default-fetcher
|
|
43
|
-
* instance while intercepting authenticated ones via their own transport.
|
|
49
|
+
* wants plain URL + JSON behavior simply omits its own transport; the
|
|
50
|
+
* runtime installs this one automatically.
|
|
44
51
|
*
|
|
45
|
-
* Intentional
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* edge worker, or any custom backend) resolves the credential and forwards
|
|
50
|
-
* upstream. Framework sees a plain URL; platform owns the secret.
|
|
51
|
-
*
|
|
52
|
-
* `headers:` IS supported because static per-site headers (tenant routing,
|
|
53
|
-
* content-type negotiation, custom Accept values) aren't credentials and
|
|
54
|
-
* aren't anything sites try to hide. Sites that accidentally put a secret
|
|
55
|
-
* in `headers:` have the same problem they'd have hardcoding it in the URL:
|
|
56
|
-
* it's public. That's not a framework feature gap; it's how browsers work.
|
|
52
|
+
* Intentional omission: credentials / secrets. Any value the framework puts
|
|
53
|
+
* into the served HTML is public to the browser. Sites needing private
|
|
54
|
+
* credentials use a deployment-layer proxy — the site fetches a same-origin
|
|
55
|
+
* URL, and a layer in front resolves the credential and forwards upstream.
|
|
57
56
|
*/
|
|
58
57
|
|
|
59
58
|
import {
|
|
60
59
|
substitutePlaceholders,
|
|
61
60
|
matchWhere,
|
|
61
|
+
sortRecords,
|
|
62
|
+
sortToWire,
|
|
62
63
|
deriveCacheKey,
|
|
63
|
-
resolveRequestStyle,
|
|
64
64
|
resolveServiceUrl,
|
|
65
65
|
} from '@uniweb/core'
|
|
66
66
|
|
|
67
|
-
// The request style is the wire dialect operators are encoded in. One
|
|
68
|
-
// ships — json-body, the framework's own — and `resolveRequestStyle` is
|
|
69
|
-
// loud on any other name: it throws in dev and logs once in production.
|
|
70
|
-
// Another dialect is a named transport, from the foundation or from an
|
|
71
|
-
// extension the site selects; it is never a second built-in style.
|
|
72
|
-
|
|
73
|
-
// Operators the default fetcher knows how to handle. When listed in
|
|
74
|
-
// `config.supports`, they're shipped to the source as part of the
|
|
75
|
-
// request; when not listed, they're applied as a JS fallback after
|
|
76
|
-
// fetch. The cache key reflects which operators get pushed down — same
|
|
77
|
-
// query against different `supports:` produces different cache entries.
|
|
78
|
-
const KNOWN_OPERATORS = new Set(['where', 'limit', 'sort'])
|
|
79
|
-
|
|
80
67
|
/**
|
|
81
68
|
* @param {Object} [options]
|
|
82
69
|
* @param {string} [options.basePath=''] - Prepended to local absolute paths
|
|
83
70
|
* for subpath deployments. Remote URLs pass through unchanged.
|
|
84
|
-
* @param {
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* does not carry warns.
|
|
93
|
-
* @returns {{ resolve: (req: Object, ctx: Object) => Promise<{ data, error? }> }}
|
|
71
|
+
* @param {boolean} [options.dev=false] - Dev-mode diagnostics: a bad `sort:`
|
|
72
|
+
* throws instead of delivering the records unsorted.
|
|
73
|
+
* @param {Function|null} [options.fetch=null] - The transport. A host executing
|
|
74
|
+
* fetches outside a browser (an SSR isolate) decides how a site-relative
|
|
75
|
+
* address is dispatched — through its own origin or a service binding — and
|
|
76
|
+
* hands that in. Defaults to the global `fetch`, resolved at call time so a
|
|
77
|
+
* test stub installed later is honoured.
|
|
78
|
+
* @returns {{ cacheKey: (req: Object) => string, resolve: (req: Object, ctx: Object) => Promise<{ data, error?, meta? }> }}
|
|
94
79
|
*/
|
|
95
|
-
export function createDefaultFetcher({ basePath = '',
|
|
96
|
-
// The transport is injectable: a host executing fetches outside a browser (an SSR isolate)
|
|
97
|
-
// decides how a site-relative address such as `/_records/members` is dispatched — through its
|
|
98
|
-
// own origin or a service binding — and hands that in. Defaults to the global `fetch`, resolved
|
|
99
|
-
// at call time so a test stub installed later is honoured.
|
|
80
|
+
export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchImpl = null } = {}) {
|
|
100
81
|
const doFetch = (input, init) => (fetchImpl || globalThis.fetch)(input, init)
|
|
101
82
|
const pathPrefix = basePath && basePath !== '/' ? basePath.replace(/\/$/, '') : ''
|
|
102
83
|
|
|
103
|
-
const baseUrl = typeof config?.baseUrl === 'string'
|
|
104
|
-
? config.baseUrl.replace(/\/$/, '')
|
|
105
|
-
: ''
|
|
106
|
-
|
|
107
|
-
// Static headers merged into every remote request. Local `/data/*.json`
|
|
108
|
-
// requests are never decorated — they're just file reads under public/.
|
|
109
|
-
const staticHeaders = buildStaticHeaders(config?.headers)
|
|
110
|
-
|
|
111
|
-
// `supports:` declares which query operators (where, limit, sort) the
|
|
112
|
-
// backend evaluates at the source. Operators in this list are shipped
|
|
113
|
-
// in the request; operators not in this list are applied as a JS
|
|
114
|
-
// fallback after the response arrives. Default: empty — the framework
|
|
115
|
-
// default fetcher serving static files supports nothing natively.
|
|
116
|
-
const supports = normalizeSupports(config?.supports)
|
|
117
|
-
|
|
118
|
-
// Request style — the wire dialect operators are encoded in. Read from
|
|
119
|
-
// `site.yml fetcher.request.style`; `null`/absent and `json-body` both
|
|
120
|
-
// resolve to the one shipped style, and any other name is loud (see
|
|
121
|
-
// `resolveRequestStyle`).
|
|
122
|
-
const requestConfig = (config?.request && typeof config.request === 'object') ? config.request : {}
|
|
123
|
-
const styleName = typeof requestConfig.style === 'string' ? requestConfig.style : null
|
|
124
|
-
const style = resolveRequestStyle(styleName, { dev })
|
|
125
84
|
|
|
126
|
-
//
|
|
127
|
-
// Shallow: only the operator keys (where / limit / sort) are rewritten.
|
|
128
|
-
// Field names inside a where-object are untouched.
|
|
129
|
-
const rename = normalizeRename(requestConfig.rename, style, { dev })
|
|
130
|
-
|
|
131
|
-
// `envelope:` extends today's `transform:` to cover detail responses and
|
|
132
|
-
// errors. Three dot-paths, all optional:
|
|
133
|
-
// - envelope.list — applied on list responses. Per-fetch
|
|
134
|
-
// `transform:` on the request wins (per-fetch overrides site-level).
|
|
135
|
-
// - envelope.item — applied when request.dynamicContext is set
|
|
136
|
-
// (the request is for a template-page item).
|
|
137
|
-
// - envelope.error — extract error text from non-2xx response body.
|
|
85
|
+
// ⭐ THE QUESTION DOOR — a batch of the misses, one POST, merged per key.
|
|
138
86
|
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
87
|
+
// The entity store dispatches every config a page needs in one synchronous
|
|
88
|
+
// loop before awaiting any of them, so a door request enqueued here and
|
|
89
|
+
// flushed on the next microtask carries every miss of that page in one body
|
|
90
|
+
// The batch response is never cached as
|
|
91
|
+
// one: each request gets its own answer, keyed by its own question.
|
|
92
|
+
const doorQueues = new Map()
|
|
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) => {
|
|
105
|
+
const url = resolveServiceUrl(request.door, pathPrefix)
|
|
106
|
+
let queue = doorQueues.get(url)
|
|
107
|
+
if (!queue) {
|
|
108
|
+
queue = []
|
|
109
|
+
doorQueues.set(url, queue)
|
|
110
|
+
queueMicrotask(() => {
|
|
111
|
+
doorQueues.delete(url)
|
|
112
|
+
flushDoor(url, queue, doFetch)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
queue.push({ request, ctx, resolve })
|
|
116
|
+
})
|
|
117
|
+
}
|
|
163
118
|
|
|
164
119
|
return {
|
|
165
120
|
/**
|
|
166
|
-
*
|
|
167
|
-
* the
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* `where:` clauses against the same path share one cache entry —
|
|
173
|
-
* the file is fetched once and each page filters its own copy. With
|
|
174
|
-
* `supports: [where]`, the same two pages fire two requests because
|
|
175
|
-
* the predicate travels in the request.
|
|
121
|
+
* The cache identity is the request's ADDRESS — or, on a question door,
|
|
122
|
+
* the QUESTION (`deriveCacheKey` hashes every operator of an address-less
|
|
123
|
+
* request). Operators evaluated here run over a shared cached value and
|
|
124
|
+
* must NOT split the cache: two pages declaring different `where:` clauses
|
|
125
|
+
* against the same path share one entry — the file is fetched once and
|
|
126
|
+
* each page filters its own copy.
|
|
176
127
|
*/
|
|
177
128
|
cacheKey(request) {
|
|
178
|
-
|
|
179
|
-
// style will actually push for this request. deriveCacheKey already
|
|
180
|
-
// covers the always-keyed fields.
|
|
181
|
-
//
|
|
182
|
-
// The key also carries the style name. With one shipped style it is
|
|
183
|
-
// a constant segment, kept so that key shapes do not move.
|
|
184
|
-
const base = deriveCacheKey(request)
|
|
185
|
-
const projected = {}
|
|
186
|
-
for (const op of supports) {
|
|
187
|
-
if (!style.canPush.has(op)) continue
|
|
188
|
-
if (request[op] !== undefined) projected[op] = request[op]
|
|
189
|
-
}
|
|
190
|
-
if (Object.keys(projected).length === 0 && style.name === 'json-body') {
|
|
191
|
-
// Keep back-compat key shape when the ambient default pushes nothing.
|
|
192
|
-
return base
|
|
193
|
-
}
|
|
194
|
-
return base + '::style=' + style.name + '::' + JSON.stringify(projected)
|
|
129
|
+
return deriveCacheKey(request)
|
|
195
130
|
},
|
|
196
131
|
|
|
197
132
|
async resolve(request, ctx = {}) {
|
|
198
133
|
if (!request) return { data: null }
|
|
199
|
-
|
|
134
|
+
if (request.door) return askDoor(request, ctx)
|
|
135
|
+
const { path, url, transform, body: rawBody } = request
|
|
200
136
|
|
|
201
137
|
// Normalize method. Only GET and POST are supported by the default
|
|
202
138
|
// fetcher — mutations (PUT/PATCH/DELETE) are a different feature
|
|
@@ -208,77 +144,19 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
|
|
|
208
144
|
}
|
|
209
145
|
|
|
210
146
|
let target
|
|
211
|
-
|
|
212
|
-
if (endpoint) {
|
|
213
|
-
// A host-declared collection lane, resolved upstream from the pattern
|
|
214
|
-
// it published (`@uniweb/core/query-address`). FINAL ON ARRIVAL:
|
|
215
|
-
//
|
|
216
|
-
// - `baseUrl` is NOT joined. That knob points a site at ITS OWN
|
|
217
|
-
// backend; prepending it to an address a host composed would
|
|
218
|
-
// corrupt exactly the layout the pattern exists to let them own.
|
|
219
|
-
// - the site `base` IS applied to a rooted address, the same rule
|
|
220
|
-
// every other site-relative address follows — shared with
|
|
221
|
-
// `resolveServiceUrl` rather than spelled a second time here.
|
|
222
|
-
//
|
|
223
|
-
// Remote semantics otherwise: this answers a QUERY, so operator
|
|
224
|
-
// pushdown and static headers both apply, which is what separates it
|
|
225
|
-
// from `path` (a static file that can neither filter nor sort).
|
|
226
|
-
target = resolveServiceUrl(endpoint, pathPrefix)
|
|
227
|
-
isRemote = true
|
|
228
|
-
} else if (path) {
|
|
147
|
+
if (path) {
|
|
229
148
|
// Local file under public/ — basePath applies for subpath deploys.
|
|
230
149
|
target = pathPrefix && path.startsWith('/') && !path.startsWith('//')
|
|
231
150
|
? pathPrefix + path
|
|
232
151
|
: path
|
|
233
|
-
isRemote = false
|
|
234
152
|
} else if (url) {
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
target = isAbsoluteUrl(url) ? url : joinUrl(baseUrl, url)
|
|
238
|
-
isRemote = true
|
|
153
|
+
// A URL the author wrote, sent exactly as written.
|
|
154
|
+
target = url
|
|
239
155
|
} else {
|
|
240
|
-
return { data: [], error: 'No path, url or
|
|
156
|
+
return { data: [], error: 'No path, url or door specified' }
|
|
241
157
|
}
|
|
242
158
|
|
|
243
159
|
const init = { signal: ctx.signal, method }
|
|
244
|
-
const headers = {}
|
|
245
|
-
|
|
246
|
-
// Static site-level headers go on remote requests only — we don't
|
|
247
|
-
// decorate local file reads with tenant/content-type headers.
|
|
248
|
-
if (isRemote && staticHeaders) Object.assign(headers, staticHeaders)
|
|
249
|
-
|
|
250
|
-
// Push down supported query operators to the source via the active
|
|
251
|
-
// request style. Pushdown only applies to remote URLs — local `path:`
|
|
252
|
-
// reads are static files that can't filter or sort. Operators the
|
|
253
|
-
// style didn't push get applied as a JS fallback after the response
|
|
254
|
-
// (see the post-fetch block below).
|
|
255
|
-
//
|
|
256
|
-
// The style owns the wire format: json-body encodes GET pushdown as
|
|
257
|
-
// `?_where=<JSON>&_limit=&_sort=` and POST pushdown as top-level keys
|
|
258
|
-
// merged into an object body. It is the only shipped wire — another
|
|
259
|
-
// dialect is a named transport.
|
|
260
|
-
const pushCandidates = new Set()
|
|
261
|
-
if (isRemote) {
|
|
262
|
-
for (const op of KNOWN_OPERATORS) {
|
|
263
|
-
if (
|
|
264
|
-
supports.has(op) &&
|
|
265
|
-
style.canPush.has(op) &&
|
|
266
|
-
request[op] !== undefined &&
|
|
267
|
-
request[op] !== null
|
|
268
|
-
) {
|
|
269
|
-
pushCandidates.add(op)
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const encoded = pushCandidates.size > 0
|
|
275
|
-
? style.encode(request, { method, pushCandidates, rename })
|
|
276
|
-
: { queryParams: [], bodyMerge: null, pushed: new Set() }
|
|
277
|
-
const pushedOperators = encoded.pushed
|
|
278
|
-
|
|
279
|
-
if (encoded.queryParams.length > 0 && method === 'GET') {
|
|
280
|
-
target = appendStyleQueryParams(target, encoded.queryParams)
|
|
281
|
-
}
|
|
282
160
|
|
|
283
161
|
if (method === 'POST') {
|
|
284
162
|
// Substitute {paramName} placeholders in body strings using the
|
|
@@ -286,50 +164,35 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
|
|
|
286
164
|
// build it from dynamicContext's { paramName, paramValue } shape.
|
|
287
165
|
// Strict-brace matcher: GraphQL selection sets pass through unchanged.
|
|
288
166
|
const dc = request.dynamicContext
|
|
289
|
-
const
|
|
167
|
+
const body = (rawBody !== undefined && rawBody !== null && dc && dc.paramName)
|
|
290
168
|
? substitutePlaceholders(rawBody, { [dc.paramName]: dc.paramValue }, { encode: false })
|
|
291
169
|
: rawBody
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// a body containing just the pushed operators if any exist.
|
|
296
|
-
const finalBody = composePostBody(resolvedBody, encoded.bodyMerge)
|
|
297
|
-
|
|
298
|
-
if (finalBody !== null) {
|
|
299
|
-
// Default Content-Type to JSON unless the site's static headers
|
|
300
|
-
// already set one (for application/graphql or form-urlencoded).
|
|
301
|
-
if (!hasHeader(headers, 'Content-Type')) {
|
|
302
|
-
headers['Content-Type'] = 'application/json'
|
|
303
|
-
}
|
|
304
|
-
init.body = typeof finalBody === 'string' ? finalBody : JSON.stringify(finalBody)
|
|
170
|
+
if (body !== undefined && body !== null) {
|
|
171
|
+
init.headers = { 'Content-Type': 'application/json' }
|
|
172
|
+
init.body = typeof body === 'string' ? body : JSON.stringify(body)
|
|
305
173
|
}
|
|
306
174
|
}
|
|
307
175
|
|
|
308
|
-
if (Object.keys(headers).length) init.headers = headers
|
|
309
|
-
|
|
310
176
|
try {
|
|
311
177
|
const response = await doFetch(target, init)
|
|
312
178
|
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
const requestEnvelope = (request.envelope && typeof request.envelope === 'object')
|
|
179
|
+
// A per-request envelope (set by the object form of `detail:`) describes
|
|
180
|
+
// this one response.
|
|
181
|
+
const envelope = (request.envelope && typeof request.envelope === 'object')
|
|
317
182
|
? request.envelope
|
|
318
|
-
:
|
|
319
|
-
const effectiveEnvelope = requestEnvelope
|
|
320
|
-
?? (endpoint && laneEnvelope ? { ...envelope, ...laneEnvelope } : envelope)
|
|
183
|
+
: {}
|
|
321
184
|
|
|
322
185
|
if (!response.ok) {
|
|
323
|
-
// If `envelope.error`
|
|
186
|
+
// If `envelope.error` names a path, try to extract a human message
|
|
324
187
|
// from the parsed body; fall back to status text if the path is
|
|
325
188
|
// missing or the body isn't JSON.
|
|
326
189
|
let extracted
|
|
327
|
-
if (
|
|
190
|
+
if (envelope.error) {
|
|
328
191
|
try {
|
|
329
192
|
const text = await response.text()
|
|
330
193
|
const body = safeParseJSON(text)
|
|
331
194
|
if (body !== undefined) {
|
|
332
|
-
const candidate = getNestedValue(body,
|
|
195
|
+
const candidate = getNestedValue(body, envelope.error)
|
|
333
196
|
if (typeof candidate === 'string' && candidate.length) {
|
|
334
197
|
extracted = candidate
|
|
335
198
|
}
|
|
@@ -357,25 +220,28 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
|
|
|
357
220
|
}
|
|
358
221
|
}
|
|
359
222
|
|
|
360
|
-
// Unwrap response
|
|
361
|
-
//
|
|
362
|
-
// 2. Per-request `envelope.item` (detail) or `envelope.list`.
|
|
363
|
-
// 3. Site-level `envelope.item` (detail) or `envelope.list`.
|
|
223
|
+
// Unwrap the response. Per-fetch `transform:` wins; otherwise the
|
|
224
|
+
// envelope's `item` path on a single-record request, `list` on a list.
|
|
364
225
|
const isDetailRequest = !!request.dynamicContext
|
|
365
226
|
const effectiveTransform =
|
|
366
227
|
transform
|
|
367
|
-
|| (isDetailRequest ?
|
|
228
|
+
|| (isDetailRequest ? envelope.item : envelope.list)
|
|
368
229
|
if (effectiveTransform && data !== null && data !== undefined) {
|
|
369
230
|
data = getNestedValue(data, effectiveTransform)
|
|
370
231
|
}
|
|
371
232
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
data =
|
|
377
|
-
|
|
378
|
-
|
|
233
|
+
// Evaluate the query locally. Only applies to array data
|
|
234
|
+
// (filtering/sorting/limiting a single record doesn't make sense).
|
|
235
|
+
// For non-arrays, operators are ignored — the source returned what
|
|
236
|
+
// it returned.
|
|
237
|
+
data = applyOperators(data, request, { dev })
|
|
238
|
+
|
|
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.)
|
|
243
|
+
const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
|
|
244
|
+
return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
|
|
379
245
|
} catch (error) {
|
|
380
246
|
if (error?.name === 'AbortError') {
|
|
381
247
|
return { data: [], error: 'aborted' }
|
|
@@ -387,168 +253,158 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
|
|
|
387
253
|
}
|
|
388
254
|
|
|
389
255
|
/**
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
* `
|
|
393
|
-
*
|
|
256
|
+
* One question of a door batch, in the door's own vocabulary
|
|
257
|
+
* (the records door's contract, §2): `schema` required, `scope` a bare
|
|
258
|
+
* path, `sort` one key spelled `date` / `-date`, `depth` brief or full. The
|
|
259
|
+
* where-object crosses as authored except for the two spellings the language
|
|
260
|
+
* settled differently from the evaluator's: `nin` is `not_in` there, and a
|
|
261
|
+
* top-level `path: { under }` — the file lane's way of naming a folder branch —
|
|
262
|
+
* is the door's `scope`. Anything the door does not accept (`like`, a dotted
|
|
263
|
+
* path) is sent as written and refused there by name: loud, never approximated.
|
|
394
264
|
*/
|
|
395
|
-
function
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
`[default-fetcher] request.rename: operator "${op}" is not pushed by ` +
|
|
404
|
-
`style "${style.name}" — rename has no effect. Known operators for ` +
|
|
405
|
-
`this style: ${[...style.canPush].join(', ') || '(none)'}.`,
|
|
406
|
-
)
|
|
407
|
-
}
|
|
408
|
-
out[op] = wireName
|
|
265
|
+
function doorQuestion(request) {
|
|
266
|
+
const q = { schema: request.schema }
|
|
267
|
+
let where = request.where && typeof request.where === 'object' ? request.where : null
|
|
268
|
+
let scope = typeof request.scope === 'string' && request.scope ? request.scope : null
|
|
269
|
+
if (where && !scope && where.path && typeof where.path === 'object' && typeof where.path.under === 'string' && where.path.under) {
|
|
270
|
+
const { path, ...rest } = where
|
|
271
|
+
scope = path.under
|
|
272
|
+
where = Object.keys(rest).length ? rest : null
|
|
409
273
|
}
|
|
410
|
-
|
|
274
|
+
if (scope) q.scope = scope
|
|
275
|
+
if (where) q.where = renameOperators(where)
|
|
276
|
+
const sort = sortToWire(request.sort)
|
|
277
|
+
if (sort) q.sort = sort
|
|
278
|
+
if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
|
|
279
|
+
if (request.depth === 'brief' || request.depth === 'full') q.depth = request.depth
|
|
280
|
+
return q
|
|
411
281
|
}
|
|
412
|
-
const warnedRenameTargets = new Set()
|
|
413
282
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
for (const op of raw) {
|
|
422
|
-
if (typeof op !== 'string') continue
|
|
423
|
-
if (KNOWN_OPERATORS.has(op)) out.add(op)
|
|
424
|
-
else if (!warnedUnknownOperators.has(op)) {
|
|
425
|
-
warnedUnknownOperators.add(op)
|
|
426
|
-
console.warn(`[default-fetcher] supports: unknown operator "${op}" — ignored.`)
|
|
427
|
-
}
|
|
283
|
+
const DOOR_OPERATOR = { nin: 'not_in' }
|
|
284
|
+
function renameOperators(where) {
|
|
285
|
+
if (Array.isArray(where)) return where.map(renameOperators)
|
|
286
|
+
if (!where || typeof where !== 'object') return where
|
|
287
|
+
const out = {}
|
|
288
|
+
for (const [key, value] of Object.entries(where)) {
|
|
289
|
+
out[DOOR_OPERATOR[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
|
|
428
290
|
}
|
|
429
291
|
return out
|
|
430
292
|
}
|
|
431
|
-
const warnedUnknownOperators = new Set()
|
|
432
293
|
|
|
433
294
|
/**
|
|
434
|
-
*
|
|
435
|
-
* parameters. Existing query string is preserved; values are URL-encoded.
|
|
436
|
-
*/
|
|
437
|
-
function appendStyleQueryParams(url, pairs) {
|
|
438
|
-
if (!pairs || pairs.length === 0) return url
|
|
439
|
-
const params = pairs.map(
|
|
440
|
-
([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v),
|
|
441
|
-
)
|
|
442
|
-
const sep = url.includes('?') ? '&' : '?'
|
|
443
|
-
return url + sep + params.join('&')
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* Compose a POST body that includes the style's bodyMerge alongside the
|
|
448
|
-
* author-supplied body. When neither exists, returns null (no body sent).
|
|
295
|
+
* Send one batch to a door and hand each question its own answer.
|
|
449
296
|
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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.
|
|
454
306
|
*/
|
|
455
|
-
function
|
|
456
|
-
|
|
457
|
-
|
|
307
|
+
async function flushDoor(url, queue, doFetch) {
|
|
308
|
+
const body = {}
|
|
309
|
+
const keys = []
|
|
310
|
+
for (const entry of queue) {
|
|
311
|
+
const base = entry.request.as || 'q'
|
|
312
|
+
let key = base
|
|
313
|
+
for (let n = 2; key in body; n += 1) key = `${base}#${n}`
|
|
314
|
+
keys.push(key)
|
|
315
|
+
body[key] = doorQuestion(entry.request)
|
|
458
316
|
}
|
|
459
|
-
|
|
460
|
-
|
|
317
|
+
let parsed
|
|
318
|
+
try {
|
|
319
|
+
const response = await doFetch(url, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: { 'Content-Type': 'application/json' },
|
|
322
|
+
body: JSON.stringify(body),
|
|
323
|
+
})
|
|
324
|
+
if (!response.ok) {
|
|
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}`
|
|
338
|
+
for (const entry of queue) entry.resolve({ data: null, error })
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
parsed = await response.json()
|
|
342
|
+
} catch (error) {
|
|
343
|
+
const message = error?.name === 'AbortError' ? 'aborted' : (error?.message || String(error))
|
|
344
|
+
for (const entry of queue) entry.resolve({ data: null, error: message })
|
|
345
|
+
return
|
|
461
346
|
}
|
|
462
|
-
const
|
|
463
|
-
|
|
347
|
+
const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
|
|
348
|
+
const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
|
|
349
|
+
const depths = parsed && typeof parsed.depths === 'object' && parsed.depths ? parsed.depths : {}
|
|
350
|
+
queue.forEach((entry, i) => {
|
|
351
|
+
const key = keys[i]
|
|
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.
|
|
356
|
+
const e = errors[key]
|
|
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
|
+
return
|
|
362
|
+
}
|
|
363
|
+
if (!(key in data)) {
|
|
364
|
+
entry.resolve({ data: null, error: `the records door answered without the key "${key}"` })
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
const depth = depths[key] === 'brief' || depths[key] === 'full'
|
|
368
|
+
? depths[key]
|
|
369
|
+
: (entry.request.depth === 'brief' || entry.request.depth === 'full' ? entry.request.depth : undefined)
|
|
370
|
+
entry.resolve(depth ? { data: data[key], meta: { depth } } : { data: data[key] })
|
|
371
|
+
})
|
|
464
372
|
}
|
|
465
373
|
|
|
466
374
|
/**
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
375
|
+
* Evaluate the query over what the source returned — the ONE evaluator,
|
|
376
|
+
* `@uniweb/core`'s, so the browser orders and filters exactly as the build
|
|
377
|
+
* did when it materialized `/data/<name>.json`.
|
|
470
378
|
*/
|
|
471
|
-
function
|
|
379
|
+
function applyOperators(data, request, { dev = false } = {}) {
|
|
472
380
|
if (!Array.isArray(data)) return data
|
|
473
381
|
let result = data
|
|
474
|
-
|
|
475
|
-
if (request.
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
if (request.sort && !pushedOperators.has('sort')) {
|
|
479
|
-
result = applySortFallback(result, request.sort)
|
|
480
|
-
}
|
|
481
|
-
if (typeof request.limit === 'number' && request.limit > 0 && !pushedOperators.has('limit')) {
|
|
482
|
-
result = result.slice(0, request.limit)
|
|
483
|
-
}
|
|
382
|
+
if (request.where) result = matchWhere(request.where, result)
|
|
383
|
+
if (request.sort) result = applySort(result, request.sort, dev)
|
|
384
|
+
if (typeof request.limit === 'number' && request.limit > 0) result = result.slice(0, request.limit)
|
|
484
385
|
return result
|
|
485
386
|
}
|
|
486
387
|
|
|
487
388
|
/**
|
|
488
|
-
*
|
|
489
|
-
*
|
|
389
|
+
* ⛔ This was a second sort implementation until 2026-09-04, and it honoured
|
|
390
|
+
* a comma-separated MULTI-KEY sort the language does not have (single-key by
|
|
391
|
+
* ruling). A bad `sort:` is an authoring error: dev throws so it is seen; in
|
|
392
|
+
* production the records are delivered in source order and the reason is
|
|
393
|
+
* logged once — a wrong order is not worth a broken page for a visitor.
|
|
490
394
|
*/
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
return
|
|
495
|
-
})
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (av > bv) return desc ? -1 : 1
|
|
395
|
+
const warnedBadSorts = new Set()
|
|
396
|
+
function applySort(items, sortExpr, dev) {
|
|
397
|
+
try {
|
|
398
|
+
return sortRecords(items, sortExpr)
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (dev) throw err
|
|
401
|
+
const key = String(sortExpr)
|
|
402
|
+
if (!warnedBadSorts.has(key)) {
|
|
403
|
+
warnedBadSorts.add(key)
|
|
404
|
+
console.error(`[default-fetcher] ${err.message} Records delivered unsorted.`)
|
|
502
405
|
}
|
|
503
|
-
return
|
|
504
|
-
})
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Build the static headers object from `site.yml fetcher.headers:`. Returns
|
|
509
|
-
* null when none are configured so the caller can skip adding an empty
|
|
510
|
-
* `headers` init option.
|
|
511
|
-
*/
|
|
512
|
-
function buildStaticHeaders(headers) {
|
|
513
|
-
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return null
|
|
514
|
-
const out = {}
|
|
515
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
516
|
-
if (v === null || v === undefined) continue
|
|
517
|
-
out[k] = String(v)
|
|
406
|
+
return items
|
|
518
407
|
}
|
|
519
|
-
return Object.keys(out).length ? out : null
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* Case-insensitive header-key check. Lets a site write `Content-Type` or
|
|
524
|
-
* `content-type` and still override the POST default correctly.
|
|
525
|
-
*/
|
|
526
|
-
function hasHeader(headers, name) {
|
|
527
|
-
const lower = name.toLowerCase()
|
|
528
|
-
return Object.keys(headers).some((k) => k.toLowerCase() === lower)
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* Is this URL absolute (has a scheme) or protocol-relative? Those two pass
|
|
533
|
-
* through the default fetcher unchanged. Everything else is considered
|
|
534
|
-
* relative and resolves against `config.baseUrl` (if set).
|
|
535
|
-
*/
|
|
536
|
-
function isAbsoluteUrl(url) {
|
|
537
|
-
if (typeof url !== 'string') return false
|
|
538
|
-
if (url.startsWith('//')) return true // protocol-relative
|
|
539
|
-
return /^[a-z][a-z0-9+.-]*:\/\//i.test(url) // scheme://…
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
* Join `baseUrl` with a relative `url`, avoiding double slashes. If `baseUrl`
|
|
544
|
-
* is empty, the url is returned unchanged — even if relative — so sites that
|
|
545
|
-
* don't set `baseUrl` behave exactly like they did before this capability
|
|
546
|
-
* was added.
|
|
547
|
-
*/
|
|
548
|
-
function joinUrl(baseUrl, url) {
|
|
549
|
-
if (!baseUrl) return url
|
|
550
|
-
if (url.startsWith('/')) return baseUrl + url
|
|
551
|
-
return baseUrl + '/' + url
|
|
552
408
|
}
|
|
553
409
|
|
|
554
410
|
/**
|