@uniweb/runtime 0.14.1 → 0.14.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.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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.36.0"
47
+ "@uniweb/build": "0.37.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The composed render entry — resolve a route, render it, inject it into a shell.
3
+ *
4
+ * ⭐ **This exists because framework had two callers of one unshared sequence, not
5
+ * because a consumer asked.** `@uniweb/runtime/ssr` exported every step of the
6
+ * per-page render and never the sequence, so each host assembled it:
7
+ *
8
+ * - `@uniweb/build`'s `prerender.js` — `renderPage` → classify → `injectPageContent`,
9
+ * once per page in its loop.
10
+ * - an SSR isolate rendering per request — `resolvePage` → `renderPage` →
11
+ * `injectPageContent`, and it wrote the route lookup three times in three files
12
+ * before `resolvePage` was exported at all (see that function's header).
13
+ *
14
+ * Two unlike callers is what makes the interface honest: a build bakes files and an
15
+ * isolate answers a request, so anything only one of them needs stayed out.
16
+ *
17
+ * ⛔ WHAT IS DELIBERATELY NOT IN HERE, and the boundary is the point:
18
+ *
19
+ * - **Shell assembly.** The shell arrives built. The import map, the CDN base and
20
+ * cache headers are host layout, and a runtime that assembled them would be
21
+ * modelling a deployment it cannot see (hosting drew this line themselves,
22
+ * 2026-09-03; `framework/CLAUDE.md` § *Serve locations are read, never constructed*).
23
+ * - **Init and hydration.** The two lanes differ REALLY here, not incidentally: a
24
+ * build initializes once and hydrates every collection up front, an isolate
25
+ * initializes per locale and prefetches per route. Folding either in would fit
26
+ * one caller and lie to the other. `initPrerender` / `initPrerenderForLocale` /
27
+ * `prefetchPageData` / `hydrateDataStore` stay separate exports, and
28
+ * `prefetchAndHydrate` below is the isolate's two-step, not a general one.
29
+ * - **Anything build-only.** `injectBuildData` stays in the build lane; it is the
30
+ * other half of the head seam and has its own parity guard.
31
+ */
32
+ import { resolvePage, renderPage, classifyRenderError, injectPageContent } from './ssr-renderer.js'
33
+ import { hydrateDataStore } from './wire-foundation.js'
34
+ import { prefetchPageData } from './prefetch.js'
35
+
36
+ /**
37
+ * A renderer bound to one initialized Website and one shell.
38
+ *
39
+ * Create it once per locale (the Website is already locale-sliced by
40
+ * `initPrerenderForLocale`) and call `render` per route or per page.
41
+ *
42
+ * @param {Object} opts
43
+ * @param {Object} opts.website - `uniweb.activeWebsite`, already initialized
44
+ * @param {string} opts.shell - the HTML shell to inject into, taken as given
45
+ * @returns {{ website: Object, render: Function }}
46
+ */
47
+ export function createPageRenderer({ website, shell }) {
48
+ if (!website) throw new Error('createPageRenderer: `website` is required')
49
+ if (typeof shell !== 'string') throw new Error('createPageRenderer: `shell` must be an HTML string')
50
+
51
+ /**
52
+ * Render one page.
53
+ *
54
+ * ⭐ Returns an OUTCOME rather than throwing or returning a bare string, because
55
+ * the two callers branch differently on the same three cases and neither wants an
56
+ * exception: a build logs and keeps going so one broken section cannot fail a whole
57
+ * site, an isolate decides a status code and a cache policy. Same reasoning as
58
+ * `prefetchPageData`'s per-entry outcome.
59
+ *
60
+ * @param {string|Object} target - a route (`/blog/1`, resolved through the same
61
+ * matcher the browser uses, so a dynamic route works) or an already-resolved
62
+ * Page, which the build lane already holds from its own loop.
63
+ * @param {Object} [options]
64
+ * @param {Object} [options.inject] - extra options forwarded to `injectPageContent`
65
+ * @returns {{ outcome: 'rendered'|'notFound'|'failed', html: string|null,
66
+ * page: Object|null, error: {type: string, message: string}|null }}
67
+ */
68
+ function render(target, { inject = {} } = {}) {
69
+ const page = typeof target === 'string' ? resolvePage(website, target) : target
70
+
71
+ // ⛔ Not an error: nothing matched, which is a genuine 404 and the caller's to
72
+ // turn into one — a build skips it, an isolate serves its 404 page with a 404
73
+ // status. Returning `failed` here would make those indistinguishable.
74
+ if (!page) return { outcome: 'notFound', html: null, page: null, error: null }
75
+
76
+ let result
77
+ try {
78
+ result = renderPage(page, website)
79
+ } catch (err) {
80
+ // `renderPage` handles its own errors, but a foundation can throw from
81
+ // module scope in ways it does not catch. Classify rather than propagate,
82
+ // so one page cannot take down a build loop or an isolate's request.
83
+ return { outcome: 'failed', html: null, page, error: classifyRenderError(err) }
84
+ }
85
+
86
+ if (result.error) return { outcome: 'failed', html: null, page, error: result.error }
87
+
88
+ // ⛔ `sectionOverrideCSS` LAST, so a caller's `inject` cannot displace it. It is
89
+ // computed by `renderPage` for this page — theme pinning and component vars —
90
+ // and a caller passing a same-named key would silently drop it, rendering a page
91
+ // that looks fine and is unstyled in exactly the places the author pinned. That
92
+ // is the empty-success shape this module keeps refusing elsewhere; the spread was
93
+ // the other way round for one commit.
94
+ const html = injectPageContent(shell, result.renderedContent, page, {
95
+ ...inject,
96
+ sectionOverrideCSS: result.sectionOverrideCSS,
97
+ })
98
+ return { outcome: 'rendered', html, page, error: null }
99
+ }
100
+
101
+ return { website, render }
102
+ }
103
+
104
+ /**
105
+ * Prefetch a route's data and hydrate it onto the graph — the isolate's two-step.
106
+ *
107
+ * ⭐ Its whole purpose is that a host stops assembling our structure by hand. It
108
+ * returns the prefetch outcomes rather than swallowing them, because a host reads
109
+ * them to tell "nothing was tried" from "everything tried failed", which is a
110
+ * different cache decision.
111
+ *
112
+ * ⛔ The build lane does NOT call this: it hydrates every collection once, before
113
+ * its page loop, from its own executor that honours the author's `prerender:` flag.
114
+ * That difference is why this is a named isolate helper and not a step inside
115
+ * `render`.
116
+ *
117
+ * @returns {Promise<Array<{config: Object, outcome: string, data: any, error?: string}>>}
118
+ */
119
+ export async function prefetchAndHydrate({ website, content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
120
+ // ⛔ Guarded for the same reason `createPageRenderer` is, and it was not for one
121
+ // commit. `hydrateDataStore` no-ops on a graph with no `dataStore`, so a caller
122
+ // passing the wrong object gets a successful-looking prefetch, an unhydrated graph
123
+ // and a page that renders empty — no error anywhere. Fail where the mistake is.
124
+ if (!website?.dataStore) {
125
+ throw new Error('prefetchAndHydrate: `website` must be an initialized Website with a dataStore')
126
+ }
127
+
128
+ // ⛔ THE TRANSPORT IS REQUIRED HERE, unlike on `prefetchPageData`, and this is the
129
+ // one place the difference matters.
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
133
+ // entrypoint's `fetch(Request)` with a JSON body the transport arrives
134
+ // **`undefined`**; passed as an argument to an RPC method it arrives as a callable
135
+ // function and the isolate invokes it. Only the RPC shape carries it.
136
+ //
137
+ // ⚠️ And `undefined` is not where it stops, which is the part their measurement
138
+ // could not see from outside our code. `createDefaultFetcher` resolves the
139
+ // transport as `fetchImpl || globalThis.fetch`, so a transport that failed to cross
140
+ // silently becomes THE ISOLATE'S OWN NETWORK — outside the host's timeout, byte
141
+ // budget and site-relative address resolution. With an absolute address it does not
142
+ // even fail: the request goes out from the wrong place and comes back
143
+ // `outcome: 'fetched'`. A wiring mistake wearing a success.
144
+ //
145
+ // ⇒ So the entry whose only caller crosses that boundary demands a real function
146
+ // rather than defaulting. A Node or browser caller that genuinely wants the global
147
+ // passes `fetch: globalThis.fetch` — one word, and it says so.
148
+ if (typeof fetch !== 'function') {
149
+ throw new Error(
150
+ 'prefetchAndHydrate: `fetch` must be a function. A transport does not survive a ' +
151
+ 'JSON-serialized isolate boundary — pass it as an RPC method argument. ' +
152
+ 'To use the ambient fetch deliberately, pass `fetch: globalThis.fetch`.'
153
+ )
154
+ }
155
+ const fetched = await prefetchPageData({ content, route, locale, fetch, dev, prerender })
156
+ hydrateDataStore(website, fetched)
157
+ return fetched
158
+ }
package/src/prefetch.js CHANGED
@@ -20,6 +20,14 @@
20
20
  * matcher the SPA uses, so `/blog/post-1` finds `/blog/:slug`.
21
21
  * - `fetch` how to dispatch a request. The runtime composes the address; the host
22
22
  * decides how a site-relative one is reached (its origin, a binding).
23
+ * ⛔ **Crossing an isolate boundary, this survives only as an RPC method
24
+ * 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
27
+ * back to `globalThis.fetch`, so the request leaves from the isolate,
28
+ * outside whatever budget the host wrapped around it. `prefetchAndHydrate`
29
+ * refuses a non-function for exactly this reason; this entry keeps the
30
+ * permissive default because the build and browser lanes call it in-process.
23
31
  * - `prerender` whether a fetch is tried — `'always'` (default) tries every config; `'author'`
24
32
  * honours the author's `prerender: false`. ⛔ The default is `'always'` because this
25
33
  * entry has exactly one kind of caller: an isolate rendering per request, where the
package/src/ssr.js CHANGED
@@ -66,6 +66,12 @@ export {
66
66
  prefetchPageData,
67
67
  } from './prefetch.js'
68
68
 
69
+ // The composed render entry — resolve, render, inject, as one call. Built because
70
+ // framework itself had two callers of this unshared sequence (the build's prerender
71
+ // loop and an SSR isolate), not because a consumer asked; see page-renderer.js for
72
+ // what it deliberately leaves to the host.
73
+ export { createPageRenderer, prefetchAndHydrate } from './page-renderer.js'
74
+
69
75
  // Appearance. injectPageContent() already emits this for every prerendered
70
76
  // page; exported for lanes that assemble a shell without a per-page render.
71
77
  export { renderAppearanceBootScript } from './appearance.js'