@uniweb/runtime 0.17.2 → 0.18.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.17.2",
3
+ "version": "0.18.1",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=20.19"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "^0.23.0",
39
+ "@uniweb/core": "^0.24.0",
40
40
  "@uniweb/theming": "^0.1.15"
41
41
  },
42
42
  "devDependencies": {
@@ -44,7 +44,7 @@
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "0.41.0"
47
+ "@uniweb/build": "0.42.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
package/src/appearance.js CHANGED
@@ -28,7 +28,7 @@
28
28
  *
29
29
  * Environment-neutral by construction. `applyBootScheme` no-ops its DOM writes
30
30
  * outside a browser, and `renderAppearanceBootScript` only stringifies — so
31
- * ssr-renderer.js can import this module in Node and in a Cloudflare isolate.
31
+ * ssr-renderer.js can import this module in Node and in a server-side isolate.
32
32
  *
33
33
  * Two writers with independent resolution is the bug this replaced:
34
34
  * WebsiteRenderer used to re-apply `appearance.default` from an effect, and
@@ -78,7 +78,7 @@ import { createDefaultFetcher } from './default-fetcher.js'
78
78
  */
79
79
  export async function collectSiteRecords(
80
80
  content,
81
- { locale, fetch, signal, only = null, depth = 'brief', maxPages } = {},
81
+ { locale, fetch, signal, only = null, whole = false, maxPages } = {},
82
82
  ) {
83
83
  const config = content?.config
84
84
  const services = config?.services ?? null
@@ -111,7 +111,7 @@ export async function collectSiteRecords(
111
111
  if (!cfg.ask) return
112
112
  // `limit` is the list page's, never the corpus's — see the header.
113
113
  const { limit, ...population } = cfg
114
- const asked = { ...population, depth, exhaustive: true }
114
+ const asked = { ...population, whole, exhaustive: true }
115
115
  if (typeof maxPages === 'number' && maxPages > 0) asked.maxPages = maxPages
116
116
 
117
117
  const result = await fetcher.resolve(asked, { signal })
@@ -230,12 +230,13 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
230
230
  // it returned.
231
231
  data = applyOperators(data, request, { dev })
232
232
 
233
- // ⭐ Say what depth was delivered, so the record index can file it — what
234
- // the config asked for, echoed: a list at brief depth when the query has
235
- // a per-record source, a record in full. (The service reports `depths` per
233
+ // ⭐ Say whether WHOLE records were delivered, so the record index can
234
+ // file them — what the config asked for, echoed: briefs when the query
235
+ // has a per-record source, whole records otherwise. (The service reports
236
+ // `whole` per
236
237
  // key and overrides this with what it actually served.)
237
- const depth = request.depth === 'brief' || request.depth === 'full' ? request.depth : undefined
238
- return depth ? { data: data ?? [], meta: { depth } } : { data: data ?? [] }
238
+ const whole = typeof request.whole === 'boolean' ? request.whole : undefined
239
+ return whole === undefined ? { data: data ?? [] } : { data: data ?? [], meta: { whole } }
239
240
  } catch (error) {
240
241
  if (error?.name === 'AbortError') {
241
242
  return { data: [], error: 'aborted' }
@@ -270,7 +271,9 @@ function toQuestion(request) {
270
271
  const sort = sortToWire(request.sort)
271
272
  if (sort) q.sort = sort
272
273
  if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
273
- if (request.depth === 'brief' || request.depth === 'full') q.depth = request.depth
274
+ // ⛔ ONLY WHEN TRUE. The brief is the default and absent means the brief, so
275
+ // sending `whole: false` would be noise on every list question the client makes.
276
+ if (request.whole === true) q.whole = true
274
277
  // ⭐ `cursor` is the ONLY field here that is not the author's: it is opaque and
275
278
  // it comes from a previous answer's `cursors` (the records contract §2). ⛔ And
276
279
  // `exhaustive` deliberately does NOT cross — it is a client instruction about
@@ -385,7 +388,10 @@ async function flushAsked(url, queue, doFetch) {
385
388
  }
386
389
  const data = parsed && typeof parsed.data === 'object' && parsed.data ? parsed.data : {}
387
390
  const errors = parsed && typeof parsed.errors === 'object' && parsed.errors ? parsed.errors : {}
388
- const depths = parsed && typeof parsed.depths === 'object' && parsed.depths ? parsed.depths : {}
391
+ // `whole[key]` is present only for keys delivered as WHOLE entities so a
392
+ // key's ABSENCE is the brief, and a key present that never asked is the
393
+ // brief-less Model saying so. Absent entirely when every key is briefs.
394
+ const wholes = parsed && typeof parsed.whole === 'object' && parsed.whole ? parsed.whole : {}
389
395
  // Both absent when empty, never `{}` (the records contract §5).
390
396
  const cursors = parsed && typeof parsed.cursors === 'object' && parsed.cursors ? parsed.cursors : {}
391
397
  const limits = parsed && typeof parsed.limits === 'object' && parsed.limits ? parsed.limits : {}
@@ -413,9 +419,9 @@ async function flushAsked(url, queue, doFetch) {
413
419
  entry.resolve({ data: null, error: `the records service answered without the key "${key}"` })
414
420
  return
415
421
  }
416
- const depth = depths[key] === 'brief' || depths[key] === 'full'
417
- ? depths[key]
418
- : (entry.request.depth === 'brief' || entry.request.depth === 'full' ? entry.request.depth : undefined)
422
+ const whole = typeof wholes[key] === 'boolean'
423
+ ? wholes[key]
424
+ : (typeof entry.request.whole === 'boolean' ? entry.request.whole : undefined)
419
425
 
420
426
  const cursor = typeof cursors[key] === 'string' && cursors[key] ? cursors[key] : null
421
427
  const bound = typeof limits[key] === 'number' ? limits[key] : undefined
@@ -429,17 +435,17 @@ async function flushAsked(url, queue, doFetch) {
429
435
  ? entry.request.maxPages
430
436
  : DEFAULT_MAX_PAGES
431
437
  if (page <= cap) {
432
- pending.set(entry, { cursor, collected: acc, page, depth, bound })
438
+ pending.set(entry, { cursor, collected: acc, page, whole, bound })
433
439
  return
434
440
  }
435
441
  // The caller's own bound, not the service's: report rather than spin.
436
- entry.resolve({ data: acc, meta: withMeta({ depth, bound, partial: true, pages: cap }) })
442
+ entry.resolve({ data: acc, meta: withMeta({ whole, bound, partial: true, pages: cap }) })
437
443
  return
438
444
  }
439
445
 
440
446
  const collected = entry.collected ? entry.collected.concat(Array.isArray(rows) ? rows : []) : rows
441
447
  const meta = withMeta({
442
- depth,
448
+ whole,
443
449
  bound,
444
450
  // ⭐ ONE FLAG, MEANING **NOT THE WHOLE POPULATION** — asked for by name, so
445
451
  // a caller has one boolean to branch on rather than three signals to
@@ -54,7 +54,7 @@
54
54
  * ## No page-level opt-in, deliberately
55
55
  *
56
56
  * `section_view` needs one (`trackSections`) because its dimension is unbounded
57
- * — a site's section types run to the hundreds and hosting capped the
57
+ * — a site's section types run to the hundreds and a host capped the
58
58
  * cardinality. **An outbound hostname is bounded by how many external sites a
59
59
  * page links to**, which is small and does not grow with the site.
60
60
  *
@@ -32,8 +32,8 @@ function resolveAgainstDocument(url) {
32
32
  * ⭐ **Exported for the test, and the test exists because a HOST builds on this.**
33
33
  * The skip below is what lets a shell put the stylesheet in the head itself —
34
34
  * so the sheet applies during HTML parse instead of waiting for the runtime to
35
- * boot, parse `__DATA__` and inject it. Hosting shipped that on the strength of
36
- * this guard (the hosting lane, 2026-08-19); until then nothing
35
+ * boot, parse `__DATA__` and inject it. A host shipped that on the strength of
36
+ * this guard (2026-08-19); until then nothing
37
37
  * asserted it, so a refactor would have produced two `<link>` tags and two
38
38
  * fetches on their lane with every test here still green.
39
39
  *
@@ -128,8 +128,8 @@ export async function prefetchAndHydrate({ website, content, route, locale = nul
128
128
  // ⛔ THE TRANSPORT IS REQUIRED HERE, unlike on `prefetchPageData`, and this is the
129
129
  // one place the difference matters.
130
130
  //
131
- // A function does NOT survive every isolate boundary. Measured by hosting under
132
- // `wrangler dev` against a real Worker Loader, 2026-09-03: passed through an
131
+ // A function does NOT survive every isolate boundary. Measured by a host against
132
+ // a real isolate loader, 2026-09-03: passed through an
133
133
  // entrypoint's `fetch(Request)` with a JSON body the transport arrives
134
134
  // **`undefined`**; passed as an argument to an RPC method it arrives as a callable
135
135
  // function and the isolate invokes it. Only the RPC shape carries it.
package/src/prefetch.js CHANGED
@@ -11,7 +11,8 @@
11
11
  * shape the datastore expects — a copy of the runtime's logic, in another repo, drifting
12
12
  * (the records envelope went silently unread that way on 2026-09-02). [Diego, 2026-09-03]:
13
13
  * *the backend sets the records service; the fetch comes from the runtime.* The host now calls
14
- * this and carries no copy. Hosting agreed to exactly that shape the same day.
14
+ * this and carries no copy; the host that renders in an isolate agreed to exactly
15
+ * that shape the same day.
15
16
  *
16
17
  * ⛔ Contract with the host, deliberately small:
17
18
  * - `content` the render payload (`site-content.json` / `__DATA__`), config included —
@@ -22,8 +23,8 @@
22
23
  * decides how a site-relative one is reached (its origin, a binding).
23
24
  * ⛔ **Crossing an isolate boundary, this survives only as an RPC method
24
25
  * argument.** Through an entrypoint's `fetch(Request)` with a serialized
25
- * body it arrives `undefined` (hosting, measured under `wrangler dev`
26
- * against a real Worker Loader, 2026-09-03) — and the fetcher then falls
26
+ * body it arrives `undefined` (measured by a host against a real
27
+ * isolate loader, 2026-09-03) — and the fetcher then falls
27
28
  * back to `globalThis.fetch`, so the request leaves from the isolate,
28
29
  * outside whatever budget the host wrapped around it. `prefetchAndHydrate`
29
30
  * refuses a non-function for exactly this reason; this entry keeps the
@@ -43,7 +44,7 @@
43
44
  * deferred it to the browser with `prerender: false`). `hydrateDataStore`
44
45
  * takes the list as-is and hydrates only `fetched` entries — a host reads the
45
46
  * outcomes to tell "nothing was tried" from "everything tried failed", which
46
- * is a different cache decision (hosting, 2026-09-03).
47
+ * is a different cache decision (measured by a host, 2026-09-03).
47
48
  *
48
49
  * It resolves nothing the host owns and models no host route layout: every address is
49
50
  * `{base}/…` from the payload, or the records service the host itself
package/src/shell/main.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * Runtime Shell Entry Point
3
3
  *
4
4
  * Standalone browser app that boots a Uniweb site from __DATA__ injected
5
- * by a dynamic backend (unicloud, PHP, etc.).
5
+ * by a dynamic backend (a Uniweb backend, PHP, anything that can render a
6
+ * page shell).
6
7
  *
7
8
  * This calls the same start() function that all sites use. When no
8
9
  * __FOUNDATION_CONFIG__ is embedded in the page, start() checks for
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * SSR Renderer
3
3
  *
4
- * Hook-free rendering pipeline for SSG (build) and cloud SSR (unicloud).
4
+ * Hook-free rendering pipeline for SSG (build) and server-side SSR.
5
5
  * Mirrors BlockRenderer.jsx + Background.jsx using React.createElement
6
6
  * directly — no hooks, no JSX, no browser APIs.
7
7
  *
@@ -363,7 +363,7 @@ export function renderLayout(page, website) {
363
363
  * Construct a Uniweb singleton scoped to a single locale.
364
364
  *
365
365
  * Combines the three steps that every SSR consumer (browser SPA, Node
366
- * SSG, Cloudflare Worker SSR) needs in the same order: slice the
366
+ * SSG, a server-side isolate) needs in the same order: slice the
367
367
  * multi-locale content payload, run `initPrerender` (which builds the
368
368
  * Website + wires foundation capabilities), then `setActiveLocale` so
369
369
  * `website.activeLang` stays in sync with what the page is rendering
@@ -396,10 +396,10 @@ export function initPrerenderForLocale(content, foundation, locale, extensionsOr
396
396
  *
397
397
  * Returns the configured uniweb instance. Consumers can add extras after:
398
398
  * - Build: pre-populate DataStore, load extensions
399
- * - Unicloud: (none needed — payload is complete)
399
+ * - A server-side renderer: (none needed — the payload is complete)
400
400
  *
401
401
  * NOTE: Does NOT clone content. Cloning is the consumer's responsibility
402
- * (build modifies content before init; unicloud clones upfront).
402
+ * (build modifies content before init; a server-side renderer clones upfront).
403
403
  *
404
404
  * @param {Object} content - Site content JSON (pages, config, hierarchy)
405
405
  * @param {Object} foundation - Loaded foundation module
@@ -119,10 +119,10 @@ export function wireFoundationCapabilities(uniweb, foundation) {
119
119
  * each `dist/{locale}/site-content.json` separately) get pass-through
120
120
  * behavior.
121
121
  *
122
- * The shape comes from the editor's publish payload, which is the
123
- * production canonical for multi-locale content (the Cloudflare Worker
124
- * SSR path consumes it directly). Build-time SSG pre-flattens to one
125
- * file per locale and so falls into the pass-through case.
122
+ * The shape comes from the editor's publish payload, which is the canonical
123
+ * form for multi-locale content a server-side renderer consumes it
124
+ * directly. Build-time SSG pre-flattens to one file per locale and so falls
125
+ * into the pass-through case.
126
126
  *
127
127
  * @param {Object} content - Site content payload, possibly multi-locale.
128
128
  * @param {string} locale - Requested locale code.
@@ -152,10 +152,10 @@ export function sliceContentForLocale(content, locale) {
152
152
  * value MUST be wrapped as `{ data }` — otherwise the dispatcher's
153
153
  * lookup at `_dataStore.get(deriveCacheKey(request))` misses every
154
154
  * time and `cached.data` reads `undefined`. Three call sites used to
155
- * inline this loop independently (browser SPA, Node SSG, Cloudflare
156
- * Worker SSR); the Cloudflare one was using the wrong shape, silently
157
- * killing prefetched-data reuse in production. This helper is the one
158
- * canonical implementation.
155
+ * inline this loop independently (browser SPA, Node SSG, and a server-side
156
+ * renderer); one of the three was using the wrong shape, silently killing
157
+ * prefetched-data reuse wherever it ran. This helper is the one canonical
158
+ * implementation.
159
159
  *
160
160
  * @param {import('@uniweb/core').Website} website
161
161
  * @param {Array<{config: Object, data: any}>} fetchedData