@uniweb/runtime 0.8.0 → 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 +5 -4
- package/src/default-fetcher.js +139 -84
- package/src/setup.js +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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.7.
|
|
38
|
+
"@uniweb/core": "0.7.1",
|
|
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.
|
|
45
|
+
"@uniweb/build": "0.10.1"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
48
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/default-fetcher.js
CHANGED
|
@@ -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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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,17 @@
|
|
|
56
56
|
* it's public. That's not a framework feature gap; it's how browsers work.
|
|
57
57
|
*/
|
|
58
58
|
|
|
59
|
-
import {
|
|
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
|
|
75
|
-
*
|
|
76
|
-
* Default behavior (empty config)
|
|
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
|
-
|
|
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
|
|
123
|
-
//
|
|
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)
|
|
130
|
-
|
|
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
|
|
171
|
-
// applies to remote URLs — local `path:`
|
|
172
|
-
// can't filter or sort. Operators
|
|
173
|
-
// JS fallback after the response
|
|
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
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
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 (
|
|
183
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
|
203
|
-
// a body containing just the operators if any exist.
|
|
204
|
-
const finalBody = composePostBody(resolvedBody,
|
|
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
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
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
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
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,
|
|
355
|
-
if (
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
220
|
+
const dev = !!(import.meta.env && import.meta.env.DEV)
|
|
221
|
+
return createDefaultFetcher({ basePath, config, dev })
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
/**
|