@uniweb/runtime 0.7.5 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.7.5",
3
+ "version": "0.8.1",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,7 +8,8 @@
8
8
  "./ssr": "./dist/ssr.js",
9
9
  "./provider": "./src/RuntimeProvider.jsx",
10
10
  "./setup": "./src/setup.js",
11
- "./foundation-loader": "./src/foundation-loader.js"
11
+ "./foundation-loader": "./src/foundation-loader.js",
12
+ "./default-fetcher": "./src/default-fetcher.js"
12
13
  },
13
14
  "files": [
14
15
  "src",
@@ -34,14 +35,14 @@
34
35
  "node": ">=20.19"
35
36
  },
36
37
  "dependencies": {
37
- "@uniweb/core": "0.6.2",
38
+ "@uniweb/core": "0.7.0",
38
39
  "@uniweb/theming": "0.1.3"
39
40
  },
40
41
  "devDependencies": {
41
42
  "@vitejs/plugin-react": "^4.5.2",
42
43
  "vite": "^7.3.1",
43
44
  "vitest": "^2.0.0",
44
- "@uniweb/build": "0.9.4"
45
+ "@uniweb/build": "0.10.0"
45
46
  },
46
47
  "peerDependencies": {
47
48
  "react": "^18.0.0 || ^19.0.0",
@@ -28,19 +28,19 @@
28
28
  * Every key is optional. When the config is empty, behavior is byte-for-byte
29
29
  * identical to a plain `fetch()` with JSON parsing.
30
30
  *
31
- * Not exported from @uniweb/runtime's public surface: foundations never
32
- * need to reach for this. If a foundation wants to compose the default
33
- * behavior with middleware, its choices are:
31
+ * Exported from a subpath — `@uniweb/runtime/default-fetcher` for
32
+ * runtime-level callers (the editor's preview iframe, custom runtime
33
+ * harnesses). **Foundations should not import this.** A foundation that
34
+ * wants plain URL + JSON behavior simply omits its own fetcher; the
35
+ * runtime installs this one automatically. A foundation that needs
36
+ * auth / retry / response normalization declares a named transport
37
+ * and composes `@uniweb/fetchers` middleware around its own `resolve()`.
34
38
  *
35
- * - Don't declare a fetcher. The runtime's default already handles
36
- * plain URL + JSON responses (and the site-level vocabulary).
37
- * - Declare a custom fetcher and write its own `fetch()` call. When it
38
- * needs auth / retry / response normalization, compose @uniweb/fetchers
39
- * primitives around its own resolve function.
40
- *
41
- * There is intentionally no "reuse the default and wrap it" path — doing
42
- * so would duplicate this code into every foundation bundle. Custom
43
- * fetchers own their transport; the default exists for sites without one.
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
44
  *
45
45
  * Intentional omissions: credentials / secrets are NOT part of the vocabulary.
46
46
  * Any value the framework puts into the served HTML is public to the browser.
@@ -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
- if (method === 'POST' && rawBody !== undefined && rawBody !== null) {
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
- // Default Content-Type to JSON unless the site's static headers
143
- // already set one (for application/graphql or form-urlencoded).
144
- if (!hasHeader(headers, 'Content-Type')) {
145
- headers['Content-Type'] = 'application/json'
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