@uniweb/runtime 0.8.1 → 0.8.2

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.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -35,14 +35,14 @@
35
35
  "node": ">=20.19"
36
36
  },
37
37
  "dependencies": {
38
- "@uniweb/core": "0.7.0",
38
+ "@uniweb/core": "0.7.1",
39
39
  "@uniweb/theming": "0.1.3"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@vitejs/plugin-react": "^4.5.2",
43
43
  "vite": "^7.3.1",
44
44
  "vitest": "^2.0.0",
45
- "@uniweb/build": "0.10.0"
45
+ "@uniweb/build": "0.10.1"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "react": "^18.0.0 || ^19.0.0",
@@ -56,7 +56,17 @@
56
56
  * it's public. That's not a framework feature gap; it's how browsers work.
57
57
  */
58
58
 
59
- import { substitutePlaceholders, matchWhere, deriveCacheKey } from '@uniweb/core'
59
+ import {
60
+ substitutePlaceholders,
61
+ matchWhere,
62
+ deriveCacheKey,
63
+ resolveRequestStyle,
64
+ } from '@uniweb/core'
65
+
66
+ // Phase 2 of the request-styles landing will read `config.request.style`
67
+ // and dispatch through the registry; in Phase 1 we hard-wire the ambient
68
+ // default (json-body) via the same resolver. No behavior change — the
69
+ // resolved style is json-body, which encodes today's conventions.
60
70
 
61
71
  // Operators the default fetcher knows how to handle. When listed in
62
72
  // `config.supports`, they're shipped to the source as part of the
@@ -71,12 +81,15 @@ const KNOWN_OPERATORS = new Set(['where', 'limit', 'sort'])
71
81
  * for subpath deployments. Remote URLs pass through unchanged.
72
82
  * @param {Object} [options.config={}] - Site-level fetcher config from
73
83
  * `site.yml fetcher:`. Vocabulary recognized by the default fetcher:
74
- * `baseUrl`, `headers`, `envelope`. Unknown keys are ignored (foundations
75
- * may use the same block for their own keys).
76
- * Default behavior (empty config) matches today's plain GET + JSON.
84
+ * `baseUrl`, `headers`, `envelope`, `supports`, `request.style`,
85
+ * `request.rename`. Unknown keys are ignored (foundations may use the
86
+ * same block for their own keys). Default behavior (empty config)
87
+ * matches today's plain GET + JSON.
88
+ * @param {boolean} [options.dev=false] - Enable dev-mode warnings (unknown
89
+ * style name, unknown rename operator).
77
90
  * @returns {{ resolve: (req: Object, ctx: Object) => Promise<{ data, error? }> }}
78
91
  */
79
- export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
92
+ export function createDefaultFetcher({ basePath = '', config = {}, dev = false } = {}) {
80
93
  const pathPrefix = basePath && basePath !== '/' ? basePath.replace(/\/$/, '') : ''
81
94
 
82
95
  const baseUrl = typeof config?.baseUrl === 'string'
@@ -87,6 +100,25 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
87
100
  // requests are never decorated — they're just file reads under public/.
88
101
  const staticHeaders = buildStaticHeaders(config?.headers)
89
102
 
103
+ // `supports:` declares which query operators (where, limit, sort) the
104
+ // backend evaluates at the source. Operators in this list are shipped
105
+ // in the request; operators not in this list are applied as a JS
106
+ // fallback after the response arrives. Default: empty — the framework
107
+ // default fetcher serving static files supports nothing natively.
108
+ const supports = normalizeSupports(config?.supports)
109
+
110
+ // Request style — how operators get reshaped for the wire. Selected
111
+ // by name via `site.yml fetcher.request.style`; `null`/absent resolves
112
+ // to the ambient default (json-body).
113
+ const requestConfig = (config?.request && typeof config.request === 'object') ? config.request : {}
114
+ const styleName = typeof requestConfig.style === 'string' ? requestConfig.style : null
115
+ const style = resolveRequestStyle(styleName, { dev })
116
+
117
+ // Operator-name renames applied on top of the style's wire names.
118
+ // Shallow: only the operator keys (where / limit / sort) are rewritten.
119
+ // Field names inside a where-object are untouched.
120
+ const rename = normalizeRename(requestConfig.rename, style, { dev })
121
+
90
122
  // `envelope:` extends today's `transform:` to cover detail responses and
91
123
  // errors. Three dot-paths, all optional:
92
124
  // - envelope.collection — applied on collection responses. Per-fetch
@@ -94,16 +126,15 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
94
126
  // - envelope.item — applied when request.dynamicContext is set
95
127
  // (the request is for a template-page item).
96
128
  // - envelope.error — extract error text from non-2xx response body.
97
- const envelope = (config?.envelope && typeof config.envelope === 'object')
129
+ //
130
+ // Priority (highest wins): per-fetch request.envelope > site-level
131
+ // config.envelope > style.defaultEnvelope. A style that defaults an
132
+ // envelope (e.g. Strapi's `{ data }` wrapper) still defers to any
133
+ // explicit site-level override.
134
+ const siteEnvelope = (config?.envelope && typeof config.envelope === 'object')
98
135
  ? config.envelope
99
- : {}
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)
136
+ : null
137
+ const envelope = { ...(style.defaultEnvelope || {}), ...(siteEnvelope || {}) }
107
138
 
108
139
  return {
109
140
  /**
@@ -119,15 +150,24 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
119
150
  * the predicate travels in the request.
120
151
  */
121
152
  cacheKey(request) {
122
- // Build a request projection that includes only pushed-down operators.
123
- // deriveCacheKey already covers the always-keyed fields.
153
+ // Build a request projection that includes only operators the active
154
+ // style will actually push for this request. deriveCacheKey already
155
+ // covers the always-keyed fields.
156
+ //
157
+ // The key also includes the style name — two sites with different
158
+ // styles against the same URL produce different wire requests, so
159
+ // their responses must not alias.
124
160
  const base = deriveCacheKey(request)
125
161
  const projected = {}
126
162
  for (const op of supports) {
163
+ if (!style.canPush.has(op)) continue
127
164
  if (request[op] !== undefined) projected[op] = request[op]
128
165
  }
129
- if (Object.keys(projected).length === 0) return base
130
- return base + '::' + JSON.stringify(projected)
166
+ if (Object.keys(projected).length === 0 && style.name === 'json-body') {
167
+ // Keep back-compat key shape when the ambient default pushes nothing.
168
+ return base
169
+ }
170
+ return base + '::style=' + style.name + '::' + JSON.stringify(projected)
131
171
  },
132
172
 
133
173
  async resolve(request, ctx = {}) {
@@ -167,25 +207,37 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
167
207
  // decorate local file reads with tenant/content-type headers.
168
208
  if (isRemote && staticHeaders) Object.assign(headers, staticHeaders)
169
209
 
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).
210
+ // Push down supported query operators to the source via the active
211
+ // request style. Pushdown only applies to remote URLs — local `path:`
212
+ // reads are static files that can't filter or sort. Operators the
213
+ // style didn't push get applied as a JS fallback after the response
214
+ // (see the post-fetch block below).
174
215
  //
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()
216
+ // The style owns the wire format. Today's json-body style encodes
217
+ // GET pushdown as `?_where=<JSON>&_limit=&_sort=` and POST pushdown
218
+ // as top-level keys merged into an object body. Other styles
219
+ // (flat-query, strapi) will ship in Phase 3.
220
+ const pushCandidates = new Set()
180
221
  if (isRemote) {
181
222
  for (const op of KNOWN_OPERATORS) {
182
- if (supports.has(op) && request[op] !== undefined && request[op] !== null) {
183
- pushedOperators.add(op)
223
+ if (
224
+ supports.has(op) &&
225
+ style.canPush.has(op) &&
226
+ request[op] !== undefined &&
227
+ request[op] !== null
228
+ ) {
229
+ pushCandidates.add(op)
184
230
  }
185
231
  }
186
232
  }
187
- if (pushedOperators.size > 0 && method === 'GET') {
188
- target = appendQueryParams(target, pushedOperators, request)
233
+
234
+ const encoded = pushCandidates.size > 0
235
+ ? style.encode(request, { method, pushCandidates, rename })
236
+ : { queryParams: [], bodyMerge: null, pushed: new Set() }
237
+ const pushedOperators = encoded.pushed
238
+
239
+ if (encoded.queryParams.length > 0 && method === 'GET') {
240
+ target = appendStyleQueryParams(target, encoded.queryParams)
189
241
  }
190
242
 
191
243
  if (method === 'POST') {
@@ -199,9 +251,9 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
199
251
  : rawBody
200
252
 
201
253
  // 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)
254
+ // operators from the style. When no body and no pushdown, send
255
+ // a body containing just the pushed operators if any exist.
256
+ const finalBody = composePostBody(resolvedBody, encoded.bodyMerge)
205
257
 
206
258
  if (finalBody !== null) {
207
259
  // Default Content-Type to JSON unless the site's static headers
@@ -293,6 +345,31 @@ export function createDefaultFetcher({ basePath = '', config = {} } = {}) {
293
345
  }
294
346
  }
295
347
 
348
+ /**
349
+ * Normalize the `request.rename` map. Returns null if nothing valid.
350
+ * Dev-mode warns on operator names that don't exist in the style's
351
+ * `canPush` set — a rename that targets an operator the style doesn't
352
+ * push is silently dead config, and the warning surfaces the mistake.
353
+ */
354
+ function normalizeRename(raw, style, { dev }) {
355
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
356
+ const out = {}
357
+ for (const [op, wireName] of Object.entries(raw)) {
358
+ if (typeof wireName !== 'string' || wireName.length === 0) continue
359
+ if (dev && !style.canPush.has(op) && !warnedRenameTargets.has(op)) {
360
+ warnedRenameTargets.add(op)
361
+ console.warn(
362
+ `[default-fetcher] request.rename: operator "${op}" is not pushed by ` +
363
+ `style "${style.name}" — rename has no effect. Known operators for ` +
364
+ `this style: ${[...style.canPush].join(', ') || '(none)'}.`,
365
+ )
366
+ }
367
+ out[op] = wireName
368
+ }
369
+ return Object.keys(out).length ? out : null
370
+ }
371
+ const warnedRenameTargets = new Set()
372
+
296
373
  /**
297
374
  * Normalize the supports declaration to a Set of known operators. Unknown
298
375
  * operator names are ignored with a one-time dev warning.
@@ -313,58 +390,36 @@ function normalizeSupports(raw) {
313
390
  const warnedUnknownOperators = new Set()
314
391
 
315
392
  /**
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:`.
393
+ * Append [key, value] pairs emitted by a style to a URL as query
394
+ * parameters. Existing query string is preserved; values are URL-encoded.
326
395
  */
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
396
+ function appendStyleQueryParams(url, pairs) {
397
+ if (!pairs || pairs.length === 0) return url
398
+ const params = pairs.map(
399
+ ([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v),
400
+ )
339
401
  const sep = url.includes('?') ? '&' : '?'
340
402
  return url + sep + params.join('&')
341
403
  }
342
404
 
343
405
  /**
344
- * Compose a POST body that includes pushed-down operators alongside the
406
+ * Compose a POST body that includes the style's bodyMerge alongside the
345
407
  * author-supplied body. When neither exists, returns null (no body sent).
346
408
  *
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.
409
+ * If the author supplied a string body (typically GraphQL), we can't
410
+ * merge the string is sent as-is and the style's bodyMerge is dropped.
411
+ * Sites with string POST bodies needing pushdown should write the
412
+ * operators into the body themselves.
353
413
  */
354
- function composePostBody(authorBody, pushedOperators, request) {
355
- if (pushedOperators.size === 0) {
414
+ function composePostBody(authorBody, bodyMerge) {
415
+ if (!bodyMerge) {
356
416
  return authorBody === undefined ? null : authorBody
357
417
  }
358
- // String body — can't merge structured operators in.
359
418
  if (typeof authorBody === 'string') {
360
419
  return authorBody
361
420
  }
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
421
+ const base = (authorBody && typeof authorBody === 'object') ? authorBody : {}
422
+ return { ...base, ...bodyMerge }
368
423
  }
369
424
 
370
425
  /**
package/src/setup.js CHANGED
@@ -217,7 +217,8 @@ function buildDefaultFetcher(content) {
217
217
  // recognizes `baseUrl` and `envelope`; foundations with their own fetchers
218
218
  // may read additional keys from the same block via ctx.website.config.fetcher.
219
219
  const config = content?.config?.fetcher ?? {}
220
- return createDefaultFetcher({ basePath, config })
220
+ const dev = !!(import.meta.env && import.meta.env.DEV)
221
+ return createDefaultFetcher({ basePath, config, dev })
221
222
  }
222
223
 
223
224
  /**