@abide/abide 0.34.2 → 0.35.0
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/AGENTS.md +3 -3
- package/CHANGELOG.md +36 -0
- package/README.md +2 -2
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +53 -9
- package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
- package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
- package/src/lib/server/runtime/createServer.ts +4 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
- package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
- package/src/lib/server/runtime/gzipResponse.ts +23 -11
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
- package/src/lib/ui/README.md +1 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +60 -55
- package/src/lib/ui/compile/desugarSignals.ts +93 -38
- package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
- package/src/lib/ui/compile/parseTemplate.ts +8 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
- package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
- package/src/lib/ui/dom/applyResolved.ts +27 -7
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/runtime/escapeKey.ts +10 -4
- package/src/lib/ui/seedStreamedResolution.ts +22 -0
- package/src/lib/ui/startClient.ts +17 -3
- package/src/lib/shared/types/CacheSnapshot.ts +0 -16
package/AGENTS.md
CHANGED
|
@@ -164,7 +164,7 @@ Reactive state is owned by a scope and declared explicitly through it: `const co
|
|
|
164
164
|
- `scope().state(initial, transform?)` — local truth. Plain `state(x)` lowers to a serializable `model` (= `scope()`) doc slot. `state(x, transform)` is a write-coercion gate (`.value` cell).
|
|
165
165
|
- `scope().linked(seed, transform?)` — a `.value` cell seeded reactively from upstream (Angular's `linkedSignal`): owns a local value, reseeds when the `seed` thunk's deps change, edits stay local. Non-serializing — reseeds on resume.
|
|
166
166
|
- `scope().computed(compute)` → a read-only computed **doc slot** (`scope().derive("name", compute)`), referenced as `name()` (string-free reader): re-computed on resume, never serialized/journalled. Always read-only (no `set` overload) — a writable computed is expressed at the binding site (`bind:value={{ get, set }}`), where the write actually originates.
|
|
167
|
-
- `
|
|
167
|
+
- `const { a = default, b: alias } = props<Shape>()` → the prop reader (the only one): destructure the parent-passed props; each binding is a read-only computed doc slot over its parent thunk (read as `a()`), an `= default` supplies the fallback when the prop is absent, `b: alias` renames. The optional `<Shape>` type argument is the parent-facing prop shape (default `Record<string, any>`). Bare `prop(...)` is a compile error pointing here.
|
|
168
168
|
- `abide/ui/effect(fn)` → run now, re-run on dependency change; `fn` may return teardown / be async; returns dispose. (`effect` IS still a public export.)
|
|
169
169
|
- `abide/ui/scope(address?)` → `Scope` — **the data + capability seam; the only public way to reach reactive state.** The reactive document (immutable path-addressed tree, every change a patch — JSON Pointer keys via `escapeKey`, so a key may contain `/`) is now *internal* (`doc`/`createDoc`): a scope wraps one and mirrors its interface — `read`/`replace`/`add`/`remove`/`cell`/`derive`/`snapshot` (data; `derive(path, compute)` is a computed slot, never stored/serialized/journalled), `child`/`root`/`parent` (tree), `dispose`. Every applied patch is announced on an internal process-global patch bus (`PATCH_BUS`) carrying `{ doc, patch, inverse }` — the single tap undo / persistence / sync consume; computed recomputes never reach it. Capabilities route where the scope's changes go — `record({ limit? })` to an undo journal (then `undo`/`redo`/`canUndo`/`canRedo`), `persist(key?)` to durable storage (key defaults to the scope `id`), `broadcast(transport)` to peers. A passable value (`<Child parentScope={scope}/>`). `scope()` = the ambient current scope — `mount`/`hydrate` (client) and `enterScope`/`exitScope` (SSR, per render) establish one per component; the lowering emits `const model = scope()`, so `scope()`'s data IS the component's `state`; `scope('/')` = the root. Resolves correctly inside event handlers, effects, and attach teardowns (they pin the scope they were registered under).
|
|
170
170
|
|
|
@@ -186,8 +186,8 @@ Reactive state is owned by a scope and declared explicitly through it: `const co
|
|
|
186
186
|
Valid HTML with `<script>` + native `<template>` control flow + scoped `<style>`.
|
|
187
187
|
- **Bindings:** `{expr}` text, `name={expr}` attr, `onclick={fn}`, `bind:value={…}` / `bind:checked` / `bind:group`, `attach={fn}` (node-lifetime attachment — the dual of `on`; the `use:`-action / `{@attach}` equivalent, lowered to `ui/dom/attach`). A two-way `bind:` target is an **lvalue** (`count`, `model.lines[i]`) or an **accessor object** `bind:value={{ get, set }}` (reads via `get()`, writes via `set(v)`) — the only way to two-way-bind derived state; binding a read-only `computed` is a compile error.
|
|
188
188
|
- **Control flow (native `<template>`):** `if` with a nested `else` (the `<template else>` is a CHILD of its `<template if>`, not a sibling), `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
|
|
189
|
-
- **A branch is a lexical scope:** any control-flow branch may host its own nested `<script>` and `<style>`. The nested `<script>` declares branch-local **plain** signals (`state`/`computed`/`linked
|
|
190
|
-
- **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). A component has no directives — every attribute is a prop under its written name (so `onclick=`/`bind:open=`/`attach=` pass through as props, e.g. callbacks, not the DOM-element directives those are on a lowercase tag) and is type-checked against the child's declared props. `
|
|
189
|
+
- **A branch is a lexical scope:** any control-flow branch may host its own nested `<script>` and `<style>`. The nested `<script>` declares branch-local **plain** signals (`state`/`computed`/`linked`) — owned by the branch's render scope, re-seeded each mount (not the serializable top-level `doc`) — with the branch's binding in scope (`then`/`catch`'s `as` value, the `each` `as`/`key` row), so it can derive from the awaited/iterated value; its bindings cover the branch subtree and later siblings auto-deref them. The nested `<style>` is scoped to that branch alone (its own `data-a-<hash>`), not the whole component.
|
|
190
|
+
- **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). A component has no directives — every attribute is a prop under its written name (so `onclick=`/`bind:open=`/`attach=` pass through as props, e.g. callbacks, not the DOM-element directives those are on a lowercase tag) and is type-checked against the child's declared props. `const { name } = props<Shape>()` reads typed component props (parent-supplied thunks, reactive + read-only); route params come from the `page` proxy (`page.params.name`), not `props()`.
|
|
191
191
|
- **Snippets / named slots:** `<template name="x" args={…}>` declares a reusable named builder (the `snippet()` form), rendered like a function — covers named slots / `{@render}`.
|
|
192
192
|
- **Reactivity:** write plain assignment (`count += 1`, `items.push(x)`); the compiler lowers it to patches. Deep-field edits wake only that field.
|
|
193
193
|
- **SSR:** byte-identical HTML string; `renderToStream` ships the shell then streams `<template await>` fragments out of order; `hydrate` adopts the server DOM in place — static structure, `if`/`else`, keyed `each`, `switch`, `try`, and child components/slots all claim existing nodes (no re-render; focus/scroll/input preserved). The one cold-rebuild case left is a genuinely-pending `await` that is neither resumable (`RESUME[id]`) nor cache-warm — it discards the boundary and builds the pending branch fresh; an `await` over a `cache`d read is warm on resume and adopts without a flash.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`c7ad3be`](https://github.com/briancray/abide/commit/c7ad3be781797ec3e2e4f66892f0711624d4a40f) - Breaking: replace prop() with destructuring props() as the sole prop reader ([`bb050d2`](https://github.com/briancray/abide/commit/bb050d2cd5b235212423f70ceef29dd8b53fcfc7))
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- [`c7ad3be`](https://github.com/briancray/abide/commit/c7ad3be781797ec3e2e4f66892f0711624d4a40f) - make the streamed-gzip and preload-manifest sources typecheck ([`d5b093d`](https://github.com/briancray/abide/commit/d5b093dd37d3f0a2c381389d9308eddd1267cb2b))
|
|
12
|
+
|
|
13
|
+
- [`b8a0080`](https://github.com/briancray/abide/commit/b8a00807d29dfcacd299b1fe30797dba74166684) - feat(ui): a bare attribute on a child component now coerces to `true` instead of the empty string. `<Toggle on />` passes `on: true`, matching HTML's presence-means-true semantics and a `boolean` prop's declared type (previously it passed `""`, which the type-checking shadow flagged against `on: boolean` — leaving no way to write a bare boolean that both read naturally and type-checked). An explicit `<Toggle on="" />` still passes the empty string, and native DOM elements are unchanged (`<button disabled>` still serialises to `disabled=""`). Behavior change: a component reading a bare prop and expecting `""` now receives `true`.
|
|
14
|
+
|
|
15
|
+
- [`a0474f3`](https://github.com/briancray/abide/commit/a0474f3eca36d101b4c5a0b35edb8ffa7f02c906) - fix(ui): JSON-Pointer-escape reactive-document path keys so a key containing `/` or `~` addresses one segment instead of mis-splitting. `lowerDocAccess` lowered `model.byId[key]` to a raw `/`-joined path (`"byId/" + key`), and `model["a/b"]` to a raw literal — but the read side (`walkPath`) splits on `/` and `unescapeKey`s each segment. So a composite key (a date, a URL id, a slash-bearing string) round-tripped wrong: `model.byId["a/b"]` read/removed `byId → a → b` instead of the `"a/b"` entry. The `escapeKey` helper was written for exactly this (its doc cites "a URL id, a date, a composite key") and AGENTS.md documents scope keys as escaped — but it was never called.
|
|
16
|
+
|
|
17
|
+
The lowering now escapes both halves: literal keys at compile time (`model["a/b"]` → `read("byId/a~1b")`), dynamic segments at runtime (`model.byId[key]` → `read("byId/" + escapeKey(key))`). `escapeKey` joins `UI_RUNTIME_IMPORTS` (DCE-filtered, so only emitted when a component uses a dynamic/special key) and is exported at `abide/ui/runtime/escapeKey`; it now coerces (`String(key)`) since a dynamic segment is often a numeric array index. Plain identifier/index paths are unaffected.
|
|
18
|
+
|
|
19
|
+
- [`d188eae`](https://github.com/briancray/abide/commit/d188eaeffd75ba80e30c3c16db714d4582c06468) - perf(ssr): preload the client entry in `<head>` so it downloads during a streamed render. The entry `<script type="module">` sits before `</body>`, which on a streaming page (top-level `{#await}`) arrives only after the whole stream — so the browser couldn't start fetching the bundle until the stream closed. `injectShellAssets` now also emits `<link rel="modulepreload" href="/_app/client.js">` in `<head>` (rebased + hashed like the other entry refs), overlapping the entry transfer with the streamed body. Execution still defers to parse-end (module script), so hydration ordering — including the trailing `__abideResolve(...)` cache seeds — is unchanged.
|
|
20
|
+
|
|
21
|
+
- [`d188eae`](https://github.com/briancray/abide/commit/d188eaeffd75ba80e30c3c16db714d4582c06468) - perf(ssr): preload the client entry's static runtime chunks in `<head>` so the whole hydration graph downloads during a streamed render. The entry `modulepreload` only covered `client.js` itself; its ~dozen static `clientEntry-<hash>.js` runtime chunks (one abide-ui primitive each, split out by `splitting:true`) are discovered only after the browser downloads and _parses_ the entry — which on a streamed page is ~stream-close — so the runtime waterfalled in a second wave after the body finished. `rewriteHashedClientEntries` now reads the built entry, extracts its static import specifiers (regex excludes dynamic `import("./page-…")`, so route chunks stay lazy and code-splitting still pays off), and emits a `<link rel="modulepreload">` for each into `<head>`. The runtime graph now transfers alongside the entry during the stream, so hydration is network-ready at stream-close instead of waterfalling after it. Builds on the entry-preload + per-chunk-flushing-gzip work that made `<head>` decodable mid-stream.
|
|
22
|
+
|
|
23
|
+
- [`e507a8b`](https://github.com/briancray/abide/commit/e507a8bd0ba5072974d472b4123193510b8f6257) - perf(ssr): preload the matched route's page + layout chunks (and their runtime deps) per render so the whole hydration chain downloads during a streamed render. The shell's `modulepreload`s cover the entry's shared runtime, but a route's page/layout chunks are dynamically imported — the browser can't discover them until it has downloaded, parsed, and _run_ the entry, which on a streamed page is ~stream-close, so the route's chunks (and the runtime only they import) waterfalled in a second wave after the body finished. A new `buildPreloadManifest` parses the built bundle once at server boot — route → chunk from the entry's compiled pages/layouts manifest, the static-import closure from each chunk's source — into a `route → chunks` map (excluding the entry graph the shell already preloads, reading disk under dev/`abide start` or the embedded gzip asset map for standalone compile). `createUiPageRenderer` injects a `<link rel="modulepreload">` per chunk into `<head>` for the matched route (page + full layout chain), rebased onto the mount base, in both the streamed and buffered paths. The route's whole chain now transfers alongside the entry during the stream, so hydration is network-ready at stream-close. Degrades to the entry-only preload when the bundle is absent or unparseable.
|
|
24
|
+
|
|
25
|
+
- [`40dce51`](https://github.com/briancray/abide/commit/40dce514a4a46a507310ebb5007a73d875f1232d) - fix(ui): make signal-ref lowering lexically scope-aware so a callback parameter (or nested local) that shadows a component signal is no longer rewritten to the signal's doc form. Previously `renameSignalRefs` skipped only declaration-site identifiers, so in a component with `prop('option')`, a callback like `list.map(option => option.toUpperCase())` had its loop variable rewritten to `option()` — throwing `option is not a function` at runtime against the array element. The rewrite now threads a per-branch shadowed-name set down the AST (function/arrow params, nested `let`/`const`/`function`/`class`, `for`-headers, `catch` bindings), leaving inner references untouched while un-shadowed signals still lower.
|
|
26
|
+
|
|
27
|
+
- [`e121179`](https://github.com/briancray/abide/commit/e121179965b9bd833a2148c61dd7828970eb8262) - fix(ssr): compress streamed SSR documents with a per-chunk-flushing gzip so the head reaches the browser decodable mid-stream. `gzipResponse` piped every dynamic body through the web `CompressionStream`, which buffers until its deflate window fills — for a streamed `text/html` page that held the whole head until ~stream close, so the browser couldn't preload-scan the entry/CSS links or paint the pending shell until the document nearly finished (defeating streaming for every gzip-accepting client, i.e. all browsers; curl missed it by not sending `Accept-Encoding: gzip`). The renderer now marks its streamed document (`STREAMED_HTML_HEADER`, stripped before send) and `gzipResponse` routes it through `flushingGzipStream` (node:zlib gzip with `Z_SYNC_FLUSH` after each chunk — the web `CompressionStream` exposes no flush). Buffered responses keep the one-shot `CompressionStream` (best ratio). Net: streamed pages stay compressed **and** flush progressively — head decodes in ~2 ms instead of ~stream-end.
|
|
28
|
+
|
|
29
|
+
- [`2246463`](https://github.com/briancray/abide/commit/22464634a78febe8dde20d612fd96dcdffc01620) - fix(cache): seed the client cache store for streamed `{#await}` reads so they hydrate warm instead of refetching. The SSR snapshot only inlined entries settled by render-return into `__SSR__.cache`; a `<template await>` read shipped its resolved tuple to `window.__abideResume` for no-flash DOM adoption, but its cache key was never seeded. `awaitBlock`'s effect re-reads the promise on the first hydrate pass (to subscribe the key for `cache.invalidate`), so that read cold-missed straight to the network — correct first paint, redundant fetch per streamed block.
|
|
30
|
+
|
|
31
|
+
The root cause is a timing one: a `{#await cache()}` expression is a thunk the SSR stream runs lazily, so its cache entry is created (and settled) _during_ the stream — after the render-return snapshot, which is therefore empty for it. The renderer now snapshots the store again once the stream has drained and emits an inline `__abideResolve(...)` chunk per entry (a warm `CacheSnapshotEntry`, or a `{ key, miss }` marker for an unshippable body → live refetch), keyed-diffed against what already shipped inline. `startClient` seeds both partitions through one sink (`seedStreamedResolution`) before the deferred bundle hydrates, so the subscription read resolves synchronously and adopts the SSR DOM with no wire round-trip.
|
|
32
|
+
|
|
33
|
+
Also hardens the reserved bundle-side stream path so it can't silently reintroduce the refetch: `startClient` installs a live, store-connected `window.__abideResolve`, and `applyResolved` seeds the store from `<abide-cache>` data frames (a script set via innerHTML never runs, so a bundle-consumed stream — streaming SPA navigation, socket-delivered SSR — carries the cache channel as data) — pairing the cache seed with the DOM swap.
|
|
34
|
+
|
|
35
|
+
Internal cleanup: `serializeCacheSnapshot` now returns `CacheSnapshotEntry[]` (the settled snapshots) instead of an `{ inline, pending }` partition. The `pending` half encoded the false premise that a `{#await}` read is in-flight at render-return — it is created lazily mid-stream, so `pending` only ever caught the rare eagerly-fired-unawaited read. The unused `CacheSnapshot` type is removed.
|
|
36
|
+
|
|
37
|
+
Guarded by a real-HTTP-entrypoint integration test (`bootTestServer` → `createServer` → stream) that reads a gated verb through `cache()` inside `{#await}` and asserts the warm `__abideResolve(...)` seed ships over the wire while `__SSR__.cache` stays empty — it reproduces the original cold-miss when the seed pass is reverted.
|
|
38
|
+
|
|
3
39
|
## 0.34.2
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ For clients that can't speak the ws multiplex, each socket has an HTTP face at `
|
|
|
75
75
|
|
|
76
76
|
## Components
|
|
77
77
|
|
|
78
|
-
Components are `.abide` files — valid HTML with `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. Reactive state is reached through `scope()` (`scope().state(v)`, `scope().computed(fn)`); `
|
|
78
|
+
Components are `.abide` files — valid HTML with `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. Reactive state is reached through `scope()` (`scope().state(v)`, `scope().computed(fn)`); props are read by destructuring `props()` (`const { room } = props()`), and `effect` is in scope without import. This page reads the verb above through `cache()`, tails the socket live, and exercises most of the template grammar:
|
|
79
79
|
|
|
80
80
|
```html
|
|
81
81
|
<script>
|
|
@@ -86,7 +86,7 @@ import { publishChat } from '$server/rpc/publishChat.ts'
|
|
|
86
86
|
import { chat } from '$server/sockets/chat.ts'
|
|
87
87
|
import Avatar from '$ui/Avatar.abide'
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
const { room } = props() // typed via src/.abide/routes.d.ts
|
|
90
90
|
const history = scope().computed(() => cache(getMessages)({ room })) // SSR snapshot + reactive refetch
|
|
91
91
|
const latest = scope().computed(() => tail(chat)) // re-renders on every new frame
|
|
92
92
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abide/abide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
"./ui/dom/tryBlock": "./src/lib/ui/dom/tryBlock.ts",
|
|
100
100
|
"./ui/dom/switchBlock": "./src/lib/ui/dom/switchBlock.ts",
|
|
101
101
|
"./ui/dom/applyResolved": "./src/lib/ui/dom/applyResolved.ts",
|
|
102
|
+
"./ui/runtime/escapeKey": "./src/lib/ui/runtime/escapeKey.ts",
|
|
102
103
|
"./ui/runtime/nextBlockId": "./src/lib/ui/runtime/nextBlockId.ts",
|
|
103
104
|
"./ui/runtime/enterRenderPass": "./src/lib/ui/runtime/enterRenderPass.ts",
|
|
104
105
|
"./ui/runtime/exitRenderPass": "./src/lib/ui/runtime/exitRenderPass.ts",
|
|
@@ -848,10 +848,17 @@ async function loadShell(cwd: string): Promise<string> {
|
|
|
848
848
|
/*
|
|
849
849
|
Injects the framework's client entry references so app.html stays a clean
|
|
850
850
|
template — page structure plus the SSR markers, no framework asset bookkeeping.
|
|
851
|
-
The css <link>
|
|
852
|
-
|
|
853
|
-
still spells the tags out (or
|
|
854
|
-
|
|
851
|
+
The css <link> and the entry's <link rel="modulepreload"> land before </head>,
|
|
852
|
+
the module <script> before </body>. Each is skipped when the shell already
|
|
853
|
+
carries that reference, so a custom app.html that still spells the tags out (or
|
|
854
|
+
one already processed) doesn't get a duplicate. rewriteHashedClientEntries then
|
|
855
|
+
swaps every reference for the hashed entry filenames.
|
|
856
|
+
|
|
857
|
+
The modulepreload makes the browser fetch the entry during a streamed render —
|
|
858
|
+
the closing </body> <script> tag arrives only after the whole stream, so without
|
|
859
|
+
it the entry download is serialized after the body. Execution still defers to
|
|
860
|
+
parse-end (module script), so hydration ordering is unchanged; only the transfer
|
|
861
|
+
overlaps the stream.
|
|
855
862
|
*/
|
|
856
863
|
function injectShellAssets(shell: string): string {
|
|
857
864
|
let result = shell
|
|
@@ -863,7 +870,16 @@ function injectShellAssets(shell: string): string {
|
|
|
863
870
|
'src/ui/app.html has no </head>',
|
|
864
871
|
)
|
|
865
872
|
}
|
|
866
|
-
if (!result.includes('
|
|
873
|
+
if (!result.includes('rel="modulepreload"')) {
|
|
874
|
+
result = injectBeforeTag(
|
|
875
|
+
result,
|
|
876
|
+
'<link rel="modulepreload" href="/_app/client.js" />',
|
|
877
|
+
'head',
|
|
878
|
+
'src/ui/app.html has no </head>',
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
// Guard on `src=` so the modulepreload's matching href doesn't mask a missing <script>.
|
|
882
|
+
if (!result.includes('src="/_app/client.js"')) {
|
|
867
883
|
result = injectBeforeTag(
|
|
868
884
|
result,
|
|
869
885
|
'<script type="module" src="/_app/client.js"></script>',
|
|
@@ -898,9 +914,10 @@ function injectBeforeTag(
|
|
|
898
914
|
Scans `dist/_app/` for the hashed client entry filenames produced by
|
|
899
915
|
build.ts (e.g. `client-abc12345.js`, `client-abc12345.css`) and swaps the
|
|
900
916
|
shell's literal `/_app/client.js` and `/_app/client.css` references for
|
|
901
|
-
them.
|
|
902
|
-
|
|
903
|
-
|
|
917
|
+
them. The js reference appears twice (the modulepreload <link> and the entry
|
|
918
|
+
<script>), so replaceAll covers both. When the directory is missing (someone
|
|
919
|
+
running the server before a build) the shell is returned unchanged so the
|
|
920
|
+
existing broken-asset behaviour is preserved.
|
|
904
921
|
*/
|
|
905
922
|
async function rewriteHashedClientEntries(shell: string, cwd: string): Promise<string> {
|
|
906
923
|
const appDir = `${cwd}/dist/_app`
|
|
@@ -914,10 +931,37 @@ async function rewriteHashedClientEntries(shell: string, cwd: string): Promise<s
|
|
|
914
931
|
const cssEntry = entries.find((file) => /^client-[a-z0-9]+\.css$/i.test(file))
|
|
915
932
|
let result = shell
|
|
916
933
|
if (jsEntry) {
|
|
917
|
-
result = result.
|
|
934
|
+
result = result.replaceAll('/_app/client.js', `/_app/${jsEntry}`)
|
|
935
|
+
result = await injectEntryDepPreloads(result, `${appDir}/${jsEntry}`)
|
|
918
936
|
}
|
|
919
937
|
if (cssEntry) {
|
|
920
938
|
result = result.replace('/_app/client.css', `/_app/${cssEntry}`)
|
|
921
939
|
}
|
|
922
940
|
return result
|
|
923
941
|
}
|
|
942
|
+
|
|
943
|
+
/* Static-import specifiers in the built entry: `import {…} from "./chunk.js"` and
|
|
944
|
+
side-effect `import "./chunk.js"`, minified (no spaces) or not. The leading
|
|
945
|
+
`import` plus the `[^"'()]` guard excludes dynamic `import("./page-….js")`, so
|
|
946
|
+
only the runtime graph matches — route chunks stay lazy. */
|
|
947
|
+
const ENTRY_STATIC_IMPORT = /import\s*(?:[^"'()]*?\bfrom\s*)?["']\.\/([\w.-]+\.js)["']/g
|
|
948
|
+
|
|
949
|
+
/*
|
|
950
|
+
SPIKE: preload the entry's static dependency chunks. The entry <script> statically
|
|
951
|
+
imports the abide-ui runtime as ~60 one-export `clientEntry-<hash>.js` chunks; the
|
|
952
|
+
browser can't discover them until it has downloaded and PARSED the entry, which on a
|
|
953
|
+
streamed page is ~stream-close — so the runtime waterfalls in a second wave after the
|
|
954
|
+
body finishes. Emitting a <link rel="modulepreload"> for each static dep into <head>
|
|
955
|
+
lets the whole graph transfer DURING the stream alongside the entry, so hydration is
|
|
956
|
+
network-ready at stream-close instead of after it. Route chunks (`import("./page-…")`)
|
|
957
|
+
are dynamic, so they don't match and stay lazy — code-splitting still pays off.
|
|
958
|
+
*/
|
|
959
|
+
async function injectEntryDepPreloads(shell: string, entryPath: string): Promise<string> {
|
|
960
|
+
const source = await Bun.file(entryPath).text()
|
|
961
|
+
const deps = [...new Set([...source.matchAll(ENTRY_STATIC_IMPORT)].map((match) => match[1]))]
|
|
962
|
+
if (deps.length === 0) {
|
|
963
|
+
return shell
|
|
964
|
+
}
|
|
965
|
+
const links = deps.map((dep) => `<link rel="modulepreload" href="/_app/${dep}" />`).join('\n')
|
|
966
|
+
return injectBeforeTag(shell, links, 'head', 'src/ui/app.html has no </head>')
|
|
967
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Internal response-header marker the streaming SSR renderer stamps on its
|
|
3
|
+
progressively-flushed `text/html` document. `gzipResponse` reads it to pick a
|
|
4
|
+
per-chunk-flushing gzip over the buffering web CompressionStream (then strips it
|
|
5
|
+
before send): a plain CompressionStream holds the head until its deflate window
|
|
6
|
+
flushes, defeating progressive delivery — the browser can't preload-scan the head
|
|
7
|
+
or paint the pending shell until the stream nearly closes. A streamed `text/html`
|
|
8
|
+
body is otherwise indistinguishable from a buffered one (both expose a
|
|
9
|
+
ReadableStream), and `isStreamingResponse` can't be widened to cover it without
|
|
10
|
+
also opting these pages out of the idle-timeout cap they rely on (see
|
|
11
|
+
disableIdleTimeoutForStream).
|
|
12
|
+
*/
|
|
13
|
+
export const STREAMED_HTML_HEADER = 'abide-streamed-html'
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { Glob, gunzipSync } from 'bun'
|
|
3
|
+
import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
|
|
4
|
+
import type { Assets } from './types/Assets.ts'
|
|
5
|
+
|
|
6
|
+
/* A static-import specifier in a built chunk: `import {…} from "./chunk.js"` and
|
|
7
|
+
side-effect `import "./chunk.js"`, minified or not. The leading `import` plus the
|
|
8
|
+
`[^"'()]` guard excludes dynamic `import("./page-….js")` — so only the runtime graph
|
|
9
|
+
matches, never a lazy route chunk. Mirrors the shell injector in abideResolverPlugin. */
|
|
10
|
+
const STATIC_IMPORT = /import\s*(?:[^"'()]*?\bfrom\s*)?["']\.\/([\w.-]+\.js)["']/g
|
|
11
|
+
|
|
12
|
+
/* A pages/layouts manifest entry in the entry chunk:
|
|
13
|
+
`"<route>": () => import("./page-<hash>.js")` (page chunk) or the layout form.
|
|
14
|
+
The route/layout key plus its lazily-loaded chunk — the only place route → chunk is
|
|
15
|
+
recorded, since every page chunk shares the `page-` stem and can't be told apart by name. */
|
|
16
|
+
const ROUTE_CHUNK = /"(\/[^"]*)"\s*:\s*\(\)\s*=>\s*import\("\.\/((?:page|layout)-[\w.-]+\.js)"\)/g
|
|
17
|
+
|
|
18
|
+
/* Reads a `_app` chunk's source by filename — gunzipped from the embedded asset map
|
|
19
|
+
(standalone compile) or off disk (dev + `abide start`). Undefined on a miss. */
|
|
20
|
+
function chunkReader(
|
|
21
|
+
distDir: string,
|
|
22
|
+
assets?: Assets,
|
|
23
|
+
): (name: string) => Promise<string | undefined> {
|
|
24
|
+
if (assets) {
|
|
25
|
+
return async (name) => {
|
|
26
|
+
const bytes = assets[`/_app/${name}`]
|
|
27
|
+
return bytes ? new TextDecoder().decode(gunzipSync(bytes)) : undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return async (name) => {
|
|
31
|
+
const file = Bun.file(`${distDir}/_app/${name}`)
|
|
32
|
+
return (await file.exists()) ? file.text() : undefined
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* The hashed client entry filename (`client-<hash>.js`), from the asset map or disk. */
|
|
37
|
+
async function findEntry(distDir: string, assets?: Assets): Promise<string | undefined> {
|
|
38
|
+
const isEntry = (name: string): boolean => /^client-[a-z0-9]+\.js$/i.test(name)
|
|
39
|
+
if (assets) {
|
|
40
|
+
const key = Object.keys(assets).find((path) => isEntry(path.replace('/_app/', '')))
|
|
41
|
+
return key?.replace('/_app/', '')
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(`${distDir}/_app`)) {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
const names = await Array.fromAsync(
|
|
47
|
+
new Glob('client-*.js').scan({ cwd: `${distDir}/_app`, onlyFiles: true }),
|
|
48
|
+
)
|
|
49
|
+
return names.find(isEntry)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
Maps each page route to the extra `_app` chunks worth preloading for it: the route's
|
|
54
|
+
page chunk, its layout-chain chunks, and the transitive static-import closure of each
|
|
55
|
+
— minus the entry's own static graph, which the shell already preloads. Built once at
|
|
56
|
+
server boot from the built bundle (the route → chunk mapping lives in the entry's
|
|
57
|
+
compiled pages/layouts manifest; the static graph is parsed from each chunk's source).
|
|
58
|
+
|
|
59
|
+
Those route chunks are dynamically imported by the entry, so the browser can't discover
|
|
60
|
+
them until it has downloaded, parsed, and RUN the entry — on a streamed page that's
|
|
61
|
+
~stream-close. Preloading them per render (the renderer knows the matched route) lets
|
|
62
|
+
the route's whole chain transfer DURING the stream, so hydration is network-ready at
|
|
63
|
+
stream-close instead of waterfalling after it. Returns an empty map when the bundle is
|
|
64
|
+
absent or unparseable, so rendering degrades to the entry-only preload.
|
|
65
|
+
*/
|
|
66
|
+
export async function buildPreloadManifest({
|
|
67
|
+
distDir,
|
|
68
|
+
assets,
|
|
69
|
+
}: {
|
|
70
|
+
distDir: string
|
|
71
|
+
assets?: Assets
|
|
72
|
+
}): Promise<Record<string, string[]>> {
|
|
73
|
+
const read = chunkReader(distDir, assets)
|
|
74
|
+
const entryName = await findEntry(distDir, assets)
|
|
75
|
+
const entrySource = entryName ? await read(entryName) : undefined
|
|
76
|
+
if (entryName === undefined || entrySource === undefined) {
|
|
77
|
+
return {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* route/layout key → its lazy chunk, split by the page-/layout- stem. */
|
|
81
|
+
const pageChunk: Record<string, string> = {}
|
|
82
|
+
const layoutChunk: Record<string, string> = {}
|
|
83
|
+
for (const match of entrySource.matchAll(ROUTE_CHUNK)) {
|
|
84
|
+
/* Both capture groups are present whenever the regex matches. */
|
|
85
|
+
const key = match[1] as string
|
|
86
|
+
const chunk = match[2] as string
|
|
87
|
+
if (chunk.startsWith('page-')) {
|
|
88
|
+
pageChunk[key] = chunk
|
|
89
|
+
} else {
|
|
90
|
+
layoutChunk[key] = chunk
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Memoised direct static imports per chunk; closure walks them to a fixpoint. */
|
|
95
|
+
const directCache = new Map<string, Promise<string[]>>()
|
|
96
|
+
const direct = (name: string): Promise<string[]> => {
|
|
97
|
+
const cached = directCache.get(name)
|
|
98
|
+
if (cached) {
|
|
99
|
+
return cached
|
|
100
|
+
}
|
|
101
|
+
const deps = read(name).then((source) =>
|
|
102
|
+
source
|
|
103
|
+
? [
|
|
104
|
+
...new Set(
|
|
105
|
+
[...source.matchAll(STATIC_IMPORT)].map((match) => match[1] as string),
|
|
106
|
+
),
|
|
107
|
+
]
|
|
108
|
+
: [],
|
|
109
|
+
)
|
|
110
|
+
directCache.set(name, deps)
|
|
111
|
+
return deps
|
|
112
|
+
}
|
|
113
|
+
const closure = async (name: string): Promise<Set<string>> => {
|
|
114
|
+
const seen = new Set<string>()
|
|
115
|
+
const queue = [name]
|
|
116
|
+
while (queue.length > 0) {
|
|
117
|
+
const current = queue.shift() as string
|
|
118
|
+
if (seen.has(current)) {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
seen.add(current)
|
|
122
|
+
for (const dependency of await direct(current)) {
|
|
123
|
+
if (!seen.has(dependency)) {
|
|
124
|
+
queue.push(dependency)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return seen
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* The entry + its static runtime — already preloaded by the shell, so excluded. */
|
|
132
|
+
const excluded = await closure(entryName)
|
|
133
|
+
const layoutKeys = Object.keys(layoutChunk)
|
|
134
|
+
const manifest: Record<string, string[]> = {}
|
|
135
|
+
for (const route of Object.keys(pageChunk)) {
|
|
136
|
+
const chunks = new Set<string>(await closure(pageChunk[route] as string))
|
|
137
|
+
for (const key of layoutChainForRoute(route, layoutKeys)) {
|
|
138
|
+
const chunk = layoutChunk[key]
|
|
139
|
+
if (chunk) {
|
|
140
|
+
for (const dependency of await closure(chunk)) {
|
|
141
|
+
chunks.add(dependency)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const preload = [...chunks].filter((chunk) => !excluded.has(chunk))
|
|
146
|
+
if (preload.length > 0) {
|
|
147
|
+
manifest[route] = preload
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return manifest
|
|
151
|
+
}
|
|
@@ -34,6 +34,7 @@ import { createSocketDispatcher } from '../sockets/createSocketDispatcher.ts'
|
|
|
34
34
|
import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
|
|
35
35
|
import { buildHealthPayload } from './buildHealthPayload.ts'
|
|
36
36
|
import { buildOpenApiSpec } from './buildOpenApiSpec.ts'
|
|
37
|
+
import { buildPreloadManifest } from './buildPreloadManifest.ts'
|
|
37
38
|
import { createAppAssetServer } from './createAppAssetServer.ts'
|
|
38
39
|
import { createPublicAssetServer } from './createPublicAssetServer.ts'
|
|
39
40
|
import { createRouteDispatcher } from './createRouteDispatcher.ts'
|
|
@@ -205,7 +206,7 @@ export async function createServer({
|
|
|
205
206
|
browser would render; the asset servers glob public/ and the build tree
|
|
206
207
|
(embedded gzip map in a compiled binary, dist/ on disk).
|
|
207
208
|
*/
|
|
208
|
-
const [clientFingerprint, servePublicAsset, serveAppAsset] = await Promise.all([
|
|
209
|
+
const [clientFingerprint, servePublicAsset, serveAppAsset, routePreloads] = await Promise.all([
|
|
209
210
|
dev
|
|
210
211
|
? devClientFingerprint({
|
|
211
212
|
srcDir: `${process.cwd()}/src`,
|
|
@@ -216,6 +217,7 @@ export async function createServer({
|
|
|
216
217
|
: undefined,
|
|
217
218
|
createPublicAssetServer({ publicDir, publicAssets }),
|
|
218
219
|
createAppAssetServer({ distDir, assets }),
|
|
220
|
+
buildPreloadManifest({ distDir, assets }),
|
|
219
221
|
])
|
|
220
222
|
setRegistryManifests({ rpc, sockets, prompts })
|
|
221
223
|
setMcpResourceServer(createMcpResourceServer({ resourcesDir, mcpResources }))
|
|
@@ -279,6 +281,7 @@ export async function createServer({
|
|
|
279
281
|
clientTimeout: parseBoundedEnvInt(process.env.ABIDE_CLIENT_TIMEOUT, 1, 600_000),
|
|
280
282
|
pages,
|
|
281
283
|
layouts,
|
|
284
|
+
routePreloads,
|
|
282
285
|
/* The wire payload, rebuilt per marked render — the __SSR__ health seed must match what /__abide/health serves. */
|
|
283
286
|
healthPayload: (request) => buildHealthPayload(request, { app, appName, appVersion }),
|
|
284
287
|
})
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { appNameSlot } from '../../shared/appNameSlot.ts'
|
|
2
2
|
import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
|
|
3
3
|
import { formatTraceparent } from '../../shared/formatTraceparent.ts'
|
|
4
|
+
import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
|
|
4
5
|
import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
|
|
6
|
+
import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
|
|
7
|
+
import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
|
|
8
|
+
import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
|
|
5
9
|
import { renderChain } from '../../ui/renderChain.ts'
|
|
6
10
|
import { renderToStream } from '../../ui/renderToStream.ts'
|
|
7
11
|
import type { UiComponent } from '../../ui/runtime/types/UiComponent.ts'
|
|
8
12
|
import { pageUrlFromStore } from './pageUrlFromStore.ts'
|
|
9
13
|
import { SSR_SWAP_SCRIPT } from './SSR_SWAP_SCRIPT.ts'
|
|
14
|
+
import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
|
|
10
15
|
import { safeJsonForScript } from './safeJsonForScript.ts'
|
|
11
16
|
import { serializeCacheSnapshot } from './serializeCacheSnapshot.ts'
|
|
17
|
+
import { streamCacheResolutions } from './streamCacheResolutions.ts'
|
|
12
18
|
import type { RequestStore } from './types/RequestStore.ts'
|
|
13
19
|
|
|
14
20
|
/* A abide-ui page module: its default export is the compiled component. */
|
|
@@ -21,6 +27,19 @@ function wantsJson(request: Request): boolean {
|
|
|
21
27
|
return (request.headers.get('accept') ?? '').includes('application/json')
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
/* Defines `window.__abideResolve` ahead of the body: the vanilla collector each
|
|
31
|
+
streamed cache resolution calls, buffering payloads for startClient to drain into
|
|
32
|
+
the store before hydration (the bundle is deferred, so it runs after every chunk). */
|
|
33
|
+
const CACHE_RESOLVE_SCRIPT =
|
|
34
|
+
'window.__abideResolve=function(r){(window.__abideResumeCache=window.__abideResumeCache||[]).push(r)}'
|
|
35
|
+
|
|
36
|
+
/* One streamed cache resolution as an inline `__abideResolve(...)` call. `<` is escaped
|
|
37
|
+
(as in encodeResume) so a body value can't close the script early. */
|
|
38
|
+
function resolveChunk(resolution: StreamedResolution): string {
|
|
39
|
+
const payload = JSON.stringify(resolution).replace(/</g, '\\u003c')
|
|
40
|
+
return `<script>__abideResolve(${payload})</script>`
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
/*
|
|
25
44
|
The abide-ui SSR document renderer. A matched route + params in, a finished HTML
|
|
26
45
|
Response out.
|
|
@@ -30,9 +49,16 @@ await blocks STREAMS: the pending shell flushes first, then each resolved fragme
|
|
|
30
49
|
(`<abide-resolve>` carrying a JSON `<script>`) as its promise settles, swapped into its boundary
|
|
31
50
|
by the inline SSR_SWAP_SCRIPT — which also registers the value into the resume
|
|
32
51
|
manifest so client hydration adopts it without re-fetching (see abide/ui/awaitBlock).
|
|
52
|
+
Then, once the stream has run every `{#await}` thunk (creating and settling its cache
|
|
53
|
+
entry mid-stream, after the render-return snapshot), each such entry streams an inline
|
|
54
|
+
`__abideResolve(...)` chunk — a warm snapshot, or a `{ key, miss }` marker for an
|
|
55
|
+
unshippable body (→ live refetch) — seeding the client store before the deferred bundle
|
|
56
|
+
so the block's subscription read is warm (no refetch).
|
|
33
57
|
|
|
34
58
|
`__SSR__` carries the route/params, mount base, trace, app name, client timeout,
|
|
35
|
-
and the
|
|
59
|
+
and the render-return cache snapshot (top-level `await` reads; the client seeds its tab
|
|
60
|
+
store from it). `{#await}` reads aren't settled yet at render-return — they arrive over
|
|
61
|
+
the stream as above. A route's
|
|
36
62
|
`layout.abide` files wrap the page outermost-first (every ancestor directory's
|
|
37
63
|
layout applies); the chain server-renders as one document via `renderChain`, the
|
|
38
64
|
page folded into each layout's `<slot/>` outlet. The client router re-resolves the
|
|
@@ -44,6 +70,7 @@ export function createUiPageRenderer({
|
|
|
44
70
|
clientTimeout,
|
|
45
71
|
pages,
|
|
46
72
|
layouts,
|
|
73
|
+
routePreloads = {},
|
|
47
74
|
healthPayload,
|
|
48
75
|
}: {
|
|
49
76
|
shell: string
|
|
@@ -51,6 +78,7 @@ export function createUiPageRenderer({
|
|
|
51
78
|
clientTimeout: number | undefined
|
|
52
79
|
pages: Record<string, LoadPage>
|
|
53
80
|
layouts: Record<string, LoadPage>
|
|
81
|
+
routePreloads?: Record<string, string[]>
|
|
54
82
|
healthPayload: (request: Request) => Promise<Record<string, unknown>>
|
|
55
83
|
}): {
|
|
56
84
|
renderPage: (
|
|
@@ -65,13 +93,15 @@ export function createUiPageRenderer({
|
|
|
65
93
|
stack?: string,
|
|
66
94
|
) => Promise<Response | undefined>
|
|
67
95
|
} {
|
|
68
|
-
/* Build the __SSR__ <script> the client (startClient) reads on boot.
|
|
96
|
+
/* Build the __SSR__ <script> the client (startClient) reads on boot. The inline
|
|
97
|
+
(settled) cache partition is computed once by the caller and threaded in, so the
|
|
98
|
+
streaming branch can also drain the pending partition over the same render. */
|
|
69
99
|
async function stateTag(
|
|
70
100
|
routeUrl: string,
|
|
71
101
|
params: Record<string, string>,
|
|
72
102
|
store: RequestStore,
|
|
103
|
+
inline: CacheSnapshotEntry[],
|
|
73
104
|
): Promise<string> {
|
|
74
|
-
const { inline } = await serializeCacheSnapshot(store.cache)
|
|
75
105
|
const health = store.healthRead ? await healthPayload(store.req) : undefined
|
|
76
106
|
const payload = safeJsonForScript({
|
|
77
107
|
route: routeUrl,
|
|
@@ -86,6 +116,32 @@ export function createUiPageRenderer({
|
|
|
86
116
|
return `<script>window.__SSR__ = ${payload};</script>`
|
|
87
117
|
}
|
|
88
118
|
|
|
119
|
+
/* Per-route `<link rel=modulepreload>`s for the route's page + layout-chain chunks
|
|
120
|
+
and their route-only static runtime deps (the shell already preloads the entry's
|
|
121
|
+
shared runtime). Those chunks are dynamically imported by the entry, so the browser
|
|
122
|
+
discovers them only after the entry runs at parse-end ≈ stream-close; preloading them
|
|
123
|
+
in <head> overlaps their transfer with the stream. Rebased onto the mount base like
|
|
124
|
+
the shell's own `/_app/` refs. Cached per route — the set is render-invariant. */
|
|
125
|
+
const preloadLinkCache = new Map<string, string>()
|
|
126
|
+
function routePreloadLinks(routeUrl: string): string {
|
|
127
|
+
const cached = preloadLinkCache.get(routeUrl)
|
|
128
|
+
if (cached !== undefined) {
|
|
129
|
+
return cached
|
|
130
|
+
}
|
|
131
|
+
const links = (routePreloads[routeUrl] ?? [])
|
|
132
|
+
.map((chunk) => `<link rel="modulepreload" href="${base}/_app/${chunk}" />`)
|
|
133
|
+
.join('')
|
|
134
|
+
preloadLinkCache.set(routeUrl, links)
|
|
135
|
+
return links
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Splices the route preloads in before the shell's </head> (case-insensitive, like the
|
|
139
|
+
build-time injector). A no-op when there are none or the shell carries no </head>. */
|
|
140
|
+
function injectRoutePreloads(html: string, routeUrl: string): string {
|
|
141
|
+
const links = routePreloadLinks(routeUrl)
|
|
142
|
+
return links === '' ? html : html.replace(/<\/head>/i, `${links}</head>`)
|
|
143
|
+
}
|
|
144
|
+
|
|
89
145
|
async function renderPage(
|
|
90
146
|
routeUrl: string,
|
|
91
147
|
params: Record<string, string>,
|
|
@@ -117,14 +173,24 @@ export function createUiPageRenderer({
|
|
|
117
173
|
params,
|
|
118
174
|
)
|
|
119
175
|
|
|
176
|
+
/* Snapshot the cache settled by render-return — top-level `await` reads (render
|
|
177
|
+
blocked on them) — into __SSR__. A `{#await}` read is NOT here: its expression
|
|
178
|
+
is a thunk `renderToStream` runs lazily, so its entry is created mid-stream and
|
|
179
|
+
seeded after the drain (see below). */
|
|
180
|
+
const inline = await serializeCacheSnapshot(store.cache)
|
|
181
|
+
const inlinedKeys = new Set(inline.map((entry) => entry.key))
|
|
182
|
+
|
|
120
183
|
/* No await blocks → render synchronously, ship buffered. */
|
|
121
184
|
if (ssr.awaits.length === 0) {
|
|
122
|
-
const html =
|
|
123
|
-
|
|
185
|
+
const html = injectRoutePreloads(
|
|
186
|
+
shell.replace(SSR_MARKER, (_match, key: string) =>
|
|
187
|
+
key === 'body' ? ssr.html : key === 'state' ? '' : '',
|
|
188
|
+
),
|
|
189
|
+
routeUrl,
|
|
124
190
|
)
|
|
125
191
|
const withState = html.replace(
|
|
126
192
|
'</body>',
|
|
127
|
-
`${await stateTag(routeUrl, params, store)}</body>`,
|
|
193
|
+
`${await stateTag(routeUrl, params, store, inline)}</body>`,
|
|
128
194
|
)
|
|
129
195
|
return new Response(withState, {
|
|
130
196
|
headers: {
|
|
@@ -139,9 +205,14 @@ export function createUiPageRenderer({
|
|
|
139
205
|
Fill head/state but LEAVE the body marker intact — it's the split point for
|
|
140
206
|
streaming the page body into `#app`; consuming it here would append the body
|
|
141
207
|
after the whole shell (outside `#app`), breaking hydration. */
|
|
142
|
-
const head =
|
|
143
|
-
|
|
144
|
-
|
|
208
|
+
const head =
|
|
209
|
+
`<script>${SSR_SWAP_SCRIPT}${CACHE_RESOLVE_SCRIPT}</script>` +
|
|
210
|
+
`${await stateTag(routeUrl, params, store, inline)}`
|
|
211
|
+
const filled = injectRoutePreloads(
|
|
212
|
+
shell.replace(/<!--ssr:(head|state)-->/g, (_match, key: string) =>
|
|
213
|
+
key === 'head' ? head : '',
|
|
214
|
+
),
|
|
215
|
+
routeUrl,
|
|
145
216
|
)
|
|
146
217
|
const [before, after] = filled.split(BODY_MARKER)
|
|
147
218
|
const encoder = new TextEncoder()
|
|
@@ -158,6 +229,26 @@ export function createUiPageRenderer({
|
|
|
158
229
|
)
|
|
159
230
|
first = false
|
|
160
231
|
}
|
|
232
|
+
/* The {#await} reads created (and settled) their cache entries DURING the
|
|
233
|
+
stream — the await expression is a thunk `renderToStream` ran lazily —
|
|
234
|
+
so they missed the render-return snapshot. Drain them now: each lands an
|
|
235
|
+
inline `__abideResolve(...)` chunk (a warm snapshot, or a `{ key, miss }`
|
|
236
|
+
marker for an unshippable body → live refetch) before the deferred
|
|
237
|
+
bundle, so startClient seeds the store and the block's subscription read
|
|
238
|
+
is warm. Skip keys already shipped inline in __SSR__. */
|
|
239
|
+
const streamedEntries = Array.from(store.cache.entries.values()).filter(
|
|
240
|
+
(entry: CacheEntry) =>
|
|
241
|
+
entry.settled === true &&
|
|
242
|
+
entry.request !== undefined &&
|
|
243
|
+
isReplayableMethod(entry.request.method.toUpperCase()) &&
|
|
244
|
+
!inlinedKeys.has(entry.key),
|
|
245
|
+
)
|
|
246
|
+
for await (const resolution of streamCacheResolutions(
|
|
247
|
+
store.cache,
|
|
248
|
+
streamedEntries,
|
|
249
|
+
)) {
|
|
250
|
+
controller.enqueue(encoder.encode(resolveChunk(resolution)))
|
|
251
|
+
}
|
|
161
252
|
controller.enqueue(encoder.encode(after ?? ''))
|
|
162
253
|
controller.close()
|
|
163
254
|
},
|
|
@@ -166,6 +257,10 @@ export function createUiPageRenderer({
|
|
|
166
257
|
headers: {
|
|
167
258
|
'Content-Type': 'text/html; charset=utf-8',
|
|
168
259
|
'Cache-Control': SSR_CACHE_CONTROL,
|
|
260
|
+
/* Mark the progressively-flushed document so gzipResponse compresses it
|
|
261
|
+
with a per-chunk-flushing gzip (the plain CompressionStream buffers the
|
|
262
|
+
head and defeats streaming); the marker is stripped before send. */
|
|
263
|
+
[STREAMED_HTML_HEADER]: '1',
|
|
169
264
|
},
|
|
170
265
|
},
|
|
171
266
|
)
|