@uniweb/runtime 0.14.2 → 0.15.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 +160 -169
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/components/BlockRenderer.jsx +7 -0
- package/src/default-fetcher.js +234 -378
- package/src/isolate-api.js +86 -0
- package/src/prefetch.js +63 -17
- package/src/prepare-props.js +1 -1
- package/src/setup.js +7 -5
- package/src/wire-foundation.js +3 -1
package/src/default-fetcher.js
CHANGED
|
@@ -2,200 +2,138 @@
|
|
|
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 THREE lanes, and takes NO site-level vocabulary for a
|
|
10
|
+
* backend of the author's own:
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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 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
|
+
* - the host's QUESTION DOOR — `door:`, one POST per tick carrying every
|
|
18
|
+
* question the page asked, answered per key (the records door's contract,
|
|
19
|
+
* as this client reads it).
|
|
22
20
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
21
|
+
* `where:` / `sort:` / `limit:` are evaluated HERE, locally, over what the
|
|
22
|
+
* first two lanes return — with `@uniweb/core`'s one evaluator, the same the
|
|
23
|
+
* build uses to materialize a file — and by the source on the third. Nothing
|
|
24
|
+
* decides that per site: the LANE decides.
|
|
27
25
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* ⛔ RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
|
|
27
|
+
* `supports`, `request.style` / `request.rename` and the `json-body`
|
|
28
|
+
* request-style registry. *"3rd party endpoints must be supported at the
|
|
29
|
+
* foundation level… making the runtime+core lean."* A backend with its own
|
|
30
|
+
* base, headers, wire or query language is a TRANSPORT — a named
|
|
31
|
+
* `{ resolve, cacheKey? }` the foundation (or an extension) registers and
|
|
32
|
+
* the site selects per schema in `fetcher.transports`. The build warns once
|
|
33
|
+
* and drops a retired key from the payload, so an author's backend does not
|
|
34
|
+
* silently stop being reached.
|
|
35
|
+
*
|
|
36
|
+
* Per-fetch, the request may still carry `method: 'POST'` + `body:` for a
|
|
37
|
+
* backend that takes a query in a body (GraphQL, a search endpoint);
|
|
38
|
+
* `{paramName}` placeholders in body strings are substituted from
|
|
39
|
+
* `request.dynamicContext`, so a template page's detail query can reference
|
|
40
|
+
* its route param. `transform:` and the object form of `detail:` (its own
|
|
41
|
+
* `envelope`) stay per fetch too — they describe ONE response, not a backend.
|
|
30
42
|
*
|
|
31
43
|
* Exported from a subpath — `@uniweb/runtime/default-fetcher` — for
|
|
32
44
|
* runtime-level callers (the editor's preview iframe, custom runtime
|
|
33
45
|
* 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.
|
|
44
|
-
*
|
|
45
|
-
* Intentional omissions: credentials / secrets are NOT part of the vocabulary.
|
|
46
|
-
* Any value the framework puts into the served HTML is public to the browser.
|
|
47
|
-
* Sites needing private credentials use a deployment-layer proxy — the site
|
|
48
|
-
* fetches a same-origin URL, and a layer in front (e.g. the Uniweb platform's
|
|
49
|
-
* edge worker, or any custom backend) resolves the credential and forwards
|
|
50
|
-
* upstream. Framework sees a plain URL; platform owns the secret.
|
|
46
|
+
* wants plain URL + JSON behavior simply omits its own transport; the
|
|
47
|
+
* runtime installs this one automatically.
|
|
51
48
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* it's public. That's not a framework feature gap; it's how browsers work.
|
|
49
|
+
* Intentional omission: credentials / secrets. Any value the framework puts
|
|
50
|
+
* into the served HTML is public to the browser. Sites needing private
|
|
51
|
+
* credentials use a deployment-layer proxy — the site fetches a same-origin
|
|
52
|
+
* URL, and a layer in front resolves the credential and forwards upstream.
|
|
57
53
|
*/
|
|
58
54
|
|
|
59
55
|
import {
|
|
60
56
|
substitutePlaceholders,
|
|
61
57
|
matchWhere,
|
|
58
|
+
sortRecords,
|
|
59
|
+
sortToWire,
|
|
62
60
|
deriveCacheKey,
|
|
63
|
-
resolveRequestStyle,
|
|
64
61
|
resolveServiceUrl,
|
|
65
62
|
} from '@uniweb/core'
|
|
66
63
|
|
|
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
64
|
/**
|
|
81
65
|
* @param {Object} [options]
|
|
82
66
|
* @param {string} [options.basePath=''] - Prepended to local absolute paths
|
|
83
67
|
* for subpath deployments. Remote URLs pass through unchanged.
|
|
84
|
-
* @param {
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* `
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
68
|
+
* @param {boolean} [options.dev=false] - Dev-mode diagnostics: a bad `sort:`
|
|
69
|
+
* 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
|
+
* @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, records = null, 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
|
-
|
|
126
|
-
// Operator-name renames applied on top of the style's wire names.
|
|
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.
|
|
138
|
-
//
|
|
139
|
-
// Priority (highest wins): per-fetch request.envelope > site-level
|
|
140
|
-
// config.envelope > style.defaultEnvelope. json-body declares no
|
|
141
|
-
// envelope; the slot is the encoder's to fill, and a site-level value
|
|
142
|
-
// always wins over it.
|
|
143
|
-
const siteEnvelope = (config?.envelope && typeof config.envelope === 'object')
|
|
144
|
-
? config.envelope
|
|
145
|
-
: null
|
|
146
|
-
const envelope = { ...(style.defaultEnvelope || {}), ...(siteEnvelope || {}) }
|
|
147
|
-
|
|
148
84
|
// ⭐ The LIVE LANE's envelope is the backend's. `config.records` is stamped by the backend
|
|
149
85
|
// that answers a records request, so where the array sits in ITS response is its to
|
|
150
86
|
// declare: `records.envelope.records` — the KEY says what it holds, the VALUE is the JSON
|
|
151
87
|
// key the array sits under (`{ records: "entries" }` ⇒ body.entries). That spelling is the
|
|
152
88
|
// agreed one (2026-08-30: `collection` retired; ⛔ not `list`, which is a URL pattern on the
|
|
153
|
-
// same stamp). It applies only to a request that resolved to that lane (`endpoint` set)
|
|
154
|
-
// and wins over the site's own `fetcher.envelope`, which describes the author's backend.
|
|
89
|
+
// same stamp). It applies only to a request that resolved to that lane (`endpoint` set).
|
|
155
90
|
// Ruled 2026-09-03 [Diego]: the backend sets `config.records`; the fetch comes from the
|
|
156
|
-
// runtime.
|
|
157
|
-
// its envelope.
|
|
91
|
+
// runtime.
|
|
158
92
|
const stampedArrayKey = (records?.envelope && typeof records.envelope === 'object'
|
|
159
93
|
&& typeof records.envelope.records === 'string' && records.envelope.records.length)
|
|
160
94
|
? records.envelope.records
|
|
161
95
|
: null
|
|
162
96
|
const laneEnvelope = stampedArrayKey ? { list: stampedArrayKey } : null
|
|
163
97
|
|
|
98
|
+
// ⭐ THE QUESTION DOOR — a batch of the misses, one POST, merged per key.
|
|
99
|
+
//
|
|
100
|
+
// The entity store dispatches every config a page needs in one synchronous
|
|
101
|
+
// loop before awaiting any of them, so a door request enqueued here and
|
|
102
|
+
// flushed on the next microtask carries every miss of that page in one body
|
|
103
|
+
// The batch response is never cached as
|
|
104
|
+
// one: each request gets its own answer, keyed by its own question.
|
|
105
|
+
const doorQueues = new Map()
|
|
106
|
+
const askDoor = (request, ctx) =>
|
|
107
|
+
new Promise((resolve) => {
|
|
108
|
+
const url = resolveServiceUrl(request.door, pathPrefix)
|
|
109
|
+
let queue = doorQueues.get(url)
|
|
110
|
+
if (!queue) {
|
|
111
|
+
queue = []
|
|
112
|
+
doorQueues.set(url, queue)
|
|
113
|
+
queueMicrotask(() => {
|
|
114
|
+
doorQueues.delete(url)
|
|
115
|
+
flushDoor(url, queue, doFetch)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
queue.push({ request, ctx, resolve })
|
|
119
|
+
})
|
|
120
|
+
|
|
164
121
|
return {
|
|
165
122
|
/**
|
|
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.
|
|
123
|
+
* The cache identity is the request's ADDRESS — or, on a question door,
|
|
124
|
+
* the QUESTION (`deriveCacheKey` hashes every operator of an address-less
|
|
125
|
+
* request). Operators evaluated here run over a shared cached value and
|
|
126
|
+
* must NOT split the cache: two pages declaring different `where:` clauses
|
|
127
|
+
* against the same path share one entry — the file is fetched once and
|
|
128
|
+
* each page filters its own copy.
|
|
176
129
|
*/
|
|
177
130
|
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)
|
|
131
|
+
return deriveCacheKey(request)
|
|
195
132
|
},
|
|
196
133
|
|
|
197
134
|
async resolve(request, ctx = {}) {
|
|
198
135
|
if (!request) return { data: null }
|
|
136
|
+
if (request.door) return askDoor(request, ctx)
|
|
199
137
|
const { path, url, endpoint, transform, body: rawBody } = request
|
|
200
138
|
|
|
201
139
|
// Normalize method. Only GET and POST are supported by the default
|
|
@@ -208,77 +146,36 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
|
|
|
208
146
|
}
|
|
209
147
|
|
|
210
148
|
let target
|
|
211
|
-
let isRemote
|
|
212
149
|
if (endpoint) {
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
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).
|
|
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.
|
|
226
156
|
target = resolveServiceUrl(endpoint, pathPrefix)
|
|
227
|
-
|
|
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
|
+
}
|
|
228
166
|
} else if (path) {
|
|
229
167
|
// Local file under public/ — basePath applies for subpath deploys.
|
|
230
168
|
target = pathPrefix && path.startsWith('/') && !path.startsWith('//')
|
|
231
169
|
? pathPrefix + path
|
|
232
170
|
: path
|
|
233
|
-
isRemote = false
|
|
234
171
|
} else if (url) {
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
target = isAbsoluteUrl(url) ? url : joinUrl(baseUrl, url)
|
|
238
|
-
isRemote = true
|
|
172
|
+
// A URL the author wrote, sent exactly as written.
|
|
173
|
+
target = url
|
|
239
174
|
} else {
|
|
240
175
|
return { data: [], error: 'No path, url or endpoint specified' }
|
|
241
176
|
}
|
|
242
177
|
|
|
243
178
|
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
179
|
|
|
283
180
|
if (method === 'POST') {
|
|
284
181
|
// Substitute {paramName} placeholders in body strings using the
|
|
@@ -286,50 +183,36 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
|
|
|
286
183
|
// build it from dynamicContext's { paramName, paramValue } shape.
|
|
287
184
|
// Strict-brace matcher: GraphQL selection sets pass through unchanged.
|
|
288
185
|
const dc = request.dynamicContext
|
|
289
|
-
const
|
|
186
|
+
const body = (rawBody !== undefined && rawBody !== null && dc && dc.paramName)
|
|
290
187
|
? substitutePlaceholders(rawBody, { [dc.paramName]: dc.paramValue }, { encode: false })
|
|
291
188
|
: 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)
|
|
189
|
+
if (body !== undefined && body !== null) {
|
|
190
|
+
init.headers = { 'Content-Type': 'application/json' }
|
|
191
|
+
init.body = typeof body === 'string' ? body : JSON.stringify(body)
|
|
305
192
|
}
|
|
306
193
|
}
|
|
307
194
|
|
|
308
|
-
if (Object.keys(headers).length) init.headers = headers
|
|
309
|
-
|
|
310
195
|
try {
|
|
311
196
|
const response = await doFetch(target, init)
|
|
312
197
|
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// item/collection/error paths independently of the collection.
|
|
198
|
+
// A per-request envelope (set by the object form of `detail:`) describes
|
|
199
|
+
// this one response; on the address door the stamp describes the lane.
|
|
316
200
|
const requestEnvelope = (request.envelope && typeof request.envelope === 'object')
|
|
317
201
|
? request.envelope
|
|
318
202
|
: null
|
|
319
|
-
const
|
|
320
|
-
?? (endpoint && laneEnvelope ? { ...envelope, ...laneEnvelope } : envelope)
|
|
203
|
+
const envelope = requestEnvelope ?? (endpoint && laneEnvelope ? laneEnvelope : {})
|
|
321
204
|
|
|
322
205
|
if (!response.ok) {
|
|
323
|
-
// If `envelope.error`
|
|
206
|
+
// If `envelope.error` names a path, try to extract a human message
|
|
324
207
|
// from the parsed body; fall back to status text if the path is
|
|
325
208
|
// missing or the body isn't JSON.
|
|
326
209
|
let extracted
|
|
327
|
-
if (
|
|
210
|
+
if (envelope.error) {
|
|
328
211
|
try {
|
|
329
212
|
const text = await response.text()
|
|
330
213
|
const body = safeParseJSON(text)
|
|
331
214
|
if (body !== undefined) {
|
|
332
|
-
const candidate = getNestedValue(body,
|
|
215
|
+
const candidate = getNestedValue(body, envelope.error)
|
|
333
216
|
if (typeof candidate === 'string' && candidate.length) {
|
|
334
217
|
extracted = candidate
|
|
335
218
|
}
|
|
@@ -357,25 +240,29 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
|
|
|
357
240
|
}
|
|
358
241
|
}
|
|
359
242
|
|
|
360
|
-
// Unwrap response
|
|
361
|
-
//
|
|
362
|
-
// 2. Per-request `envelope.item` (detail) or `envelope.list`.
|
|
363
|
-
// 3. Site-level `envelope.item` (detail) or `envelope.list`.
|
|
243
|
+
// Unwrap the response. Per-fetch `transform:` wins; otherwise the
|
|
244
|
+
// envelope's `item` path on a single-record request, `list` on a list.
|
|
364
245
|
const isDetailRequest = !!request.dynamicContext
|
|
365
246
|
const effectiveTransform =
|
|
366
247
|
transform
|
|
367
|
-
|| (isDetailRequest ?
|
|
248
|
+
|| (isDetailRequest ? envelope.item : envelope.list)
|
|
368
249
|
if (effectiveTransform && data !== null && data !== undefined) {
|
|
369
250
|
data = getNestedValue(data, effectiveTransform)
|
|
370
251
|
}
|
|
371
252
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
data =
|
|
377
|
-
|
|
378
|
-
|
|
253
|
+
// Evaluate the query locally. Only applies to array data
|
|
254
|
+
// (filtering/sorting/limiting a single record doesn't make sense).
|
|
255
|
+
// For non-arrays, operators are ignored — the source returned what
|
|
256
|
+
// it returned.
|
|
257
|
+
data = applyOperators(data, request, { dev })
|
|
258
|
+
|
|
259
|
+
// ⭐ Say what depth was delivered, so the record index can file it. On
|
|
260
|
+
// the address door that is what the config asked for — a list at brief
|
|
261
|
+
// depth when the query has a per-record source, a record in full — so
|
|
262
|
+
// the config's `depth` is echoed. A door that reports `depths` per key
|
|
263
|
+
// will override this with what it actually served.
|
|
264
|
+
const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
|
|
265
|
+
return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
|
|
379
266
|
} catch (error) {
|
|
380
267
|
if (error?.name === 'AbortError') {
|
|
381
268
|
return { data: [], error: 'aborted' }
|
|
@@ -387,168 +274,137 @@ export function createDefaultFetcher({ basePath = '', config = {}, dev = false,
|
|
|
387
274
|
}
|
|
388
275
|
|
|
389
276
|
/**
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
* `
|
|
393
|
-
*
|
|
277
|
+
* One question of a door batch, in the door's own vocabulary
|
|
278
|
+
* (the records door's contract, §2): `schema` required, `scope` a bare
|
|
279
|
+
* path, `sort` one key spelled `date` / `-date`, `depth` brief or full. The
|
|
280
|
+
* where-object crosses as authored except for the two spellings the language
|
|
281
|
+
* settled differently from the evaluator's: `nin` is `not_in` there, and a
|
|
282
|
+
* top-level `path: { under }` — the file lane's way of naming a folder branch —
|
|
283
|
+
* is the door's `scope`. Anything the door does not accept (`like`, a dotted
|
|
284
|
+
* path) is sent as written and refused there by name: loud, never approximated.
|
|
394
285
|
*/
|
|
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
|
|
286
|
+
function doorQuestion(request) {
|
|
287
|
+
const q = { schema: request.schema }
|
|
288
|
+
let where = request.where && typeof request.where === 'object' ? request.where : null
|
|
289
|
+
let scope = typeof request.scope === 'string' && request.scope ? request.scope : null
|
|
290
|
+
if (where && !scope && where.path && typeof where.path === 'object' && typeof where.path.under === 'string' && where.path.under) {
|
|
291
|
+
const { path, ...rest } = where
|
|
292
|
+
scope = path.under
|
|
293
|
+
where = Object.keys(rest).length ? rest : null
|
|
409
294
|
}
|
|
410
|
-
|
|
295
|
+
if (scope) q.scope = scope
|
|
296
|
+
if (where) q.where = renameOperators(where)
|
|
297
|
+
const sort = sortToWire(request.sort)
|
|
298
|
+
if (sort) q.sort = sort
|
|
299
|
+
if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
|
|
300
|
+
if (request.depth === 'brief' || request.depth === 'full') q.depth = request.depth
|
|
301
|
+
return q
|
|
411
302
|
}
|
|
412
|
-
const warnedRenameTargets = new Set()
|
|
413
303
|
|
|
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
|
-
}
|
|
304
|
+
const DOOR_OPERATOR = { nin: 'not_in' }
|
|
305
|
+
function renameOperators(where) {
|
|
306
|
+
if (Array.isArray(where)) return where.map(renameOperators)
|
|
307
|
+
if (!where || typeof where !== 'object') return where
|
|
308
|
+
const out = {}
|
|
309
|
+
for (const [key, value] of Object.entries(where)) {
|
|
310
|
+
out[DOOR_OPERATOR[key] ?? key] = value && typeof value === 'object' ? renameOperators(value) : value
|
|
428
311
|
}
|
|
429
312
|
return out
|
|
430
313
|
}
|
|
431
|
-
const warnedUnknownOperators = new Set()
|
|
432
314
|
|
|
433
315
|
/**
|
|
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).
|
|
316
|
+
* Send one batch to a door and hand each question its own answer.
|
|
449
317
|
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
318
|
+
* The response is `{ data, depths?, errors? }` (contract §5): `data` answers
|
|
319
|
+
* exactly the keys sent, `[]` when nothing matched; a key that ERRORED is absent
|
|
320
|
+
* from `data` and present in `errors`; `depths` says what was actually served,
|
|
321
|
+
* which the record index files rather than what was asked for. A key missing
|
|
322
|
+
* from both is a protocol violation and is reported as an error, never as
|
|
323
|
+
* silence.
|
|
454
324
|
*/
|
|
455
|
-
function
|
|
456
|
-
|
|
457
|
-
|
|
325
|
+
async function flushDoor(url, queue, doFetch) {
|
|
326
|
+
const body = {}
|
|
327
|
+
const keys = []
|
|
328
|
+
for (const entry of queue) {
|
|
329
|
+
const base = entry.request.as || 'q'
|
|
330
|
+
let key = base
|
|
331
|
+
for (let n = 2; key in body; n += 1) key = `${base}#${n}`
|
|
332
|
+
keys.push(key)
|
|
333
|
+
body[key] = doorQuestion(entry.request)
|
|
458
334
|
}
|
|
459
|
-
|
|
460
|
-
|
|
335
|
+
let parsed
|
|
336
|
+
try {
|
|
337
|
+
const response = await doFetch(url, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: { 'Content-Type': 'application/json' },
|
|
340
|
+
body: JSON.stringify(body),
|
|
341
|
+
})
|
|
342
|
+
if (!response.ok) {
|
|
343
|
+
const error = `HTTP ${response.status}: ${response.statusText}`
|
|
344
|
+
for (const entry of queue) entry.resolve({ data: null, error })
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
parsed = await response.json()
|
|
348
|
+
} catch (error) {
|
|
349
|
+
const message = error?.name === 'AbortError' ? 'aborted' : (error?.message || String(error))
|
|
350
|
+
for (const entry of queue) entry.resolve({ data: null, error: message })
|
|
351
|
+
return
|
|
461
352
|
}
|
|
462
|
-
const
|
|
463
|
-
|
|
353
|
+
const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
|
|
354
|
+
const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
|
|
355
|
+
const depths = parsed && typeof parsed.depths === 'object' && parsed.depths ? parsed.depths : {}
|
|
356
|
+
queue.forEach((entry, i) => {
|
|
357
|
+
const key = keys[i]
|
|
358
|
+
if (key in errors) {
|
|
359
|
+
const e = errors[key]
|
|
360
|
+
entry.resolve({ data: null, error: typeof e === 'string' ? e : (e?.message || JSON.stringify(e)) })
|
|
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
|
/**
|