@abide/abide 0.38.1 → 0.40.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 +5 -5
- package/CHANGELOG.md +63 -0
- package/package.json +9 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
- package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
- package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
- package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
- package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
- package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +19 -3
- package/src/lib/server/sockets/defineSocket.ts +3 -1
- package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
- package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
- package/src/lib/shared/buildRpcRequest.ts +8 -1
- package/src/lib/shared/createRemoteFunction.ts +20 -11
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/shared/escapeHtml.ts +15 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
- package/src/lib/shared/streamResponse.ts +8 -1
- package/src/lib/shared/types/RemoteCallable.ts +12 -3
- package/src/lib/shared/types/RpcOptions.ts +22 -0
- package/src/lib/shared/types/SourceMap.ts +14 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
- package/src/lib/ui/compile/compileModule.ts +5 -2
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +25 -10
- package/src/lib/ui/compile/composeProps.ts +53 -0
- package/src/lib/ui/compile/desugarSignals.ts +45 -17
- package/src/lib/ui/compile/generateBuild.ts +87 -76
- package/src/lib/ui/compile/generateSSR.ts +217 -71
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +118 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
- package/src/lib/ui/compile/staticAttr.ts +1 -1
- package/src/lib/ui/compile/staticTextPart.ts +1 -1
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +15 -3
- package/src/lib/ui/createScope.ts +14 -0
- package/src/lib/ui/dom/appendText.ts +2 -3
- package/src/lib/ui/dom/appendTextAt.ts +2 -3
- package/src/lib/ui/dom/applyResolved.ts +5 -8
- package/src/lib/ui/dom/awaitBlock.ts +14 -1
- package/src/lib/ui/dom/mergeProps.ts +32 -0
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/installHotBridge.ts +10 -0
- package/src/lib/ui/navigate.ts +28 -3
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +39 -37
- package/src/lib/ui/renderToStream.ts +84 -68
- package/src/lib/ui/resumeSeedScript.ts +27 -0
- package/src/lib/ui/router.ts +81 -51
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +21 -8
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
- package/src/lib/ui/socketChannel.ts +5 -2
- package/src/lib/ui/tryEncodeResume.ts +20 -0
- package/src/lib/ui/types/Scope.ts +9 -3
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
package/AGENTS.md
CHANGED
|
@@ -69,7 +69,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
|
|
|
69
69
|
|
|
70
70
|
### RPC verbs — @documentation rpc
|
|
71
71
|
|
|
72
|
-
- `abide/server/GET`, `abide/server/POST`, `abide/server/PUT`, `abide/server/PATCH`, `abide/server/DELETE`, `abide/server/HEAD` — declare a verb: `VERB(handler, opts?)`; the preload rewrites each to `defineVerb(method, url, …)` (server) or `remoteProxy` (client). `opts`: `inputSchema`, `outputSchema`, `filesSchema`, `clients`, `crossOrigin`, `maxBodySize`, `timeout`. The returned function is callable in-process and exposes `.raw(args)` (the `Response`), `.stream(args)` (frame iterable), and `.fetch(request)` (router entry).
|
|
72
|
+
- `abide/server/GET`, `abide/server/POST`, `abide/server/PUT`, `abide/server/PATCH`, `abide/server/DELETE`, `abide/server/HEAD` — declare a verb: `VERB(handler, opts?)`; the preload rewrites each to `defineVerb(method, url, …)` (server) or `remoteProxy` (client). `opts`: `inputSchema`, `outputSchema`, `filesSchema`, `clients`, `crossOrigin`, `maxBodySize`, `timeout`. The returned function is callable in-process as `fn(args, callOpts?)` and exposes `.raw(args, callOpts?)` (the `Response`), `.stream(args)` (frame iterable), and `.fetch(request)` (router entry). `callOpts` is a curated `Pick` of `RequestInit` for per-call transport — `signal`, `keepalive`, `priority`, `cache`, `headers` — that the server handler never sees, so the call stays isomorphic: `signal` merges with the scope abort + client timeout (`AbortSignal.any`, ignored under `cache()`), and `headers` merge onto the framework headers with `traceparent`/offline/`content-type` winning.
|
|
73
73
|
- `abide/server/rpc/defineVerb` — the rewrite target the verb helpers expand to: validates args against `inputSchema` (422 on issues), enforces `timeout` (504 + abort), and registers the verb for MCP/CLI/OpenAPI/inspector discovery.
|
|
74
74
|
|
|
75
75
|
### Responses — @documentation response
|
|
@@ -142,7 +142,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
|
|
|
142
142
|
|
|
143
143
|
### Reactive state — @documentation reactive-state
|
|
144
144
|
|
|
145
|
-
- `abide/ui/scope` — `scope()`: the sole reactive surface. `.state(initial?, transform?)` (writable cell), `.computed(fn)` (read-only), `.linked(seed, transform?)` (local draft reseeded from upstream). Bare `state`/`computed`/`linked` are a compile error; a writable computed is expressed at the binding (`bind:value={{ get, set }}`). Also carries doc/persistence/undo/broadcast methods.
|
|
145
|
+
- `abide/ui/scope` — `scope()`: the sole reactive surface. `.state(initial?, transform?)` (writable cell), `.computed(fn)` (read-only), `.linked(seed, transform?)` (local draft reseeded from upstream). Bare `state`/`computed`/`linked` are a compile error; a writable computed is expressed at the binding (`bind:value={{ get, set }}`). `.share(key, value)`/`.shared(key)` pass context down the tree (closest-ancestor lookup; the reactive doc itself doesn't inherit, so share a `cell` for reactive context). Also carries doc/persistence/undo/broadcast methods.
|
|
146
146
|
|
|
147
147
|
### Effect — @documentation effect
|
|
148
148
|
|
|
@@ -151,7 +151,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
|
|
|
151
151
|
### Tail & navigate — @documentation tail · navigate
|
|
152
152
|
|
|
153
153
|
- `abide/ui/tail` — `tail(subscribable)`: latest frame of a `Socket`/stream (reactive); window form `tail(s, { last })` → array; `tail.error`/`tail.status` probes.
|
|
154
|
-
- `abide/ui/navigate` — `navigate(path,
|
|
154
|
+
- `abide/ui/navigate` — `navigate(path, options?)`: client navigation through the router. `options` is `{ replace?, keepScroll? }` — `replace` swaps the current history entry, `keepScroll` preserves the live scroll offset across an in-page URL swap.
|
|
155
155
|
|
|
156
156
|
### Outbox — @documentation ui
|
|
157
157
|
|
|
@@ -162,7 +162,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
|
|
|
162
162
|
- `abide/ui/enterScope`, `abide/ui/exitScope` — push/pop the lexical scope around an SSR render.
|
|
163
163
|
- `abide/ui/router`, `abide/ui/startClient`, `abide/ui/renderToStream` — the client router, the official client entry (reads `window.__SSR__`, seeds cache, starts the router), and the out-of-order SSR streamer.
|
|
164
164
|
- `abide/ui/remoteProxy`, `abide/ui/socketProxy` — the bundler-emitted client substitutes that swap a server verb/socket import for a `fetch` proxy / ws-multiplexed `Socket`.
|
|
165
|
-
- `abide/ui/dom/*` — the compiled-template DOM runtime (one node-builder per construct): `mount`, `mountChild`, `mountSlot`, `outlet`, `hydrate`, `skeleton`, `cloneStatic`, `anchorCursor`, `text`, `appendText`, `appendTextAt`, `appendSnippet`, `appendStatic`, `attr`, `on`, `attach`, `each`, `eachAsync`, `when`, `awaitBlock`, `tryBlock`, `switchBlock`, `applyResolved`. `outlet` is the marker-range a layout `<slot/>` compiles to (no wrapper element — the router fills it with the next chain layer as a direct child).
|
|
165
|
+
- `abide/ui/dom/*` — the compiled-template DOM runtime (one node-builder per construct): `mount`, `mountChild`, `mergeProps`, `spreadProps`, `restProps`, `spreadAttrs`, `readCall`, `mountSlot`, `outlet`, `hydrate`, `skeleton`, `cloneStatic`, `anchorCursor`, `text`, `appendText`, `appendTextAt`, `appendSnippet`, `appendStatic`, `attr`, `on`, `attach`, `each`, `eachAsync`, `when`, `awaitBlock`, `tryBlock`, `switchBlock`, `applyResolved`. `outlet` is the marker-range a layout `<slot/>` compiles to (no wrapper element — the router fills it with the next chain layer as a direct child). `readCall` guards a method call lowered onto a reactive-doc read (`model.draft.trim()` → `readCall(model.read("draft"), …)`) so a nullish read throws naming the scope path and member, not the engine's opaque `undefined is not an object`. A `{...expr}` spreads onto a component (its props, via `mergeProps`/`spreadProps`) or a native element (its attributes, via `spreadAttrs` — each key reactive, an `on<event>` function becomes a listener); keys resolve last-wins in source order. A component reads the props it doesn't name with `const { foo, ...rest } = props()` — `rest` (a `restProps` bag of the unconsumed props) can itself be spread onward (`<input {...rest}/>`).
|
|
166
166
|
- `abide/ui/runtime/*` — render-pass helpers: `escapeKey` (JSON-Pointer key escaping), `nextBlockId`, `enterRenderPass`, `exitRenderPass`.
|
|
167
167
|
|
|
168
168
|
## Build / tooling — @documentation building
|
|
@@ -171,7 +171,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
|
|
|
171
171
|
- `abide/compile` — `compile({ cwd?, target?, outfile?, buildClient? })`: produce a standalone Bun server executable (bytecode, embedded compressed assets).
|
|
172
172
|
- `abide/preload` — the Bun preload (`bunfig.toml`) that installs the `.abide`, resolver, and CSS-noop plugins and rewrites verb/socket imports.
|
|
173
173
|
- `abide/resolver-plugin` — `abideResolverPlugin({ cwd?, embedAssets?, target? })`: virtualizes every generated module and rewrites verbs/sockets per side.
|
|
174
|
-
- `abide/ui-plugin` — `abideUiPlugin`: the Bun loader compiling `.abide` SFCs to ES modules (scoped `<style>` via virtual imports; `layout.abide`
|
|
174
|
+
- `abide/ui-plugin` — `abideUiPlugin`: the Bun loader compiling `.abide` SFCs to ES modules (scoped `<style>` via virtual imports; a `layout.abide`'s `<slot/>` lowers to the marker-range `outlet` the router fills as a direct child, no wrapper element).
|
|
175
175
|
- `abide/tsconfig` — `tsconfig.app.json` for consumers to extend (`bundler` resolution, `strict`, `noEmit`, `bun` types).
|
|
176
176
|
|
|
177
177
|
## Desktop bundle — @documentation bundle
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.40.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`05ea7e2`](https://github.com/briancray/abide/commit/05ea7e235b1e7c035d3b4313e12fc83733fa814b) - <template elseif> conditional chains ([`c3237cc`](https://github.com/briancray/abide/commit/c3237ccc0d222c36e8b00ce68595eb92609ef856))
|
|
8
|
+
|
|
9
|
+
- [`15c0ad3`](https://github.com/briancray/abide/commit/15c0ad334f698dfb49a9dcd0e0361925afc4f6c8) - `navigate` takes an options object — `navigate(path, { replace?, keepScroll? })` — replacing the old `navigate(path, replace?)` boolean. **Breaking:** a caller passing a boolean (`navigate(path, true)`) must migrate to `navigate(path, { replace: true })`; a bare `navigate(path)` is unchanged. The new `keepScroll` flag carries the live scroll offset onto the destination entry so an in-page URL swap — e.g. selecting another episode on the same detail page — restores to the current position instead of jumping to top. A push re-buckets the offset under the freshly minted entry id; a `replace` keeps the entry's bucket instead of discarding it.
|
|
10
|
+
|
|
11
|
+
- [`ba56bb2`](https://github.com/briancray/abide/commit/ba56bb28be06fa0a11ab57fb10fa2d6b75ab8656) - Add `share`/`shared` context to `scope()`. The reactive doc does not inherit down the scope tree (each scope owns a separate document), so passing a value from an ancestor to a descendant previously meant threading it through every layer as props. `scope().share(key, value)` now puts a named value on a scope's own side-map, and `scope().shared(key)` reads the closest ancestor (self included) that has the key — an existence-checked, non-tracking upward walk, returning `undefined` when no scope provides it. The value is held by reference and the lookup never subscribes, so reactive context is expressed by sharing a `cell` (or a scope) rather than a plain object — reactivity rides what you share, not the share itself. A shared `undefined` shadows an ancestor (the walk stops on `has`, not truthiness). The shared map is released on `dispose`.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - extract resolveBranches for branch lookup ([`19a28a1`](https://github.com/briancray/abide/commit/19a28a1236e38a78c53a2adff0a981c8ad20b698))
|
|
16
|
+
|
|
17
|
+
- [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - sync AGENTS.md surface map for scope share/shared and navigate options ([`3e41255`](https://github.com/briancray/abide/commit/3e4125516164c94cfe9c9e6baa35f9ff9ec9c3e0))
|
|
18
|
+
|
|
19
|
+
- [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - use Promise.withResolvers in runWithVerbTimeout ([`504bdca`](https://github.com/briancray/abide/commit/504bdcac285cfd3762c8390fd1b6d640e62029bc))
|
|
20
|
+
|
|
21
|
+
- [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - extract VerbBaseOpts for shared verb options ([`d7414ca`](https://github.com/briancray/abide/commit/d7414cab7a557b54395321026d818b9fba82ba34))
|
|
22
|
+
|
|
23
|
+
- [`ccc7f87`](https://github.com/briancray/abide/commit/ccc7f879d0506af084335785026200aeb38fdde4) - `on()` no longer attaches a non-function event handler. A component that omits an optional `on*` prop forwards it as `undefined`, yet the compiler still emits the `on()` call — so the listener fired `undefined(event)` and threw "handler is not a function" on every matching event (a keystroke or input on a search box, a click, …). `on()` now skips attaching when the handler is not a function, so an omitted optional handler is a no-op rather than a per-event crash.
|
|
24
|
+
|
|
25
|
+
- [`c29dcd2`](https://github.com/briancray/abide/commit/c29dcd2796b5caa3ee41857aa612322b1e4220fa) - Parse `html`-branded raw markup in the parent's namespace. The client-side raw-HTML
|
|
26
|
+
binding parsed markup by setting `innerHTML` on a throwaway `<div>`, so the fragment
|
|
27
|
+
parser always used the HTML namespace — markup bound inside an `<svg>` produced
|
|
28
|
+
HTML-namespaced `<path>`/`<circle>` elements that exist in the DOM but never render.
|
|
29
|
+
This only surfaced on client-created or re-rendered nodes; the initial SSR/hydration
|
|
30
|
+
path adopts the server markup verbatim and kept the correct namespace, so an icon
|
|
31
|
+
component (`{html(svgInnerMarkup)}` inside `<svg>`) rendered on first paint but
|
|
32
|
+
vanished the moment its subtree was rebuilt on the client (e.g. a live cache delta).
|
|
33
|
+
A new `parseRawNodes` helper picks an SVG holder when the parent is SVG-namespaced,
|
|
34
|
+
shared by both `appendText` and `appendTextAt`.
|
|
35
|
+
|
|
36
|
+
- [`9a74da6`](https://github.com/briancray/abide/commit/9a74da67c1f65cdb37e7a47af2e7b104cfe7620f) - Stop the signal-ref rewrite from descending into type space. `renameSignalRefs` rewrote every value-position identifier matching a signal name, but never skipped TypeScript type aliases, interfaces, or type annotations — so a component that destructures a prop (`const { option, ...rest } = props<Props>()`) and names that prop in its own type (`type Props = { option?: (value: T) => unknown; … }`) had the type member rewritten into a call (`option(): …`). This only surfaced once a `...rest` binding forced the named prop type to be emitted into the build (a no-rest `props<{…}>()` strips its type argument); with simple property types the mangle landed as accidentally-valid method-signature syntax, but optional/function/union members (`option?: (v) => unknown`, `value?: T | T[] | string`) became invalid TS and failed the page build. The visitor now leaves type aliases, interfaces, and any type node untouched — type space never holds a runtime read, and types erase at build regardless.
|
|
37
|
+
|
|
38
|
+
- [`92597f0`](https://github.com/briancray/abide/commit/92597f0cbabc1e44449c0f0f812048481c38c579) - RPC bodies and socket frames now ship as ref-json (cycles, shared references, and
|
|
39
|
+
the types JSON drops/coerces — `undefined`, `bigint`, `Date`, `Map`, `Set`, `NaN`,
|
|
40
|
+
`±Infinity`, `-0` — survive the wire), and the server still accepts ordinary JSON from
|
|
41
|
+
non-abide clients. abide's own client flags a ref-json body with the `abide-ref-json`
|
|
42
|
+
header so the server decodes it with the matching codec; a request without the header
|
|
43
|
+
(curl, an OpenAPI-generated SDK, a webhook) is read with plain `JSON.parse`, so the
|
|
44
|
+
documented HTTP/OpenAPI body contract keeps working. The header is the discriminator
|
|
45
|
+
rather than the payload shape because ref-json's `[root, slots]` envelope is ambiguous
|
|
46
|
+
with a legitimate plain-JSON two-element array body. Socket frames need no header — a
|
|
47
|
+
frame is always an object, never the envelope array, so a plain-JSON frame falls back
|
|
48
|
+
to `JSON.parse` unambiguously.
|
|
49
|
+
|
|
50
|
+
## 0.39.0
|
|
51
|
+
|
|
52
|
+
### Minor Changes
|
|
53
|
+
|
|
54
|
+
- [`ae15b27`](https://github.com/briancray/abide/commit/ae15b2733c3c569b9bde9e4d79f9b768836b3a46) - async SSR render with inline nested-blocking awaits and spread/rest props ([`514a796`](https://github.com/briancray/abide/commit/514a796833c5be4caa296b295d63fda6c8452a36))
|
|
55
|
+
|
|
56
|
+
- [`527795d`](https://github.com/briancray/abide/commit/527795d1035f7cb169723a03571603a9ce8556e1) - Remote functions now accept an optional trailing options bag — `fn(args, opts)` — for per-call transport control: `signal`, `keepalive`, `priority`, `cache`, and `headers`. It's a curated `Pick` of `RequestInit`, not a raw passthrough: the server handler never observes these, so the call stays isomorphic, and a caller can't clobber the method, body, or framework headers the RPC contract owns. `opts.signal` merges with the scope abort and client timeout (`AbortSignal.any`) rather than replacing them, and is ignored under `cache()` so one reader can't abort a coalesced flight the others share. `opts.headers` merge onto abide's headers with the framework winning — a caller adds transport metadata (idempotency-key, authorization) but can't overwrite `traceparent`, the offline marker, or `content-type`.
|
|
57
|
+
|
|
58
|
+
### Patch Changes
|
|
59
|
+
|
|
60
|
+
- [`ae15b27`](https://github.com/briancray/abide/commit/ae15b2733c3c569b9bde9e4d79f9b768836b3a46) - harden client router, asset serving, sockets, and runtime edge cases ([`4735920`](https://github.com/briancray/abide/commit/4735920edea942dcd2954c522673b0c8af5a9a7f))
|
|
61
|
+
|
|
62
|
+
- [`ae15b27`](https://github.com/briancray/abide/commit/ae15b2733c3c569b9bde9e4d79f9b768836b3a46) - consolidate escapeHtml and safeJsonForScript into shared/ ([`ae2d602`](https://github.com/briancray/abide/commit/ae2d6022f6dc23994164d1764bf5b812b136e383))
|
|
63
|
+
|
|
64
|
+
- [`76a4a19`](https://github.com/briancray/abide/commit/76a4a19048fd5373b0d3ed75f2c6de017e33c7a3) - Make reactive errors and stack traces read in authored terms. A method call lowered onto a reactive-doc read now routes through a guard (`readCall`) that throws naming the scope path and member (`cannot call .close() — scope value "modal" is undefined`) instead of the engine's opaque `undefined is not an object`. The client build's source maps ignore-list abide's own framework sources, so a debugger collapses the mount-stack wall (`scope`/`mountRange`/`runNode`/…) and shows only authored `.abide`/`.ts` frames. Reactive bindings emit named thunks (`attr_title`/`text`/`bind_value`) so those surviving frames carry a name instead of `(anonymous)`.
|
|
65
|
+
|
|
3
66
|
## 0.38.1
|
|
4
67
|
|
|
5
68
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abide/abide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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",
|
|
@@ -79,6 +79,11 @@
|
|
|
79
79
|
"./ui/outbox": "./src/lib/ui/outbox.ts",
|
|
80
80
|
"./ui/dom/mount": "./src/lib/ui/dom/mount.ts",
|
|
81
81
|
"./ui/dom/mountChild": "./src/lib/ui/dom/mountChild.ts",
|
|
82
|
+
"./ui/dom/mergeProps": "./src/lib/ui/dom/mergeProps.ts",
|
|
83
|
+
"./ui/dom/spreadProps": "./src/lib/ui/dom/spreadProps.ts",
|
|
84
|
+
"./ui/dom/restProps": "./src/lib/ui/dom/restProps.ts",
|
|
85
|
+
"./ui/dom/spreadAttrs": "./src/lib/ui/dom/spreadAttrs.ts",
|
|
86
|
+
"./ui/dom/readCall": "./src/lib/ui/dom/readCall.ts",
|
|
82
87
|
"./ui/dom/hydrate": "./src/lib/ui/dom/hydrate.ts",
|
|
83
88
|
"./ui/dom/text": "./src/lib/ui/dom/text.ts",
|
|
84
89
|
"./ui/dom/appendText": "./src/lib/ui/dom/appendText.ts",
|
|
@@ -127,6 +132,9 @@
|
|
|
127
132
|
"./ui/socketProxy": "./src/lib/ui/socketProxy.ts",
|
|
128
133
|
"./server/prompts/renderPromptTemplate": "./src/lib/server/prompts/renderPromptTemplate.ts"
|
|
129
134
|
},
|
|
135
|
+
"scripts": {
|
|
136
|
+
"test": "bun test ./tests"
|
|
137
|
+
},
|
|
130
138
|
"bin": {
|
|
131
139
|
"abide": "./bin/abide.ts"
|
|
132
140
|
},
|
package/src/build.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { clientBuildPlugins } from './clientBuildPlugins.ts'
|
|
2
2
|
import { abideLog } from './lib/shared/abideLog.ts'
|
|
3
3
|
import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
|
|
4
|
+
import { markFrameworkSourcesIgnored } from './lib/shared/markFrameworkSourcesIgnored.ts'
|
|
4
5
|
|
|
5
6
|
const CLIENT_ENTRY = new URL('./clientEntry.ts', import.meta.url).pathname
|
|
6
7
|
|
|
@@ -97,6 +98,19 @@ export async function build({
|
|
|
97
98
|
return false
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/* Ignore-list abide's own framework sources in every emitted map, so a debugger
|
|
102
|
+
collapses the mount-stack wall (scope/mountRange/runNode/…) and a stack trace
|
|
103
|
+
shows only authored `.abide`/`.ts` frames. Runs before the gzip step so the
|
|
104
|
+
`.gz` siblings compress the updated maps. */
|
|
105
|
+
await Promise.all(
|
|
106
|
+
result.outputs
|
|
107
|
+
.filter((output) => output.kind === 'sourcemap')
|
|
108
|
+
.map(async (output) => {
|
|
109
|
+
const map = await Bun.file(output.path).json()
|
|
110
|
+
await Bun.write(output.path, JSON.stringify(markFrameworkSourcesIgnored(map)))
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
|
|
100
114
|
// Dev skips the gzip siblings (paths still point into stagingDir here).
|
|
101
115
|
const compressedBytes = compress
|
|
102
116
|
? (
|
|
@@ -10,10 +10,12 @@ the var is absent (standalone `abide start`).
|
|
|
10
10
|
*/
|
|
11
11
|
export function exitWithParent(): void {
|
|
12
12
|
const parent = process.env.ABIDE_PARENT_PID
|
|
13
|
-
|
|
13
|
+
const parentPid = Number(parent)
|
|
14
|
+
/* A non-numeric value is truthy but coerces to NaN; without the integer guard
|
|
15
|
+
process.kill(NaN, 0) throws on the first tick and exits a healthy server. */
|
|
16
|
+
if (!parent || !Number.isInteger(parentPid)) {
|
|
14
17
|
return
|
|
15
18
|
}
|
|
16
|
-
const parentPid = Number(parent)
|
|
17
19
|
const timer = setInterval(() => {
|
|
18
20
|
try {
|
|
19
21
|
// Signal 0 sends nothing — it only probes existence, throwing when the
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
|
|
2
2
|
import { contentTypeOf } from '../../shared/contentTypeOf.ts'
|
|
3
|
+
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
3
4
|
import { HttpError } from '../../shared/HttpError.ts'
|
|
5
|
+
import { REF_JSON_HEADER } from '../../shared/REF_JSON_HEADER.ts'
|
|
4
6
|
import type { HttpVerb } from '../../shared/types/HttpVerb.ts'
|
|
5
7
|
import { error } from '../error.ts'
|
|
6
8
|
import { requestContext } from '../runtime/requestContext.ts'
|
|
@@ -92,7 +94,14 @@ export async function parseArgs(
|
|
|
92
94
|
if (contentType.includes('application/json')) {
|
|
93
95
|
const text = await bounded.text()
|
|
94
96
|
if (text !== '') {
|
|
95
|
-
|
|
97
|
+
/* abide's own client flags ref-json with REF_JSON_HEADER (restores
|
|
98
|
+
cycles/shared refs JSON can't carry); a non-abide client (curl, an
|
|
99
|
+
OpenAPI SDK) omits it and sends ordinary JSON — read with plain
|
|
100
|
+
JSON.parse, since the ref-json envelope is ambiguous with a 2-element
|
|
101
|
+
array body. */
|
|
102
|
+
body = bounded.headers.has(REF_JSON_HEADER)
|
|
103
|
+
? decodeRefJson(text)
|
|
104
|
+
: JSON.parse(text)
|
|
96
105
|
}
|
|
97
106
|
} else if (
|
|
98
107
|
contentType.includes('application/x-www-form-urlencoded') ||
|
|
@@ -40,5 +40,8 @@ export async function readBodyWithinLimit(request: Request, maxBytes: number): P
|
|
|
40
40
|
method: request.method,
|
|
41
41
|
headers: request.headers,
|
|
42
42
|
body: buffered,
|
|
43
|
+
/* Carry the inbound signal forward so a handler reading `request().signal` still
|
|
44
|
+
aborts on client disconnect — a fresh Request would otherwise drop it. */
|
|
45
|
+
signal: request.signal,
|
|
43
46
|
})
|
|
44
47
|
}
|
|
@@ -19,18 +19,16 @@ export function runWithVerbTimeout(
|
|
|
19
19
|
ms: number,
|
|
20
20
|
onTimeout: () => void,
|
|
21
21
|
): Promise<Response> {
|
|
22
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
23
22
|
let timedOut = false
|
|
24
|
-
const deadline =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
})
|
|
23
|
+
const deadline = Promise.withResolvers<Response>()
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
timedOut = true
|
|
26
|
+
onTimeout()
|
|
27
|
+
deadline.resolve(error(504, 'handler timeout'))
|
|
28
|
+
}, ms)
|
|
31
29
|
return (async () => {
|
|
32
30
|
try {
|
|
33
|
-
return await Promise.race([work, deadline])
|
|
31
|
+
return await Promise.race([work, deadline.promise])
|
|
34
32
|
} finally {
|
|
35
33
|
clearTimeout(timer)
|
|
36
34
|
if (timedOut) {
|
|
@@ -3,6 +3,21 @@ import type { RemoteFunction } from '../../../shared/types/RemoteFunction.ts'
|
|
|
3
3
|
import type { StandardSchemaV1 } from '../../../shared/types/StandardSchemaV1.ts'
|
|
4
4
|
import type { RemoteHandler } from './RemoteHandler.ts'
|
|
5
5
|
|
|
6
|
+
/*
|
|
7
|
+
Options every verb overload accepts: the OpenAPI 200 `outputSchema`, the
|
|
8
|
+
`clients` surface flags, the same-origin CSRF exemption (`crossOrigin`), the
|
|
9
|
+
pre-parse body-byte ceiling (`maxBodySize`), and the per-surface handler
|
|
10
|
+
`timeout` (ms). The schema-bearing overloads intersect this with their own
|
|
11
|
+
`inputSchema`/`filesSchema` members.
|
|
12
|
+
*/
|
|
13
|
+
type VerbBaseOpts = {
|
|
14
|
+
outputSchema?: StandardSchemaV1
|
|
15
|
+
clients?: Partial<ClientFlags>
|
|
16
|
+
crossOrigin?: boolean
|
|
17
|
+
maxBodySize?: number
|
|
18
|
+
timeout?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
/*
|
|
7
22
|
Shared signature for every verb helper (GET / POST / …). Three overloads:
|
|
8
23
|
|
|
@@ -50,36 +65,18 @@ export type VerbHelper = {
|
|
|
50
65
|
StandardSchemaV1.InferOutput<InputSchema> & StandardSchemaV1.InferOutput<FilesSchema>,
|
|
51
66
|
Return
|
|
52
67
|
>,
|
|
53
|
-
opts: {
|
|
68
|
+
opts: VerbBaseOpts & {
|
|
54
69
|
inputSchema: InputSchema
|
|
55
70
|
filesSchema: FilesSchema
|
|
56
|
-
outputSchema?: StandardSchemaV1
|
|
57
|
-
clients?: Partial<ClientFlags>
|
|
58
|
-
crossOrigin?: boolean
|
|
59
|
-
maxBodySize?: number
|
|
60
|
-
timeout?: number
|
|
61
71
|
},
|
|
62
72
|
): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
|
|
63
73
|
<Return = unknown, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
|
|
64
74
|
fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return>,
|
|
65
|
-
opts: {
|
|
66
|
-
inputSchema: InputSchema
|
|
67
|
-
outputSchema?: StandardSchemaV1
|
|
68
|
-
clients?: Partial<ClientFlags>
|
|
69
|
-
crossOrigin?: boolean
|
|
70
|
-
maxBodySize?: number
|
|
71
|
-
timeout?: number
|
|
72
|
-
},
|
|
75
|
+
opts: VerbBaseOpts & { inputSchema: InputSchema },
|
|
73
76
|
): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
|
|
74
77
|
<Args = undefined, Return = unknown>(
|
|
75
78
|
fn: RemoteHandler<Args, Return>,
|
|
76
|
-
opts:
|
|
77
|
-
outputSchema?: StandardSchemaV1
|
|
78
|
-
clients?: Partial<ClientFlags>
|
|
79
|
-
crossOrigin?: boolean
|
|
80
|
-
maxBodySize?: number
|
|
81
|
-
timeout?: number
|
|
82
|
-
},
|
|
79
|
+
opts: VerbBaseOpts,
|
|
83
80
|
): RemoteFunction<Args, Return>
|
|
84
81
|
<Args = undefined, Return = unknown>(
|
|
85
82
|
fn: RemoteHandler<Args, Return>,
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/*
|
|
2
2
|
The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
|
|
3
3
|
`<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
the
|
|
9
|
-
|
|
4
|
+
stores its raw ref-json text into `window.__abideResume` (the resume manifest hydration
|
|
5
|
+
reads, decoding each entry at read time) and swaps the resolved markup into the matching
|
|
6
|
+
`<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary — so the pending shell paints
|
|
7
|
+
instantly and each value lands as it arrives, before the client bundle even loads. It
|
|
8
|
+
stores the text undecoded precisely because it's vanilla and self-contained — no framework
|
|
9
|
+
runtime, hence no ref-json decoder; the bundle decodes on read. Minified to one line so it
|
|
10
|
+
inlines cheaply ahead of the document body.
|
|
10
11
|
*/
|
|
11
12
|
export const SSR_SWAP_SCRIPT =
|
|
12
13
|
"function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
|
|
13
14
|
"var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
|
|
14
|
-
"if(p&&p.nodeName==='SCRIPT'){
|
|
15
|
+
"if(p&&p.nodeName==='SCRIPT'){(window.__abideResume=window.__abideResume||{})[id]=p.textContent||'';p.remove();}" +
|
|
15
16
|
"while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
|
|
16
17
|
"if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
|
|
17
18
|
"while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
|
|
@@ -34,13 +34,19 @@ export async function createAppAssetServer({
|
|
|
34
34
|
}): Promise<(req: Request, url: URL) => Promise<Response>> {
|
|
35
35
|
// Per-pathname asset header bundles, hashed-chunk-aware Cache-Control.
|
|
36
36
|
const headersForAsset = createAssetHeaderCache(cacheControlForAsset)
|
|
37
|
-
|
|
37
|
+
/* Boot snapshot of every disk `_app` path, mirroring createPublicAssetServer: the
|
|
38
|
+
header cache keys on (request-controlled) pathnames, so building bundles for junk
|
|
39
|
+
`/_app/*` probes would grow it without bound. A rebuild restarts the server (same
|
|
40
|
+
restart `bun --watch` triggers on a code change), re-snapshotting. */
|
|
41
|
+
const diskPaths = assets
|
|
38
42
|
? new Set<string>()
|
|
39
|
-
: await globToPathSet(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
: await globToPathSet(`${distDir}/_app`, '**/*', (file) => `/_app/${file}`)
|
|
44
|
+
/* Derive the precompressed `.gz` sibling set from the single tree scan (it already
|
|
45
|
+
includes the `.gz` files) — a gzip-capable client gets those bytes without
|
|
46
|
+
on-the-fly compression. Keyed by the base path (the `.gz` suffix stripped). */
|
|
47
|
+
const diskGzipPaths = new Set(
|
|
48
|
+
[...diskPaths].filter((path) => path.endsWith('.gz')).map((path) => path.slice(0, -3)),
|
|
49
|
+
)
|
|
44
50
|
|
|
45
51
|
return async function serveAppAsset(req, url) {
|
|
46
52
|
if (containsTraversal(req.url)) {
|
|
@@ -49,8 +55,20 @@ export async function createAppAssetServer({
|
|
|
49
55
|
headers: { 'Content-Type': TEXT_PLAIN, 'Cache-Control': NO_STORE },
|
|
50
56
|
})
|
|
51
57
|
}
|
|
58
|
+
/* Embed map and gzip Set hold decoded filesystem names; url.pathname stays
|
|
59
|
+
percent-encoded — decode so a chunk/asset with a non-ASCII name matches and
|
|
60
|
+
Bun.file opens the real path, not a literal `%xx` name. */
|
|
61
|
+
let assetPath: string
|
|
62
|
+
try {
|
|
63
|
+
assetPath = decodeURIComponent(url.pathname)
|
|
64
|
+
} catch {
|
|
65
|
+
return new Response('Not Found', {
|
|
66
|
+
status: 404,
|
|
67
|
+
headers: { 'Content-Type': TEXT_PLAIN, 'Cache-Control': NO_STORE },
|
|
68
|
+
})
|
|
69
|
+
}
|
|
52
70
|
if (assets) {
|
|
53
|
-
const compressed = assets[
|
|
71
|
+
const compressed = assets[assetPath]
|
|
54
72
|
/* Miss-check before header work: the header cache keys on
|
|
55
73
|
(request-controlled) pathnames, so building bundles for junk
|
|
56
74
|
`/_app/*` probes would grow it without bound. */
|
|
@@ -63,12 +81,20 @@ export async function createAppAssetServer({
|
|
|
63
81
|
return respondWithEmbeddedAsset(
|
|
64
82
|
compressed,
|
|
65
83
|
acceptsGzip(req),
|
|
66
|
-
headersForAsset(
|
|
84
|
+
headersForAsset(assetPath),
|
|
67
85
|
)
|
|
68
86
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
87
|
+
/* Miss-check before header work so probes for non-existent chunks can't grow the
|
|
88
|
+
header cache (the embed branch above guards the same way). */
|
|
89
|
+
if (!diskPaths.has(assetPath)) {
|
|
90
|
+
return new Response('Not Found', {
|
|
91
|
+
status: 404,
|
|
92
|
+
headers: { 'Content-Type': TEXT_PLAIN, 'Cache-Control': NO_STORE },
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
const { base: baseHeaders, gzip: gzipHeaders } = headersForAsset(assetPath)
|
|
96
|
+
const diskPath = distDir + assetPath
|
|
97
|
+
if (acceptsGzip(req) && diskGzipPaths.has(assetPath)) {
|
|
72
98
|
return new Response(Bun.file(`${diskPath}.gz`), { headers: gzipHeaders })
|
|
73
99
|
}
|
|
74
100
|
return new Response(Bun.file(diskPath), { headers: baseHeaders })
|
|
@@ -50,18 +50,27 @@ export async function createPublicAssetServer({
|
|
|
50
50
|
if (containsTraversal(req.url)) {
|
|
51
51
|
return undefined
|
|
52
52
|
}
|
|
53
|
+
/* Both the embed map and the disk Set hold decoded filesystem names, but
|
|
54
|
+
url.pathname stays percent-encoded — decode it so a file whose name has a
|
|
55
|
+
space or non-ASCII char matches. A malformed escape can't name a real file. */
|
|
56
|
+
let assetPath: string
|
|
57
|
+
try {
|
|
58
|
+
assetPath = decodeURIComponent(url.pathname)
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
53
62
|
if (publicAssets) {
|
|
54
|
-
const compressed = publicAssets[
|
|
63
|
+
const compressed = publicAssets[assetPath]
|
|
55
64
|
if (!compressed) {
|
|
56
65
|
return undefined
|
|
57
66
|
}
|
|
58
|
-
return respondWithEmbeddedAsset(compressed, acceptsGzip(req), headersFor(
|
|
67
|
+
return respondWithEmbeddedAsset(compressed, acceptsGzip(req), headersFor(assetPath))
|
|
59
68
|
}
|
|
60
|
-
if (!diskPaths.has(
|
|
69
|
+
if (!diskPaths.has(assetPath)) {
|
|
61
70
|
return undefined
|
|
62
71
|
}
|
|
63
|
-
return new Response(Bun.file(publicDir +
|
|
64
|
-
headers: headersFor(
|
|
72
|
+
return new Response(Bun.file(publicDir + assetPath), {
|
|
73
|
+
headers: headersFor(assetPath).base,
|
|
65
74
|
})
|
|
66
75
|
}
|
|
67
76
|
}
|
|
@@ -3,16 +3,17 @@ import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
|
|
|
3
3
|
import { formatTraceparent } from '../../shared/formatTraceparent.ts'
|
|
4
4
|
import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
|
|
5
5
|
import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
|
|
6
|
+
import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
|
|
6
7
|
import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
|
|
7
8
|
import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
|
|
8
9
|
import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
|
|
9
10
|
import { renderChain } from '../../ui/renderChain.ts'
|
|
10
11
|
import { renderToStream } from '../../ui/renderToStream.ts'
|
|
12
|
+
import { resumeSeedScript } from '../../ui/resumeSeedScript.ts'
|
|
11
13
|
import type { UiComponent } from '../../ui/runtime/types/UiComponent.ts'
|
|
12
14
|
import { pageUrlFromStore } from './pageUrlFromStore.ts'
|
|
13
15
|
import { SSR_SWAP_SCRIPT } from './SSR_SWAP_SCRIPT.ts'
|
|
14
16
|
import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
|
|
15
|
-
import { safeJsonForScript } from './safeJsonForScript.ts'
|
|
16
17
|
import { serializeCacheSnapshot } from './serializeCacheSnapshot.ts'
|
|
17
18
|
import { streamCacheResolutions } from './streamCacheResolutions.ts'
|
|
18
19
|
import type { RequestStore } from './types/RequestStore.ts'
|
|
@@ -33,11 +34,11 @@ function wantsJson(request: Request): boolean {
|
|
|
33
34
|
const CACHE_RESOLVE_SCRIPT =
|
|
34
35
|
'window.__abideResolve=function(r){(window.__abideResumeCache=window.__abideResumeCache||[]).push(r)}'
|
|
35
36
|
|
|
36
|
-
/* One streamed cache resolution as an inline `__abideResolve(...)` call.
|
|
37
|
-
|
|
37
|
+
/* One streamed cache resolution as an inline `__abideResolve(...)` call. Encoded via
|
|
38
|
+
safeJsonForScript so `<`, `-->`, and U+2028/U+2029 can't close the script early or
|
|
39
|
+
parse as line terminators — the same escaping the page's other inline scripts use. */
|
|
38
40
|
function resolveChunk(resolution: StreamedResolution): string {
|
|
39
|
-
|
|
40
|
-
return `<script>__abideResolve(${payload})</script>`
|
|
41
|
+
return `<script>__abideResolve(${safeJsonForScript(resolution)})</script>`
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
/*
|
|
@@ -168,19 +169,21 @@ export function createUiPageRenderer({
|
|
|
168
169
|
...chainKeys.map((key) => layouts[key]?.().then((module) => module.default)),
|
|
169
170
|
loadPage().then((module) => module.default),
|
|
170
171
|
])
|
|
171
|
-
const ssr = renderChain(
|
|
172
|
+
const ssr = await renderChain(
|
|
172
173
|
views.filter((view): view is UiComponent => view !== undefined),
|
|
173
174
|
params,
|
|
174
175
|
)
|
|
175
176
|
|
|
176
|
-
/* Snapshot the cache settled by render-return — top-level `await` reads
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
seeded after the drain (
|
|
177
|
+
/* Snapshot the cache settled by render-return — top-level `await` reads and blocking
|
|
178
|
+
`{#await … then}` reads (the async render awaited them inline) — into __SSR__. A
|
|
179
|
+
STREAMING `{#await}` read is NOT here: its expression is a thunk `renderToStream`
|
|
180
|
+
runs lazily, so its entry is created mid-stream and seeded after the drain (below). */
|
|
180
181
|
const inline = await serializeCacheSnapshot(store.cache)
|
|
181
182
|
const inlinedKeys = new Set(inline.map((entry) => entry.key))
|
|
182
183
|
|
|
183
|
-
/* No await blocks →
|
|
184
|
+
/* No STREAMING await blocks → ship buffered. Blocking `{#await … then}` blocks
|
|
185
|
+
rendered inline (their markup is already in `ssr.html`); seed their resolved
|
|
186
|
+
values so hydration adopts them without a refetch. */
|
|
184
187
|
if (ssr.awaits.length === 0) {
|
|
185
188
|
const html = injectRoutePreloads(
|
|
186
189
|
shell.replace(SSR_MARKER, (_match, key: string) =>
|
|
@@ -190,7 +193,7 @@ export function createUiPageRenderer({
|
|
|
190
193
|
)
|
|
191
194
|
const withState = html.replace(
|
|
192
195
|
'</body>',
|
|
193
|
-
`${await stateTag(routeUrl, params, store, inline)}</body>`,
|
|
196
|
+
`${resumeSeedScript(ssr.resume)}${await stateTag(routeUrl, params, store, inline)}</body>`,
|
|
194
197
|
)
|
|
195
198
|
return new Response(withState, {
|
|
196
199
|
headers: {
|
|
@@ -219,38 +222,46 @@ export function createUiPageRenderer({
|
|
|
219
222
|
return new Response(
|
|
220
223
|
new ReadableStream({
|
|
221
224
|
async start(controller) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
/* The shell `before` already flushed, so a mid-stream render rejection
|
|
226
|
+
(a streaming `{#await}` with no `:catch`) can't become a 500 — surface it
|
|
227
|
+
on the stream (`controller.error`) so the response terminates legibly
|
|
228
|
+
instead of leaking an unhandledrejection (process-fatal under Bun). */
|
|
229
|
+
try {
|
|
230
|
+
controller.enqueue(encoder.encode(before ?? ''))
|
|
231
|
+
let first = true
|
|
232
|
+
for await (const chunk of renderToStream(() => ssr)) {
|
|
233
|
+
controller.enqueue(
|
|
234
|
+
encoder.encode(
|
|
235
|
+
first ? chunk : `${chunk}<script>__abideSwap()</script>`,
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
first = false
|
|
239
|
+
}
|
|
240
|
+
/* The {#await} reads created (and settled) their cache entries DURING the
|
|
241
|
+
stream — the await expression is a thunk `renderToStream` ran lazily —
|
|
242
|
+
so they missed the render-return snapshot. Drain them now: each lands an
|
|
243
|
+
inline `__abideResolve(...)` chunk (a warm snapshot, or a `{ key, miss }`
|
|
244
|
+
marker for an unshippable body → live refetch) before the deferred
|
|
245
|
+
bundle, so startClient seeds the store and the block's subscription read
|
|
246
|
+
is warm. Skip keys already shipped inline in __SSR__. */
|
|
247
|
+
const streamedEntries = Array.from(store.cache.entries.values()).filter(
|
|
248
|
+
(entry: CacheEntry) =>
|
|
249
|
+
entry.settled === true &&
|
|
250
|
+
entry.request !== undefined &&
|
|
251
|
+
isReplayableMethod(entry.request.method.toUpperCase()) &&
|
|
252
|
+
!inlinedKeys.has(entry.key),
|
|
229
253
|
)
|
|
230
|
-
|
|
254
|
+
for await (const resolution of streamCacheResolutions(
|
|
255
|
+
store.cache,
|
|
256
|
+
streamedEntries,
|
|
257
|
+
)) {
|
|
258
|
+
controller.enqueue(encoder.encode(resolveChunk(resolution)))
|
|
259
|
+
}
|
|
260
|
+
controller.enqueue(encoder.encode(after ?? ''))
|
|
261
|
+
controller.close()
|
|
262
|
+
} catch (streamError) {
|
|
263
|
+
controller.error(streamError)
|
|
231
264
|
}
|
|
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
|
-
}
|
|
252
|
-
controller.enqueue(encoder.encode(after ?? ''))
|
|
253
|
-
controller.close()
|
|
254
265
|
},
|
|
255
266
|
}),
|
|
256
267
|
{
|