@uniweb/runtime 0.7.5 → 0.8.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/package.json +3 -3
- package/src/default-fetcher.js +195 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -34,14 +34,14 @@
|
|
|
34
34
|
"node": ">=20.19"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@uniweb/core": "0.
|
|
37
|
+
"@uniweb/core": "0.7.0",
|
|
38
38
|
"@uniweb/theming": "0.1.3"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@vitejs/plugin-react": "^4.5.2",
|
|
42
42
|
"vite": "^7.3.1",
|
|
43
43
|
"vitest": "^2.0.0",
|
|
44
|
-
"@uniweb/build": "0.9.
|
|
44
|
+
"@uniweb/build": "0.9.5"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/default-fetcher.js
CHANGED
|
@@ -56,7 +56,14 @@
|
|
|
56
56
|
* it's public. That's not a framework feature gap; it's how browsers work.
|
|
57
57
|
*/
|
|
58
58
|
|
|
59
|
-
import { substitutePlaceholders } from '@uniweb/core'
|
|
59
|
+
import { substitutePlaceholders, matchWhere, deriveCacheKey } from '@uniweb/core'
|
|
60
|
+
|
|
61
|
+
// Operators the default fetcher knows how to handle. When listed in
|
|
62
|
+
// `config.supports`, they're shipped to the source as part of the
|
|
63
|
+
// request; when not listed, they're applied as a JS fallback after
|
|
64
|
+
// fetch. The cache key reflects which operators get pushed down — same
|
|
65
|
+
// query against different `supports:` produces different cache entries.
|
|
66
|
+
const KNOWN_OPERATORS = new Set(['where', 'limit', 'sort'])
|
|
60
67
|
|
|
61
68
|
/**
|
|
62
69
|
* @param {Object} [options]
|
|
@@ -91,7 +98,38 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
|
|
|
91
98
|
? config.envelope
|
|
92
99
|
: {}
|
|
93
100
|
|
|
101
|
+
// `supports:` declares which query operators (where, limit, sort) the
|
|
102
|
+
// backend evaluates at the source. Operators in this list are shipped
|
|
103
|
+
// in the request; operators not in this list are applied as a JS
|
|
104
|
+
// fallback after the response arrives. Default: empty — the framework
|
|
105
|
+
// default fetcher serving static files supports nothing natively.
|
|
106
|
+
const supports = normalizeSupports(config?.supports)
|
|
107
|
+
|
|
94
108
|
return {
|
|
109
|
+
/**
|
|
110
|
+
* Cache-key function. The default-fetcher's cache key includes only
|
|
111
|
+
* the operators it pushes down (because they affect what the source
|
|
112
|
+
* sees). Operators applied as runtime fallback operate on a shared
|
|
113
|
+
* cached value and therefore must NOT split the cache.
|
|
114
|
+
*
|
|
115
|
+
* Example: with `supports: []`, two pages declaring different
|
|
116
|
+
* `where:` clauses against the same path share one cache entry —
|
|
117
|
+
* the file is fetched once and each page filters its own copy. With
|
|
118
|
+
* `supports: [where]`, the same two pages fire two requests because
|
|
119
|
+
* the predicate travels in the request.
|
|
120
|
+
*/
|
|
121
|
+
cacheKey(request) {
|
|
122
|
+
// Build a request projection that includes only pushed-down operators.
|
|
123
|
+
// deriveCacheKey already covers the always-keyed fields.
|
|
124
|
+
const base = deriveCacheKey(request)
|
|
125
|
+
const projected = {}
|
|
126
|
+
for (const op of supports) {
|
|
127
|
+
if (request[op] !== undefined) projected[op] = request[op]
|
|
128
|
+
}
|
|
129
|
+
if (Object.keys(projected).length === 0) return base
|
|
130
|
+
return base + '::' + JSON.stringify(projected)
|
|
131
|
+
},
|
|
132
|
+
|
|
95
133
|
async resolve(request, ctx = {}) {
|
|
96
134
|
if (!request) return { data: null }
|
|
97
135
|
const { path, url, transform, body: rawBody } = request
|
|
@@ -129,24 +167,50 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
|
|
|
129
167
|
// decorate local file reads with tenant/content-type headers.
|
|
130
168
|
if (isRemote && staticHeaders) Object.assign(headers, staticHeaders)
|
|
131
169
|
|
|
132
|
-
|
|
170
|
+
// Push down supported query operators to the source. Pushdown only
|
|
171
|
+
// applies to remote URLs — local `path:` reads are static files that
|
|
172
|
+
// can't filter or sort. Operators not pushed down get applied as a
|
|
173
|
+
// JS fallback after the response (see post-fetch block below).
|
|
174
|
+
//
|
|
175
|
+
// GET pushdown uses URL query parameters: `?_where=<JSON>`, `?_limit=`,
|
|
176
|
+
// `?_sort=field:dir`. The leading underscore avoids collision with
|
|
177
|
+
// backend-specific params. POST pushdown injects supported operators
|
|
178
|
+
// into the request body alongside any author-supplied body.
|
|
179
|
+
let pushedOperators = new Set()
|
|
180
|
+
if (isRemote) {
|
|
181
|
+
for (const op of KNOWN_OPERATORS) {
|
|
182
|
+
if (supports.has(op) && request[op] !== undefined && request[op] !== null) {
|
|
183
|
+
pushedOperators.add(op)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (pushedOperators.size > 0 && method === 'GET') {
|
|
188
|
+
target = appendQueryParams(target, pushedOperators, request)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (method === 'POST') {
|
|
133
192
|
// Substitute {paramName} placeholders in body strings using the
|
|
134
193
|
// dynamic-route context. The helper expects a flat key→value map;
|
|
135
194
|
// build it from dynamicContext's { paramName, paramValue } shape.
|
|
136
195
|
// Strict-brace matcher: GraphQL selection sets pass through unchanged.
|
|
137
196
|
const dc = request.dynamicContext
|
|
138
|
-
const resolvedBody = dc && dc.paramName
|
|
197
|
+
const resolvedBody = (rawBody !== undefined && rawBody !== null && dc && dc.paramName)
|
|
139
198
|
? substitutePlaceholders(rawBody, { [dc.paramName]: dc.paramValue }, { encode: false })
|
|
140
199
|
: rawBody
|
|
141
200
|
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
if
|
|
145
|
-
|
|
201
|
+
// Compose the final body: author-supplied body merged with pushed
|
|
202
|
+
// operators (where/limit/sort). When no body and no pushdown, send
|
|
203
|
+
// a body containing just the operators if any exist.
|
|
204
|
+
const finalBody = composePostBody(resolvedBody, pushedOperators, request)
|
|
205
|
+
|
|
206
|
+
if (finalBody !== null) {
|
|
207
|
+
// Default Content-Type to JSON unless the site's static headers
|
|
208
|
+
// already set one (for application/graphql or form-urlencoded).
|
|
209
|
+
if (!hasHeader(headers, 'Content-Type')) {
|
|
210
|
+
headers['Content-Type'] = 'application/json'
|
|
211
|
+
}
|
|
212
|
+
init.body = typeof finalBody === 'string' ? finalBody : JSON.stringify(finalBody)
|
|
146
213
|
}
|
|
147
|
-
init.body = typeof resolvedBody === 'string'
|
|
148
|
-
? resolvedBody
|
|
149
|
-
: JSON.stringify(resolvedBody)
|
|
150
214
|
}
|
|
151
215
|
|
|
152
216
|
if (Object.keys(headers).length) init.headers = headers
|
|
@@ -212,6 +276,12 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
|
|
|
212
276
|
data = getNestedValue(data, effectiveTransform)
|
|
213
277
|
}
|
|
214
278
|
|
|
279
|
+
// Apply runtime fallback for query operators not pushed down.
|
|
280
|
+
// Only applies to array data (filtering/sorting/limiting a single
|
|
281
|
+
// record doesn't make sense). For non-arrays, operators are
|
|
282
|
+
// silently ignored — the source returned what it returned.
|
|
283
|
+
data = applyFallbackOperators(data, request, pushedOperators)
|
|
284
|
+
|
|
215
285
|
return { data: data ?? [] }
|
|
216
286
|
} catch (error) {
|
|
217
287
|
if (error?.name === 'AbortError') {
|
|
@@ -223,6 +293,121 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
|
|
|
223
293
|
}
|
|
224
294
|
}
|
|
225
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Normalize the supports declaration to a Set of known operators. Unknown
|
|
298
|
+
* operator names are ignored with a one-time dev warning.
|
|
299
|
+
*/
|
|
300
|
+
function normalizeSupports(raw) {
|
|
301
|
+
const out = new Set()
|
|
302
|
+
if (!Array.isArray(raw)) return out
|
|
303
|
+
for (const op of raw) {
|
|
304
|
+
if (typeof op !== 'string') continue
|
|
305
|
+
if (KNOWN_OPERATORS.has(op)) out.add(op)
|
|
306
|
+
else if (!warnedUnknownOperators.has(op)) {
|
|
307
|
+
warnedUnknownOperators.add(op)
|
|
308
|
+
console.warn(`[default-fetcher] supports: unknown operator "${op}" — ignored.`)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return out
|
|
312
|
+
}
|
|
313
|
+
const warnedUnknownOperators = new Set()
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Append pushed-down operators to a URL as query parameters. Existing
|
|
317
|
+
* query string is preserved.
|
|
318
|
+
*
|
|
319
|
+
* Conventions:
|
|
320
|
+
* - where: `?_where=<JSON.stringify(whereObject)>` (URL-encoded)
|
|
321
|
+
* - limit: `?_limit=N`
|
|
322
|
+
* - sort: `?_sort=field:dir` (matches the author-facing string form)
|
|
323
|
+
*
|
|
324
|
+
* The leading underscore avoids collision with backend-specific params
|
|
325
|
+
* the author may have included in `url:`.
|
|
326
|
+
*/
|
|
327
|
+
function appendQueryParams(url, pushedOperators, request) {
|
|
328
|
+
const params = []
|
|
329
|
+
if (pushedOperators.has('where')) {
|
|
330
|
+
params.push('_where=' + encodeURIComponent(JSON.stringify(request.where)))
|
|
331
|
+
}
|
|
332
|
+
if (pushedOperators.has('limit')) {
|
|
333
|
+
params.push('_limit=' + encodeURIComponent(String(request.limit)))
|
|
334
|
+
}
|
|
335
|
+
if (pushedOperators.has('sort')) {
|
|
336
|
+
params.push('_sort=' + encodeURIComponent(String(request.sort)))
|
|
337
|
+
}
|
|
338
|
+
if (params.length === 0) return url
|
|
339
|
+
const sep = url.includes('?') ? '&' : '?'
|
|
340
|
+
return url + sep + params.join('&')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Compose a POST body that includes pushed-down operators alongside the
|
|
345
|
+
* author-supplied body. When neither exists, returns null (no body sent).
|
|
346
|
+
*
|
|
347
|
+
* If the author supplied a body (typically an object for GraphQL or a
|
|
348
|
+
* search endpoint), pushed operators are merged into it as top-level keys
|
|
349
|
+
* (`where`, `limit`, `sort`). If the author supplied a string body, we
|
|
350
|
+
* don't try to merge — the string is sent as-is and operators are
|
|
351
|
+
* dropped. This is a known limitation; sites with string POST bodies
|
|
352
|
+
* needing pushdown should write the operators into the body themselves.
|
|
353
|
+
*/
|
|
354
|
+
function composePostBody(authorBody, pushedOperators, request) {
|
|
355
|
+
if (pushedOperators.size === 0) {
|
|
356
|
+
return authorBody === undefined ? null : authorBody
|
|
357
|
+
}
|
|
358
|
+
// String body — can't merge structured operators in.
|
|
359
|
+
if (typeof authorBody === 'string') {
|
|
360
|
+
return authorBody
|
|
361
|
+
}
|
|
362
|
+
// No body or object body — merge operators.
|
|
363
|
+
const merged = (authorBody && typeof authorBody === 'object') ? { ...authorBody } : {}
|
|
364
|
+
if (pushedOperators.has('where')) merged.where = request.where
|
|
365
|
+
if (pushedOperators.has('limit')) merged.limit = request.limit
|
|
366
|
+
if (pushedOperators.has('sort')) merged.sort = request.sort
|
|
367
|
+
return merged
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Apply query operators that weren't pushed down to the source. The
|
|
372
|
+
* source returned `data` unfiltered/unlimited/unsorted for those
|
|
373
|
+
* operators; the runtime applies them now in JS.
|
|
374
|
+
*/
|
|
375
|
+
function applyFallbackOperators(data, request, pushedOperators) {
|
|
376
|
+
if (!Array.isArray(data)) return data
|
|
377
|
+
let result = data
|
|
378
|
+
|
|
379
|
+
if (request.where && !pushedOperators.has('where')) {
|
|
380
|
+
result = matchWhere(request.where, result)
|
|
381
|
+
}
|
|
382
|
+
if (request.sort && !pushedOperators.has('sort')) {
|
|
383
|
+
result = applySortFallback(result, request.sort)
|
|
384
|
+
}
|
|
385
|
+
if (typeof request.limit === 'number' && request.limit > 0 && !pushedOperators.has('limit')) {
|
|
386
|
+
result = result.slice(0, request.limit)
|
|
387
|
+
}
|
|
388
|
+
return result
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Stable sort by an expression like "date desc" or "order asc, title asc".
|
|
393
|
+
* Mirrors the build-time applySort behavior.
|
|
394
|
+
*/
|
|
395
|
+
function applySortFallback(items, sortExpr) {
|
|
396
|
+
const sorts = String(sortExpr).split(',').map((s) => {
|
|
397
|
+
const [field, dir = 'asc'] = s.trim().split(/\s+/)
|
|
398
|
+
return { field, desc: dir.toLowerCase() === 'desc' }
|
|
399
|
+
})
|
|
400
|
+
return [...items].sort((a, b) => {
|
|
401
|
+
for (const { field, desc } of sorts) {
|
|
402
|
+
const av = getNestedValue(a, field) ?? ''
|
|
403
|
+
const bv = getNestedValue(b, field) ?? ''
|
|
404
|
+
if (av < bv) return desc ? 1 : -1
|
|
405
|
+
if (av > bv) return desc ? -1 : 1
|
|
406
|
+
}
|
|
407
|
+
return 0
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
226
411
|
/**
|
|
227
412
|
* Build the static headers object from `site.yml fetcher.headers:`. Returns
|
|
228
413
|
* null when none are configured so the caller can skip adding an empty
|