@abide/abide 0.52.0 → 0.54.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 +25 -12
- package/CHANGELOG.md +82 -0
- package/README.md +8 -6
- package/package.json +3 -2
- package/src/checkAbide.ts +1 -1
- package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +26 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
- package/src/lib/server/runtime/types/RequestStore.ts +9 -0
- package/src/lib/server/sockets/amendFamilyEntry.ts +39 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
- package/src/lib/server/sockets/defineSocket.ts +11 -1
- package/src/lib/shared/AMEND_TOPIC_PREFIX.ts +9 -0
- package/src/lib/shared/SOCKET_SEED.ts +13 -0
- package/src/lib/shared/amend.ts +48 -0
- package/src/lib/shared/amendBroadcastSlot.ts +19 -0
- package/src/lib/shared/applyAmendLocally.ts +20 -0
- package/src/lib/shared/attachRpcSelectorMethods.ts +5 -5
- package/src/lib/shared/buildSocketOverChannel.ts +9 -2
- package/src/lib/shared/cache.ts +35 -10
- package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
- package/src/lib/shared/createCacheStore.ts +8 -0
- package/src/lib/shared/peek.ts +1 -1
- package/src/lib/shared/socketTailsSlot.ts +13 -0
- package/src/lib/shared/types/AmendApply.ts +15 -0
- package/src/lib/shared/types/CacheReaderHook.ts +11 -0
- package/src/lib/shared/types/RemoteFunction.ts +30 -19
- package/src/lib/shared/types/SocketTails.ts +13 -0
- package/src/lib/shared/types/SsrPayload.ts +6 -0
- package/src/lib/test/createTestApp.ts +6 -0
- package/src/lib/ui/amendReaderHook.ts +125 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/analyzeComponent.ts +2 -0
- package/src/lib/ui/compile/cachedShadowProgram.ts +38 -0
- package/src/lib/ui/compile/compileComponent.ts +10 -1
- package/src/lib/ui/compile/compileSSR.ts +12 -0
- package/src/lib/ui/compile/compileShadow.ts +37 -34
- package/src/lib/ui/compile/desugarSignals.ts +127 -34
- package/src/lib/ui/compile/generateBuild.ts +5 -1
- package/src/lib/ui/compile/hasTopLevelAwait.ts +28 -0
- package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
- package/src/lib/ui/compile/isFunctionScopeBoundary.ts +20 -0
- package/src/lib/ui/compile/liftAsyncSubExpressions.ts +2 -2
- package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
- package/src/lib/ui/compile/lowerContext.ts +4 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
- package/src/lib/ui/compile/lowerScript.ts +11 -1
- package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +36 -24
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +9 -14
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
- package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
- package/src/lib/ui/dom/appendText.ts +29 -8
- package/src/lib/ui/dom/appendTextAt.ts +21 -4
- package/src/lib/ui/dom/attr.ts +16 -1
- package/src/lib/ui/dom/awaitBlock.ts +34 -13
- package/src/lib/ui/dom/each.ts +10 -2
- package/src/lib/ui/dom/eachAsync.ts +9 -1
- package/src/lib/ui/dom/readCellBlocking.ts +29 -0
- package/src/lib/ui/dom/readTextOrSuspend.ts +21 -0
- package/src/lib/ui/dom/spreadAttrs.ts +47 -1
- package/src/lib/ui/dom/switchBlock.ts +19 -2
- package/src/lib/ui/dom/tryBlock.ts +10 -0
- package/src/lib/ui/dom/when.ts +19 -1
- package/src/lib/ui/dom/withSuspense.ts +22 -0
- package/src/lib/ui/router.ts +8 -0
- package/src/lib/ui/runtime/SuspenseSignal.ts +24 -0
- package/src/lib/ui/runtime/createDoc.ts +5 -1
- package/src/lib/ui/runtime/flushEffects.ts +18 -10
- package/src/lib/ui/runtime/restoreWarmSeeds.ts +20 -0
- package/src/lib/ui/runtime/types/Cell.ts +4 -2
- package/src/lib/ui/runtime/types/Doc.ts +3 -1
- package/src/lib/ui/runtime/warmSeedBackup.ts +10 -0
- package/src/lib/ui/socketProxy.ts +17 -1
- package/src/lib/ui/startClient.ts +30 -0
- package/src/lib/ui/types/Scope.ts +1 -1
- package/src/lib/ui/watch.ts +25 -7
- package/src/serverEntry.ts +15 -0
- package/src/lib/shared/patch.ts +0 -41
- package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +0 -19
package/AGENTS.md
CHANGED
|
@@ -100,11 +100,11 @@ For tests, add `preload = ["@abide/abide/preload"]` under `[test]` in
|
|
|
100
100
|
- **Consume forms** — the **bare call `fn(args)` is the smart read** (cached,
|
|
101
101
|
coalesced, reactive; in-process during SSR and baked into the HTML for warm
|
|
102
102
|
hydration — there is no `cache()` wrapper). Around it: `fn.raw(args, opts?)`
|
|
103
|
-
(raw `Response`), `fn.refresh` / `fn.invalidate` / `fn.
|
|
103
|
+
(raw `Response`), `fn.refresh` / `fn.invalidate` / `fn.amend` / `fn.peek` /
|
|
104
104
|
`fn.pending` / `fn.refreshing` / `fn.error` / `fn.watch` / `fn.isError`. A
|
|
105
105
|
streaming handler (`jsonl`/`sse`) makes the bare call return a
|
|
106
106
|
`NamedAsyncIterable<Frame>` (the iterable IS the value; `await`-ing it is a
|
|
107
|
-
compile error; `
|
|
107
|
+
compile error; `amend` is dropped).
|
|
108
108
|
- **Socket** (`src/server/sockets/<name>.ts`) — `export const x =
|
|
109
109
|
socket<T>(opts?)`. `opts`: `tail` (retention depth; defaults to keeping the last
|
|
110
110
|
frame), `ttl` (per-frame eviction ms), `clientPublish` (accept `pub` frames from
|
|
@@ -176,13 +176,19 @@ Mustache `{#…}` blocks, not `<template>`. The rendered blocks are
|
|
|
176
176
|
| `{#try}` / `{:catch}` / `{:finally}` / `{/try}` | Render error boundary. |
|
|
177
177
|
| `{#snippet name(args)}…{/snippet}` | Reusable builder; called `{name(args)}`. |
|
|
178
178
|
|
|
179
|
-
Async reads have no ceremony
|
|
180
|
-
(`{getMessages({ limit })}`)
|
|
181
|
-
`undefined` while pending (
|
|
182
|
-
`?.`, `{#if}`, and
|
|
183
|
-
probes. `{await fn()}`
|
|
184
|
-
|
|
185
|
-
the
|
|
179
|
+
Async reads have no ceremony, and `await` is the marker that picks the mode
|
|
180
|
+
(ADR-0042). A **bare call in an interpolation** (`{getMessages({ limit })}`)
|
|
181
|
+
*streams*: a peek that reads `undefined` while pending (typed `T | undefined`,
|
|
182
|
+
auto-streaming on SSR), composing with `?? fallback`, `?.`, `{#if}`, and
|
|
183
|
+
attributes; pair it with `.pending(...)` / `.error(...)` probes. `{await fn()}`
|
|
184
|
+
means *resolved*: the server blocks the flush AND the client **suspends** the
|
|
185
|
+
reading region (renders nothing) until it settles, so the value is never pending
|
|
186
|
+
at the read — `{(await fn()).field}` needs no `?.` and never derefs a pending
|
|
187
|
+
`undefined`. A refetch never re-suspends (stale-while-revalidate); warm-seed keeps
|
|
188
|
+
a hydrating read `refreshing()`, so no flash. (Consequently a no-`await` `async`
|
|
189
|
+
thunk — `computed(async () => getFoo())` — is a *streaming* cell, not blocking;
|
|
190
|
+
`await` is the sole blocking marker.) `{#await}` is the opt-in for branch structure
|
|
191
|
+
and `{:then}` narrowing — not the default.
|
|
186
192
|
|
|
187
193
|
Components are capitalised tags; the content nested in a component renders where
|
|
188
194
|
it calls `{children()}` (`{#if children}{children()}{:else}…{/if}` is the
|
|
@@ -285,9 +291,13 @@ from the server.
|
|
|
285
291
|
- `abide/shared/invalidate` — `invalidate(selector?, args?)`: the drop verb — drop
|
|
286
292
|
matching cached reads so the next read reloads lazily. Selector grammar:
|
|
287
293
|
`(fn, args)` / `(fn)` / `({ tags })` / `()`.
|
|
288
|
-
- `abide/shared/
|
|
289
|
-
|
|
290
|
-
optimistic-update / socket-frame primitive
|
|
294
|
+
- `abide/shared/amend` — `amend(fn, args?, value | updater)` / `amend({ tags }, …)`:
|
|
295
|
+
set the retained value of matching reads in place (a concrete `Return`) or transform
|
|
296
|
+
it (`updater`), reactive, no network — the optimistic-update / socket-frame primitive.
|
|
297
|
+
Isomorphic like `invalidate`/`refresh` (ADR-0043): the value form applies locally on
|
|
298
|
+
the client but from the server broadcasts the keyed value to every client reading that
|
|
299
|
+
exact call (a push-refresh — zero refetches); the updater form is client-local (a
|
|
300
|
+
closure can't broadcast). For a no-input rpc the args collapse (`fn.amend(value)`).
|
|
291
301
|
- `abide/shared/refresh` — `refresh(selector?, args?)`: refetch matching reads now,
|
|
292
302
|
keeping the stale value visible (`refreshing()` true) until the fresh one swaps in.
|
|
293
303
|
|
|
@@ -444,6 +454,9 @@ Compiler-emitted; an author never imports these directly.
|
|
|
444
454
|
read, with legible authored-scope errors.
|
|
445
455
|
- `abide/ui/dom/readCell` — unified read of a `computed`/`linked`/derive reference
|
|
446
456
|
(async → throwing peek, function → call, sync → `.value`).
|
|
457
|
+
- `abide/ui/dom/readCellBlocking` — the blocking (`await`) variant of `readCell`: on a
|
|
458
|
+
pending cell it throws a suspense signal so the reading region withholds until the
|
|
459
|
+
value resolves, rather than reading `undefined` (client template lowering; ADR-0042).
|
|
447
460
|
- `abide/ui/dom/writeCell` — unified write of a `linked` reference from an author
|
|
448
461
|
assignment (async → `.set`, sync → `.value =`).
|
|
449
462
|
- `abide/ui/dom/cellPending` — whether a control-flow subject is a still-loading async
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.54.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7cd75d7: `amend` is now isomorphic like `invalidate`/`refresh` (ADR-0043): called on the client it mutates the local cache, but called on the **server** with a concrete value it broadcasts that value to every client reading the call — a push-refresh with zero refetches.
|
|
8
|
+
|
|
9
|
+
`amend` gains a value form alongside the updater form. The last argument may be a concrete `Return` (set it) or an updater `(current) => Return` (transform it); for a no-input rpc the args collapse (`getFoo.amend(value)`), matching how `fn()` vs `fn(args)` already read. From the server, only the value form broadcasts — an updater is a closure with no wire form and throws, and the value must target a keyed remote call (a producer/`{ tags }` selector throws). Delivery is confined to clients already reading that exact call (over a per-call reserved `__abide/amend/<key>` topic), so a pushed value reaches only readers already authorized for that key; a dropped frame heals on the next reconnect refresh. The updater form and client-side `amend` are unchanged.
|
|
10
|
+
|
|
11
|
+
- 42354ba: A bare `state.computed(getFoo())` over an rpc now unwraps to the resolved value, so `{foo?.field}` renders and reactively updates — the pattern the docs and examples already teach.
|
|
12
|
+
|
|
13
|
+
Previously a no-`await` `state.computed(getFoo())` was classified as an opaque `Computed<Promise>` (ADR-0023 routing), so a template read like `{foo?.messages}` read `.messages` off a Promise and always showed `undefined`, with no cache reactivity reaching it. Now `getFoo()` and `await getFoo()` inside `state.computed(...)` differ only by SSR tier and are both unwrapped value cells (ADR-0045):
|
|
14
|
+
|
|
15
|
+
- `state.computed(getFoo())` — **streaming**: SSR ships the pending branch, the client resolves after hydration.
|
|
16
|
+
- `state.computed(await getFoo())` — **blocking**: SSR waits and bakes the value into the HTML (warm hydrate, never pending).
|
|
17
|
+
|
|
18
|
+
**Breaking behavior change:** `foo` is now a reactive value cell in both forms, never a `Promise`. Code that treated the bare binding as a thenable — `state.computed(getFoo()).then(…)` or `await foo` — no longer gets the fetch promise (it gets the unwrapped value, or `undefined` while pending). For an imperative one-off, call `await getFoo()` directly (cached/coalesced); to render with an await block use `{#await getFoo()}` inline; for a genuinely opaque `Computed<Promise>` binding use an explicit thunk, `state.computed(() => getFoo())`. `{#await foo}` on a bare computed still works (it reads the async-cell subject reactively).
|
|
19
|
+
|
|
20
|
+
- 7bc2bfb: Rename the cache-mutation verb `patch` → `amend`. The word "patch" collided with the HTTP `PATCH` method (`abide/server/PATCH`) and the internal DOM-tree `Patch` type; `amend` names the same intent — mutate the retained value of matching cached reads in place, reactive, no network — without the overload.
|
|
21
|
+
|
|
22
|
+
**Breaking:**
|
|
23
|
+
|
|
24
|
+
- **`abide/shared/patch` is now `abide/shared/amend`.** The standalone `patch(fn, args?, updater)` / `patch({ tags }, updater)` is now `amend(...)` with identical signatures.
|
|
25
|
+
- **The rpc selector method `fn.patch(...)` is now `fn.amend(...)`.** `fn.amend(args?, updater)` ≡ `amend(fn, args, updater)`. Still fetch-only (omitted for a streaming rpc).
|
|
26
|
+
|
|
27
|
+
Mechanical rename — no behavior change. Update imports (`abide/shared/patch` → `abide/shared/amend`) and call sites (`.patch(` → `.amend(`). The HTTP `PATCH` method export is unaffected.
|
|
28
|
+
|
|
29
|
+
- 88959d7: `watch(source, handler)` now runs the handler untracked — the named source(s) are the sole triggers (ADR-0044).
|
|
30
|
+
|
|
31
|
+
Previously the two cell forms (`watch(cell, h)` / `watch([a, b], h)`) wrapped the handler in an effect that captured _every_ reactive read in the handler body, so `watch(foo, () => { id = bar.id })` silently re-ran on `bar.id` as well as `foo`. The handler is now a sink: reactive reads inside it no longer become extra triggers. This makes the cell forms match the socket and rpc forms, whose handlers already ran outside the tracking window. Only the bare `watch(() => …)` binding form still auto-tracks everything it reads. If you relied on a handler-body read re-triggering the watch, name it as a source instead (`watch([foo, bar], …)`) or use the auto-track form (`watch(() => { id = bar.id })`).
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- dc4ddfc: normalize pre-existing format drift ([`757f142`](https://github.com/briancray/abide/commit/757f1420b4e37064da1b5048d2f5d3b1a556b6fe))
|
|
36
|
+
- 11965e9: Fix the JS semantics of reactive-state mutation expressions so `{ }` code behaves like plain TypeScript.
|
|
37
|
+
|
|
38
|
+
- **Logical assignments now short-circuit.** `count ??= 5` / `x ||= v` / `x &&= v` on reactive state (`state` slot or `linked` cell) now write **only when the guard passes** — `count ??= 5` no longer fires a redundant patch (and reseed) when `count` is already non-nullish. Previously every logical assignment lowered to an unconditional write.
|
|
39
|
+
- **Postfix `x++` / `x--` now evaluates to the previous value** (and prefix `++x` to the new value), matching JS. Previously postfix returned the stepped value.
|
|
40
|
+
- As a consequence, reactive writes now evaluate to the written value, so chained assignment (`a = b = count`) through a state slot works too.
|
|
41
|
+
|
|
42
|
+
Internally, `Cell.set` and `Doc.replace` now return the written value, and the two lowering sites share `lowerCompoundAssignment` / `lowerUpdateExpression` helpers so they can't drift.
|
|
43
|
+
|
|
44
|
+
- 9773885: Warn when the type program for a project can't be built, instead of silently disabling async detection.
|
|
45
|
+
|
|
46
|
+
The type-directed async lowering (deciding whether `{getFoo()}` is a promise/stream and streaming it) depends on a warm shadow program built once per project root. If that program failed to build (e.g. a broken `tsconfig`), the failure was swallowed and **every** async interpolation in the project silently degraded to plain text — a bare `{getFoo()}` shipping as the literal `[object Promise]` with no signal. It now emits a one-time `console.warn` per root naming the cause, so a build with async detection disabled is visible. Behavior is otherwise unchanged (the build still never breaks on a program-build failure).
|
|
47
|
+
|
|
48
|
+
- 0d9316e: `watch(cell.foo, handler)` is now a compile error instead of a silent no-op.
|
|
49
|
+
|
|
50
|
+
abide has no per-property cells — a member access on a cell is read once as a plain value, so `watch(s.foo, handler)` subscribed to nothing and the handler silently never fired. It was only flagged by a `console.warn` (easily lost in build output). It now fails compilation with a message pointing at the corrective forms: wrap it in a thunk (`watch(() => …(s.foo))`) or watch the whole cell (`watch(s, v => …)`). A member of a cell is never itself a cell, so this can never reject a valid `watch`.
|
|
51
|
+
|
|
52
|
+
- 66e5d3b: Fix a hydration desync (and the dead page it could cascade into) when a render reads a `tail`-retaining socket's `peek(socket)`. At SSR the server holds the socket's retained frame so `peek` returns it; a not-yet-connected client's `peek` returned `undefined`, so any markup derived from it (e.g. `state.computed(() => peek(refreshStatus))`) disagreed with the server HTML and tripped a hydration mismatch — which discards the server markup and cold-renders.
|
|
53
|
+
|
|
54
|
+
Sockets now warm-seed their retained frame across SSR→client like async cells do: the server records each frame read via `peek(socket)` during the render, ships it in `__SSR__.sockets` keyed by socket name, and the client seeds the socket's latest frame before mount so `peek(socket)` returns the same value the server rendered. (Contrast `cache.peek`, which withholds on the client because the server materializes no cache value — sockets carry a real server value forward, so they seed instead.)
|
|
55
|
+
|
|
56
|
+
Also hardens the hydration-desync recovery: the discard→cold-rebuild path now re-primes the async-cell and doc-state warm-seed manifests the failed hydration pass consumed, so the cold rebuild re-adopts the SSR-resolved values instead of refetching. A cold refetch left blocking `await` cells pending, and a top-level blocking read sits in no suspense region, so its `SuspenseSignal` escaped the rebuild and killed the mount ("page threw while mounting"). The rebuild now reads settled — a live page with real data, no loading flash.
|
|
57
|
+
|
|
58
|
+
No public API change.
|
|
59
|
+
|
|
60
|
+
## 0.53.0
|
|
61
|
+
|
|
62
|
+
### Minor Changes
|
|
63
|
+
|
|
64
|
+
- shadow narrows streaming cells to T|undefined + D7 barrier tripwire (ADR-0042 D5.2/D7) ([`034a384`](https://github.com/briancray/abide/commit/034a384bdfb5b482f818de757e10c2e2e88ac52c))
|
|
65
|
+
- suspend interpolations/attributes on a pending blocking read (ADR-0042) ([`88da27c`](https://github.com/briancray/abide/commit/88da27c998666b7cca9149effd5f171f3adca8d1))
|
|
66
|
+
- flip async-thunk blocking marker + emit $$readCellBlocking on the client (ADR-0042 D5/D6) ([`a3468fe`](https://github.com/briancray/abide/commit/a3468feacbb67ac9cd337ffa2e220b10af309324))
|
|
67
|
+
- dormant client-suspense foundation for await-means-resolved (ADR-0042) ([`b5f6104`](https://github.com/briancray/abide/commit/b5f6104c41ea1d57d3347ae007cdb1b316a09784))
|
|
68
|
+
- complete the client suspense withhold surface across all template effects (ADR-0042) ([`c5db314`](https://github.com/briancray/abide/commit/c5db314fa3e0ce18294f609be5fb43e5128970d0))
|
|
69
|
+
- fa7bfed: `await` now means "resolved" for a cell and its dependents, with the client suspending to mirror the server flush barrier (ADR-0042). A blocking `await` read (`{await foo()}`, `const x = state.computed(await …)`, `{#if await …}`) reads as a resolved value on both sides: the server already blocks the flush until it settles, and the client now **withholds the reading region** (interpolation, attribute, `{#if}`/`{#each}`/`{#switch}` block) until the value resolves rather than rendering against a pending `undefined`. So `{sources.length}` on an `await` binding no longer needs `?.` and no longer crashes while pending — the region simply shows nothing until it settles, then reveals. After the first resolution a refetch never re-suspends (stale-while-revalidate); on hydrate the warm-seed keeps the read `refreshing()`, never `pending()`, so there is no flash.
|
|
70
|
+
|
|
71
|
+
**Breaking:**
|
|
72
|
+
|
|
73
|
+
- **The async modifier alone is no longer a blocking marker — `await` is.** `computed(async () => getFoo())` (an `async` thunk with no top-level `await`) now creates a **streaming** cell (ships pending, resolves on the client) instead of a blocking one. Add `await` — `computed(async () => await getFoo())` — to keep the blocking/inline-SSR behavior. `computed(await …)` and `computed(async () => await …)` are unchanged (blocking).
|
|
74
|
+
- **A streaming (no-`await`) async `computed`/`linked` now types as `T | undefined`.** Its read is honestly pending-able, so guard it with `?.`/`??` (matching the runtime). A blocking `await` binding stays `T`.
|
|
75
|
+
|
|
76
|
+
No new public API — the change is in how `await` lowers and types.
|
|
77
|
+
|
|
78
|
+
### Patch Changes
|
|
79
|
+
|
|
80
|
+
- document await-means-resolved (ADR-0042) ([`16ec341`](https://github.com/briancray/abide/commit/16ec341a6ea7da509919b37a1cdcc2f237e04c1c))
|
|
81
|
+
- close await-suspense gaps and simplify the boundary (ADR-0042 review) ([`7f9fcbb`](https://github.com/briancray/abide/commit/7f9fcbbec862eba6da3103a6aca77d505ef6f58d))
|
|
82
|
+
- type {:catch} bindings as unknown, not any (ADR-0042) ([`9a91522`](https://github.com/briancray/abide/commit/9a915229caf92914b5bcd9e5625153831783049d))
|
|
83
|
+
- withhold pending blocking reads in {#await}/{#for await} subjects (ADR-0042) ([`e58b538`](https://github.com/briancray/abide/commit/e58b538f477c2bfcec654fd1a7aab65466cc956c))
|
|
84
|
+
|
|
3
85
|
## 0.52.0
|
|
4
86
|
|
|
5
87
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ One declaration branches to every surface:
|
|
|
53
53
|
|
|
54
54
|
A **typed handler input** is what flips those surfaces on: the input type is projected to JSON Schema at build, so a plainly-typed handler auto-exposes to the CLI and — for read-only methods (`GET`/`HEAD`) — MCP with no hand-written `schemas.input` (a declared `schemas.input` adds runtime validation on top). A mutating method (`POST`/`PUT`/`PATCH`/`DELETE`) never auto-exposes to MCP; opt it in with `clients: { mcp: true }`.
|
|
55
55
|
|
|
56
|
-
Consume it by importing the export. The **bare call `getMessages(args)` is the smart read** — cached, coalesced, reactive, isomorphic (in-process on the server, `fetch` in the browser). Around it sit `getMessages.raw(args, opts?)` for the raw `Response`, the mutators/probes `.refresh()` / `.invalidate()` / `.
|
|
56
|
+
Consume it by importing the export. The **bare call `getMessages(args)` is the smart read** — cached, coalesced, reactive, isomorphic (in-process on the server, `fetch` in the browser). Around it sit `getMessages.raw(args, opts?)` for the raw `Response`, the mutators/probes `.refresh()` / `.invalidate()` / `.amend(...)` / `.peek()` / `.pending()` / `.refreshing()` / `.error()`, and — when the handler streams (`jsonl`/`sse`) — a bare call that returns an `AsyncIterable`.
|
|
57
57
|
|
|
58
58
|
> Query, path, and form args auto-coerce from the endpoint's typed shape at build, so a numeric/boolean/date field arrives already typed — no `z.coerce` (a value that won't parse stays a string, so the schema raises an honest 422). The per-RPC `timeout` (504 on every surface) is distinct from the client transport ceiling `ABIDE_CLIENT_TIMEOUT`.
|
|
59
59
|
|
|
@@ -124,14 +124,16 @@ async function send() {
|
|
|
124
124
|
<section class="chat" class:empty={limit === 0} style:--rows={shown} {...rootAttrs}>
|
|
125
125
|
<h1>{title}</h1>
|
|
126
126
|
|
|
127
|
-
<!--
|
|
128
|
-
|
|
127
|
+
<!-- A bare read STREAMS: it peeks `undefined` while pending, so it composes
|
|
128
|
+
with `??` and the probes. `await` is the marker for the other mode. -->
|
|
129
129
|
<p class:loading={getMessages.pending({ limit })}>
|
|
130
130
|
{getMessages({ limit })?.length ?? 0} messages
|
|
131
131
|
{#if getMessages.error({ limit })}<span>failed to load</span>{/if}
|
|
132
132
|
</p>
|
|
133
133
|
|
|
134
|
-
<!-- `{await}`
|
|
134
|
+
<!-- `{await}` means RESOLVED: the server blocks the flush and the client suspends
|
|
135
|
+
this region until it settles, so the value is never pending at the read (no `?.`)
|
|
136
|
+
— the region just shows nothing, then reveals. -->
|
|
135
137
|
<small>today: {await countToday()}</small>
|
|
136
138
|
|
|
137
139
|
<!-- `{#await}` is the explicit opt-in — a distinct pending branch and `{:then}` narrowing. -->
|
|
@@ -150,7 +152,7 @@ async function send() {
|
|
|
150
152
|
<p>raise the limit</p>
|
|
151
153
|
{/if}
|
|
152
154
|
{:catch err}
|
|
153
|
-
<p>{err.message}</p>
|
|
155
|
+
<p>{err instanceof Error ? err.message : String(err)}</p>
|
|
154
156
|
{:finally}
|
|
155
157
|
<hr />
|
|
156
158
|
{/await}
|
|
@@ -171,7 +173,7 @@ async function send() {
|
|
|
171
173
|
{#try}
|
|
172
174
|
<p>{risky()}</p>
|
|
173
175
|
{:catch e}
|
|
174
|
-
<p>widget crashed: {e.message}</p>
|
|
176
|
+
<p>widget crashed: {e instanceof Error ? e.message : String(e)}</p>
|
|
175
177
|
{:finally}
|
|
176
178
|
<span>rendered</span>
|
|
177
179
|
{/try}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abide/abide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.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",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"./ui/watch": "./src/lib/ui/watch.ts",
|
|
61
61
|
"./shared/pending": "./src/lib/shared/pending.ts",
|
|
62
62
|
"./shared/invalidate": "./src/lib/shared/invalidate.ts",
|
|
63
|
-
"./shared/
|
|
63
|
+
"./shared/amend": "./src/lib/shared/amend.ts",
|
|
64
64
|
"./shared/peek": "./src/lib/shared/peek.ts",
|
|
65
65
|
"./shared/refresh": "./src/lib/shared/refresh.ts",
|
|
66
66
|
"./shared/refreshing": "./src/lib/shared/refreshing.ts",
|
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
"./ui/dom/spreadAttrs": "./src/lib/ui/dom/spreadAttrs.ts",
|
|
96
96
|
"./ui/dom/readCall": "./src/lib/ui/dom/readCall.ts",
|
|
97
97
|
"./ui/dom/readCell": "./src/lib/ui/dom/readCell.ts",
|
|
98
|
+
"./ui/dom/readCellBlocking": "./src/lib/ui/dom/readCellBlocking.ts",
|
|
98
99
|
"./ui/dom/writeCell": "./src/lib/ui/dom/writeCell.ts",
|
|
99
100
|
"./ui/dom/cellPending": "./src/lib/ui/dom/cellPending.ts",
|
|
100
101
|
"./ui/runtime/withPath": "./src/lib/ui/runtime/withPath.ts",
|
package/src/checkAbide.ts
CHANGED
|
@@ -78,7 +78,7 @@ function collectByProject(cwd: string): { diagnostics: AbideDiagnostic[]; checke
|
|
|
78
78
|
)
|
|
79
79
|
/* The shadow program already holds the project's real `.ts` files (loaded so the
|
|
80
80
|
components' imports/types resolve), but `collectAbideDiagnostics` only reports the
|
|
81
|
-
`.abide` shadows. Report the `.ts` files too, so a mistyped `navigate`/`url`/`
|
|
81
|
+
`.abide` shadows. Report the `.ts` files too, so a mistyped `navigate`/`url`/`amend`
|
|
82
82
|
call — or any type error — in an rpc handler, `app.ts`, or a `$shared` helper fails
|
|
83
83
|
`abide check` instead of only surfacing under a separately-run `tsc`. */
|
|
84
84
|
return [...collectAbideDiagnostics(shadow), ...collectTsDiagnostics(shadow.program)]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { AMEND_TOPIC_PREFIX } from '../../shared/AMEND_TOPIC_PREFIX.ts'
|
|
2
|
+
import { encodeRefJson } from '../../shared/encodeRefJson.ts'
|
|
3
|
+
import { keyForRemoteCall } from '../../shared/keyForRemoteCall.ts'
|
|
4
|
+
import { REMOTE_FUNCTION } from '../../shared/REMOTE_FUNCTION.ts'
|
|
5
|
+
import type { CacheSelector } from '../../shared/types/CacheSelector.ts'
|
|
6
|
+
import type { RawRemoteFunction } from '../../shared/types/RawRemoteFunction.ts'
|
|
7
|
+
import type { SocketServerFrame } from '../../shared/types/SocketServerFrame.ts'
|
|
8
|
+
import { getActiveServer } from './getActiveServer.ts'
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
The server half of the isomorphic amend value form (ADR-0043): serialize the keyed
|
|
12
|
+
value to a standard socket `msg` frame and publish it on the per-call reserved topic
|
|
13
|
+
`__abide/amend/<key>`, so every browser with a live reader of that exact call (and thus
|
|
14
|
+
already authorized for it) sets the pushed value locally with no refetch. Installed as
|
|
15
|
+
the amendBroadcastSlot resolver by serverEntry ONLY — never imported from a shared or ui
|
|
16
|
+
module — so this server socket code stays out of the client reachability graph; the
|
|
17
|
+
resolver-slot indirection is the guarantee, not the ADR-0022 DCE guard (which polices the
|
|
18
|
+
app's own src/server edge, not abide's internal lib/server modules). Do not import this
|
|
19
|
+
from a client-reachable module.
|
|
20
|
+
|
|
21
|
+
Only the value form broadcasts: an updater is a closure with no wire form, so it throws
|
|
22
|
+
here (the mistake surfaces at the mutation site). The value must target an exact call — a
|
|
23
|
+
remote function with its args, or a no-input rpc's single key — since a value has to land
|
|
24
|
+
on one entry; a producer/closure or `{ tags }` selector has no single cross-client key and
|
|
25
|
+
throws. The rpc's own key derivation (keyForRemoteCall) names the topic, so it agrees with
|
|
26
|
+
the client cache key by construction. Subscriber-gated: skip the encode + native publish
|
|
27
|
+
when no client is reading that key (the topic evaporates — it holds no tail).
|
|
28
|
+
*/
|
|
29
|
+
export function broadcastAmend<Args, Return>(
|
|
30
|
+
selector: CacheSelector<Args, Return>,
|
|
31
|
+
args: Args | undefined,
|
|
32
|
+
isValue: boolean,
|
|
33
|
+
payload: Return | ((current: Return) => Return),
|
|
34
|
+
): void {
|
|
35
|
+
if (!isValue) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'[abide] amend() with an updater is client-local (a closure has no wire form) — pass a value to broadcast from the server.',
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
if (typeof selector !== 'function' || !(REMOTE_FUNCTION in selector)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'[abide] amend(value) can only broadcast for a remote function — a producer/closure or { tags } selector has no cross-client key.',
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
const remote = selector as RawRemoteFunction<Args>
|
|
46
|
+
const topic = `${AMEND_TOPIC_PREFIX}${keyForRemoteCall(remote.method, remote.url, args)}`
|
|
47
|
+
const server = getActiveServer()
|
|
48
|
+
if (server === undefined || server.subscriberCount(`socket:${topic}`) === 0) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
const frame: SocketServerFrame = { type: 'msg', socket: topic, message: payload }
|
|
52
|
+
server.publish(`socket:${topic}`, encodeRefJson(frame))
|
|
53
|
+
}
|
|
@@ -7,6 +7,7 @@ import { hasSeedableRequest } from '../../shared/hasSeedableRequest.ts'
|
|
|
7
7
|
import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
|
|
8
8
|
import { resolvedCellsSlot } from '../../shared/resolvedCellsSlot.ts'
|
|
9
9
|
import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
|
|
10
|
+
import { socketTailsSlot } from '../../shared/socketTailsSlot.ts'
|
|
10
11
|
import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
|
|
11
12
|
import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
|
|
12
13
|
import type { SsrPayload } from '../../shared/types/SsrPayload.ts'
|
|
@@ -161,6 +162,30 @@ export function createUiPageRenderer({
|
|
|
161
162
|
return Object.keys(docs).length > 0 ? docs : undefined
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
/* The `sockets` partition of __SSR__: each socket whose retained frame this render read via
|
|
166
|
+
`peek(socket)` (drained from the request-scoped `socketTailsSlot` populated by
|
|
167
|
+
`defineSocket.peek`), keyed by socket NAME, ref-json-encoded. Last write per name wins — a
|
|
168
|
+
socket peeked twice ships its latest read. An unserializable frame is dropped (a warn): that
|
|
169
|
+
socket falls back to a cold client peek (undefined) rather than blanking the payload. Undefined
|
|
170
|
+
when no socket was peeked during the render (the common page). */
|
|
171
|
+
function socketTailSnapshots(): Record<string, string> | undefined {
|
|
172
|
+
const entries = socketTailsSlot.get()?.entries ?? []
|
|
173
|
+
if (entries.length === 0) {
|
|
174
|
+
return undefined
|
|
175
|
+
}
|
|
176
|
+
const sockets: Record<string, string> = {}
|
|
177
|
+
for (const { name, value } of entries) {
|
|
178
|
+
try {
|
|
179
|
+
sockets[name] = encodeRefJson(value)
|
|
180
|
+
} catch {
|
|
181
|
+
console.warn(
|
|
182
|
+
`[abide] socket "${name}" retained frame is unserializable — its client peek() will read undefined at hydration instead of the server value.`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return Object.keys(sockets).length > 0 ? sockets : undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
164
189
|
/* Build the __SSR__ <script> the client (startClient) reads on boot. The inline
|
|
165
190
|
(settled) cache partition is computed once by the caller and threaded in, so the
|
|
166
191
|
streaming branch can also drain the pending partition over the same render. */
|
|
@@ -177,6 +202,7 @@ export function createUiPageRenderer({
|
|
|
177
202
|
cache: inline,
|
|
178
203
|
cells: resolvedCellCells(),
|
|
179
204
|
docs: docSeedSnapshots(),
|
|
205
|
+
sockets: socketTailSnapshots(),
|
|
180
206
|
base: base || undefined,
|
|
181
207
|
trace: formatTraceparent(store.trace),
|
|
182
208
|
app: appNameSlot.name,
|
|
@@ -34,6 +34,7 @@ export function runWithRequestScope(
|
|
|
34
34
|
pendingAsyncCells: { promises: [] },
|
|
35
35
|
resolvedCells: { entries: [] },
|
|
36
36
|
streamedCells: { entries: [] },
|
|
37
|
+
socketTails: { entries: [] },
|
|
37
38
|
docSnapshots: { entries: [] },
|
|
38
39
|
trace: createTraceContext(req.headers.get('traceparent')),
|
|
39
40
|
start: Bun.nanoseconds(),
|
|
@@ -2,6 +2,7 @@ import type { CacheStore } from '../../../shared/types/CacheStore.ts'
|
|
|
2
2
|
import type { DocSnapshots } from '../../../shared/types/DocSnapshots.ts'
|
|
3
3
|
import type { PendingAsyncCells } from '../../../shared/types/PendingAsyncCells.ts'
|
|
4
4
|
import type { ResolvedCells } from '../../../shared/types/ResolvedCells.ts'
|
|
5
|
+
import type { SocketTails } from '../../../shared/types/SocketTails.ts'
|
|
5
6
|
import type { StreamedCells } from '../../../shared/types/StreamedCells.ts'
|
|
6
7
|
import type { TraceContext } from '../../../shared/types/TraceContext.ts'
|
|
7
8
|
import type { Scope } from '../../../ui/types/Scope.ts'
|
|
@@ -42,6 +43,14 @@ export type RequestStore = {
|
|
|
42
43
|
*/
|
|
43
44
|
streamedCells: StreamedCells
|
|
44
45
|
/*
|
|
46
|
+
Retained socket frames read via `peek(socket)` during this request's SSR pass, keyed by socket
|
|
47
|
+
name. `defineSocket`'s server `peek()` pushes each read; the page renderer stamps them into
|
|
48
|
+
`__SSR__.sockets` (ref-json) so the client seeds `peek(socket)` to the SAME retained value the
|
|
49
|
+
server rendered instead of `undefined` on the not-yet-connected client. Sibling of `resolvedCells`
|
|
50
|
+
— a server value carried forward, read at render-return, not awaited.
|
|
51
|
+
*/
|
|
52
|
+
socketTails: SocketTails
|
|
53
|
+
/*
|
|
45
54
|
Reactive-document snapshots captured during this request's SSR pass, keyed by render-path id.
|
|
46
55
|
`createScope` registers a lazy `take` for each rendered scope; the page renderer stamps the
|
|
47
56
|
non-empty ones into `__SSR__.docs` (ref-json) so the client seeds a plain `state(initial)` to
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { browserClientFlags } from '../../shared/browserClientFlags.ts'
|
|
2
|
+
import type { Socket } from '../../shared/types/Socket.ts'
|
|
3
|
+
import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
|
|
4
|
+
|
|
5
|
+
/* An empty async iterable — the amend channel replays nothing (live-only, no tail). */
|
|
6
|
+
const emptyIterable = {
|
|
7
|
+
[Symbol.asyncIterator]: () => ({
|
|
8
|
+
next: async () => ({ done: true as const, value: undefined }),
|
|
9
|
+
}),
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
The single synthetic registry entry the dispatcher resolves every `__abide/amend/<key>`
|
|
14
|
+
topic to (ADR-0043). The amend value channel is a per-call reserved topic family: the
|
|
15
|
+
per-key Bun topic is the sub frame's own socket name, so one shared entry serves the whole
|
|
16
|
+
family — there is no per-key socket object or module to load. It is subscribe-only
|
|
17
|
+
(clientPublish false, so a browser can't forge a push), holds no tail (snapshotTail empty —
|
|
18
|
+
a missed frame heals via the reader's reconnect refresh), and is browser-only, so the REST
|
|
19
|
+
face 404s. The stub Socket methods are never reached: the ws sub path subscribes the raw
|
|
20
|
+
Bun topic and replays via snapshotTail, and the REST face is gated off by the clients flags.
|
|
21
|
+
*/
|
|
22
|
+
const socket = {
|
|
23
|
+
name: '__abide/amend',
|
|
24
|
+
clients: browserClientFlags,
|
|
25
|
+
publish: () => undefined,
|
|
26
|
+
tail: () => emptyIterable,
|
|
27
|
+
peek: () => undefined,
|
|
28
|
+
refresh: () => undefined,
|
|
29
|
+
watch: () => () => undefined,
|
|
30
|
+
[Symbol.asyncIterator]: emptyIterable[Symbol.asyncIterator],
|
|
31
|
+
} as unknown as Socket<unknown>
|
|
32
|
+
|
|
33
|
+
export const amendFamilyEntry: SocketRegistryEntry = {
|
|
34
|
+
socket,
|
|
35
|
+
allowClientPublish: false,
|
|
36
|
+
schema: undefined,
|
|
37
|
+
clients: browserClientFlags,
|
|
38
|
+
snapshotTail: () => [],
|
|
39
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ServerWebSocket } from 'bun'
|
|
2
|
+
import { AMEND_TOPIC_PREFIX } from '../../shared/AMEND_TOPIC_PREFIX.ts'
|
|
2
3
|
import { abideLog } from '../../shared/abideLog.ts'
|
|
3
4
|
import { decodeWireBody } from '../../shared/decodeWireBody.ts'
|
|
4
5
|
import { encodeRefJson } from '../../shared/encodeRefJson.ts'
|
|
@@ -10,6 +11,7 @@ import type { SocketServerFrame } from '../../shared/types/SocketServerFrame.ts'
|
|
|
10
11
|
import { error } from '../error.ts'
|
|
11
12
|
import { json } from '../json.ts'
|
|
12
13
|
import { sse } from '../sse.ts'
|
|
14
|
+
import { amendFamilyEntry } from './amendFamilyEntry.ts'
|
|
13
15
|
import { lookupSocket } from './lookupSocket.ts'
|
|
14
16
|
import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
|
|
15
17
|
import type { SocketRoutes } from './types/SocketRoutes.ts'
|
|
@@ -87,6 +89,13 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
|
|
|
87
89
|
| { entry: SocketRegistryEntry }
|
|
88
90
|
| { failure: 'unregistered' | 'load-failed' | 'no-export'; message: string }
|
|
89
91
|
> {
|
|
92
|
+
/* The amend value channel (ADR-0043) is a per-call reserved topic family: one synthetic,
|
|
93
|
+
subscribe-only entry serves every `__abide/amend/<key>` topic — the per-key Bun topic is
|
|
94
|
+
the sub frame's own name, and there is no module to load or tail to replay. Checked
|
|
95
|
+
before the generic reserved lookup, which would miss (no per-key registry entry). */
|
|
96
|
+
if (name.startsWith(AMEND_TOPIC_PREFIX)) {
|
|
97
|
+
return { entry: amendFamilyEntry }
|
|
98
|
+
}
|
|
90
99
|
/* A reserved `__abide/` topic has no user module to load — it is minted at boot into
|
|
91
100
|
the registry (ADR-0041), so skip the loader and read it straight from there. */
|
|
92
101
|
if (name.startsWith(RESERVED_SOCKET_PREFIX)) {
|
|
@@ -2,6 +2,7 @@ import { attachSocketSelectorMethods } from '../../shared/attachSocketSelectorMe
|
|
|
2
2
|
import { createPushIterator } from '../../shared/createPushIterator.ts'
|
|
3
3
|
import { encodeRefJson } from '../../shared/encodeRefJson.ts'
|
|
4
4
|
import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
|
|
5
|
+
import { socketTailsSlot } from '../../shared/socketTailsSlot.ts'
|
|
5
6
|
import { socketTapSlot } from '../../shared/socketTapSlot.ts'
|
|
6
7
|
import type { Socket } from '../../shared/types/Socket.ts'
|
|
7
8
|
import type { SocketServerFrame } from '../../shared/types/SocketServerFrame.ts'
|
|
@@ -185,7 +186,16 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
|
|
|
185
186
|
/* The latest retained frame (after ttl pruning), or undefined when none is held. */
|
|
186
187
|
peek: () => {
|
|
187
188
|
pruneExpired(Date.now())
|
|
188
|
-
|
|
189
|
+
const value =
|
|
190
|
+
buffer.length > 0 ? (buffer[buffer.length - 1] as BufferEntry).value : undefined
|
|
191
|
+
/* Record the retained frame this SSR render read so the page renderer can ship it in
|
|
192
|
+
`__SSR__.sockets` — the client seeds `peek()` to the same value instead of undefined,
|
|
193
|
+
keeping hydration congruent. `get()` is undefined off-request (ws/boot), so this is a
|
|
194
|
+
no-op outside an SSR render; a `tail: 0` socket holds no frame and records nothing. */
|
|
195
|
+
if (value !== undefined) {
|
|
196
|
+
socketTailsSlot.get()?.entries.push({ name, value })
|
|
197
|
+
}
|
|
198
|
+
return value
|
|
189
199
|
},
|
|
190
200
|
/* No-op on the server: it is the source of truth, so there is nothing to re-pull. */
|
|
191
201
|
refresh: () => undefined,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Reserved topic-family prefix for the per-call amend value channel (ADR-0043). A
|
|
3
|
+
server-side `amend(args, value)` broadcasts on `${AMEND_TOPIC_PREFIX}${cacheKey}`, and
|
|
4
|
+
a client with a live reader of that exact call subscribes to the same topic — so a
|
|
5
|
+
pushed value only ever reaches a browser already reading (and therefore authorized
|
|
6
|
+
for) that key. Under the reserved `__abide/` namespace so user code can't shadow it,
|
|
7
|
+
and per-call (not per-rpc) so the subscription set doubles as the authorized-reader set.
|
|
8
|
+
*/
|
|
9
|
+
export const AMEND_TOPIC_PREFIX = '__abide/amend/'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/* The socket warm-seed manifest: a socket's retained latest frame as read via `peek(socket)` during
|
|
2
|
+
SSR, keyed by the socket's name, as a ref-json-encoded STRING (decoded at the read site in
|
|
3
|
+
`socketProxy`). `startClient` drains `__SSR__.sockets` into here before mount; a hydrating
|
|
4
|
+
`socketProxy` reads its key to seed the socket's `lastFrame` WARM, so `peek(socket)` returns the
|
|
5
|
+
SAME retained value the server rendered instead of `undefined` on the not-yet-connected client —
|
|
6
|
+
killing the SSR/hydration divergence that would otherwise discard the server markup and cold-render.
|
|
7
|
+
Backed by `globalThis.__abideSockets` so an inline pre-bundle script and the framework share one
|
|
8
|
+
store — whoever runs first creates it, the other adopts the same reference (mirrors CELL_SEED). */
|
|
9
|
+
const globalScope = globalThis as { __abideSockets?: Record<string, string> }
|
|
10
|
+
globalScope.__abideSockets ??= {}
|
|
11
|
+
|
|
12
|
+
// @documentation plumbing
|
|
13
|
+
export const SOCKET_SEED: Record<string, string> = globalScope.__abideSockets
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { amendBroadcastSlot } from './amendBroadcastSlot.ts'
|
|
2
|
+
import type { CacheOptions } from './types/CacheOptions.ts'
|
|
3
|
+
import type { CacheSelector } from './types/CacheSelector.ts'
|
|
4
|
+
import type { RemoteFunction } from './types/RemoteFunction.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Mutate the retained value of the matching cached read(s) in place — reactive
|
|
8
|
+
(readers re-render), no network. The optimistic-update / real-time primitive:
|
|
9
|
+
amend a cached list from a socket frame (`on(chat, m => getList.amend(l => [...l,
|
|
10
|
+
m]))`) or apply an optimistic edit before a write lands. The last argument is
|
|
11
|
+
either a concrete `Return` value (set it) or an updater `(current) => Return`
|
|
12
|
+
(transform the current decoded value). The value form is what a server-side
|
|
13
|
+
`amend(args, value)` broadcasts (ADR-0043 phase 2); the updater form is a closure
|
|
14
|
+
and stays local.
|
|
15
|
+
|
|
16
|
+
amend(getFoo, args, value | updater) → that exact call
|
|
17
|
+
amend(getFoo, value | updater) → every args-variant of that rpc
|
|
18
|
+
amend({ tags }, value | updater) → every entry sharing a tag
|
|
19
|
+
|
|
20
|
+
Instance sugar `getFoo.amend(args?, value | updater)` ≡ `amend(getFoo, args, …)`;
|
|
21
|
+
for a no-input rpc the args collapse away (`getFoo.amend(value)` — ADR-0043). The
|
|
22
|
+
value/updater is always the last argument; a not-yet-read key has nothing to amend.
|
|
23
|
+
*/
|
|
24
|
+
// @documentation cache
|
|
25
|
+
export function amend<Args, Return>(
|
|
26
|
+
fn: RemoteFunction<Args, Return> | ((args?: Args) => Promise<Return>),
|
|
27
|
+
args: Args | undefined,
|
|
28
|
+
value: Return | ((current: Return) => Return),
|
|
29
|
+
): void
|
|
30
|
+
export function amend<Args, Return>(
|
|
31
|
+
fn: RemoteFunction<Args, Return> | ((args?: Args) => Promise<Return>),
|
|
32
|
+
value: Return | ((current: Return) => Return),
|
|
33
|
+
): void
|
|
34
|
+
export function amend<Return>(
|
|
35
|
+
selector: Pick<CacheOptions, 'tags'>,
|
|
36
|
+
value: Return | ((current: Return) => Return),
|
|
37
|
+
): void
|
|
38
|
+
export function amend(selector: unknown, argsOrPayload: unknown, maybePayload?: unknown): void {
|
|
39
|
+
/* Payload (value or updater) is always last: 3 args → (selector, args, payload); 2 args →
|
|
40
|
+
(selector, payload). A function payload is an updater (a local closure); anything else is
|
|
41
|
+
a replacement value — a Return that is itself a function must be wrapped by the caller
|
|
42
|
+
(ADR-0043). Both forms route through the side-swap slot: on the client it applies locally,
|
|
43
|
+
on the server the value form broadcasts and the updater form throws. */
|
|
44
|
+
const payload = maybePayload !== undefined ? maybePayload : argsOrPayload
|
|
45
|
+
const args = maybePayload !== undefined ? argsOrPayload : undefined
|
|
46
|
+
const isValue = typeof payload !== 'function'
|
|
47
|
+
amendBroadcastSlot.get()?.(selector as CacheSelector<unknown, unknown>, args, isValue, payload)
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { applyAmendLocally } from './applyAmendLocally.ts'
|
|
2
|
+
import { createResolverSlot } from './createResolverSlot.ts'
|
|
3
|
+
import type { AmendApply } from './types/AmendApply.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
The one side-swap seam for the isomorphic value form of amend (ADR-0043), mirroring
|
|
7
|
+
cacheStalenessSlot. amend() routes both forms through this — the value form so the side
|
|
8
|
+
decides apply-vs-broadcast, and the updater form only so the server resolver can reject
|
|
9
|
+
it loudly:
|
|
10
|
+
|
|
11
|
+
- client entry (startClient): installs applyAmendLocally — set this tab's retained value.
|
|
12
|
+
- server entry (serverEntry): installs broadcastAmend — publish the keyed value to every
|
|
13
|
+
authorized reader over the reserved __abide/amend/<key> topic, and throw on an updater
|
|
14
|
+
(a closure can't broadcast).
|
|
15
|
+
|
|
16
|
+
With no resolver registered the fallback is applyAmendLocally too, so isolated unit tests
|
|
17
|
+
keep local behaviour without booting a runtime.
|
|
18
|
+
*/
|
|
19
|
+
export const amendBroadcastSlot = createResolverSlot<AmendApply>(() => applyAmendLocally)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cache } from './cache.ts'
|
|
2
|
+
import type { CacheSelector } from './types/CacheSelector.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Applies an amend to THIS side's cache store(s) — the local half of the isomorphic
|
|
6
|
+
amend value form (ADR-0043). A concrete value folds to an updater that ignores the
|
|
7
|
+
current value; an updater is used as-is. Both the amendBroadcastSlot fallback and the
|
|
8
|
+
client entry's resolver point here so the local-apply path can't diverge between an
|
|
9
|
+
unbooted unit test and a booted client tab. The server entry installs a broadcaster
|
|
10
|
+
instead (the side-swap seam), so this never runs on the server's throwaway request store.
|
|
11
|
+
*/
|
|
12
|
+
export function applyAmendLocally<Args, Return>(
|
|
13
|
+
selector: CacheSelector<Args, Return>,
|
|
14
|
+
args: Args | undefined,
|
|
15
|
+
isValue: boolean,
|
|
16
|
+
payload: Return | ((current: Return) => Return),
|
|
17
|
+
): void {
|
|
18
|
+
const updater = isValue ? () => payload as Return : (payload as (current: Return) => Return)
|
|
19
|
+
cache.amend(selector, args, updater, isValue)
|
|
20
|
+
}
|