@abide/abide 0.53.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 +9 -5
- package/CHANGELOG.md +57 -0
- package/README.md +1 -1
- package/package.json +2 -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/cachedShadowProgram.ts +38 -0
- package/src/lib/ui/compile/compileComponent.ts +9 -2
- package/src/lib/ui/compile/desugarSignals.ts +68 -3
- package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
- package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
- package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +18 -24
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +9 -14
- package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
- package/src/lib/ui/dom/awaitBlock.ts +1 -1
- package/src/lib/ui/dom/tryBlock.ts +1 -1
- package/src/lib/ui/router.ts +8 -0
- package/src/lib/ui/runtime/createDoc.ts +5 -1
- 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 +18 -5
- 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
|
|
@@ -291,9 +291,13 @@ from the server.
|
|
|
291
291
|
- `abide/shared/invalidate` — `invalidate(selector?, args?)`: the drop verb — drop
|
|
292
292
|
matching cached reads so the next read reloads lazily. Selector grammar:
|
|
293
293
|
`(fn, args)` / `(fn)` / `({ tags })` / `()`.
|
|
294
|
-
- `abide/shared/
|
|
295
|
-
|
|
296
|
-
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)`).
|
|
297
301
|
- `abide/shared/refresh` — `refresh(selector?, args?)`: refetch matching reads now,
|
|
298
302
|
keeping the stale value visible (`refreshing()` true) until the fresh one swaps in.
|
|
299
303
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
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
|
+
|
|
3
60
|
## 0.53.0
|
|
4
61
|
|
|
5
62
|
### 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
|
|
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",
|
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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { amend } from './amend.ts'
|
|
1
2
|
import { invalidate } from './invalidate.ts'
|
|
2
3
|
import { keyForRemoteCall } from './keyForRemoteCall.ts'
|
|
3
4
|
import { keyPrefixForRemote } from './keyPrefixForRemote.ts'
|
|
4
|
-
import { patch } from './patch.ts'
|
|
5
5
|
import { peek } from './peek.ts'
|
|
6
6
|
import { pending } from './pending.ts'
|
|
7
7
|
import { refresh } from './refresh.ts'
|
|
@@ -12,13 +12,13 @@ import type { RemoteFunction } from './types/RemoteFunction.ts'
|
|
|
12
12
|
/*
|
|
13
13
|
Attaches the pre-bound selector sugar onto an assembled RemoteFunction:
|
|
14
14
|
`fn.pending(args?)` ≡ `pending(fn, args?)`, likewise refreshing / refresh / invalidate / peek,
|
|
15
|
-
`fn.
|
|
15
|
+
`fn.amend(args?, value | updater)` ≡ `amend(fn, args, …)`, and `fn.error(args?)` — the typed
|
|
16
16
|
last error from the rpc error registry (most-recent across the rpc when args omitted, that
|
|
17
17
|
exact call when given). The cached read is the bare call `fn(args, opts)` itself; refetch is
|
|
18
18
|
`fn.refresh(args?)`. The methods only reference the globals at call time, so the shared import
|
|
19
19
|
edge carries no module-init dependency (safe against any cache ↔ createRemoteFunction cycle).
|
|
20
20
|
Attached in createRemoteFunction so the server (defineRpc) and client (remoteProxy) shapes are
|
|
21
|
-
identical. `
|
|
21
|
+
identical. `amend` is attached uniformly; the type omits it for a streaming rpc (harmless at
|
|
22
22
|
runtime — a stream has no cache entry).
|
|
23
23
|
*/
|
|
24
24
|
export function attachRpcSelectorMethods<Args, Return>(fn: RemoteFunction<Args, Return>): void {
|
|
@@ -28,8 +28,8 @@ export function attachRpcSelectorMethods<Args, Return>(fn: RemoteFunction<Args,
|
|
|
28
28
|
refresh: (args?: Args) => refresh(fn, args),
|
|
29
29
|
invalidate: (args?: Args) => invalidate(fn, args),
|
|
30
30
|
peek: (args?: Args) => peek(fn, args),
|
|
31
|
-
|
|
32
|
-
(
|
|
31
|
+
amend: (argsOrUpdater?: unknown, updater?: unknown) =>
|
|
32
|
+
(amend as (fn: unknown, a?: unknown, b?: unknown) => void)(fn, argsOrUpdater, updater),
|
|
33
33
|
/* Reaction sugar is client-only (`watch` is a ui primitive that must not ride into a
|
|
34
34
|
server bundle), so the shared attach binds an inert no-op — the server (defineRpc)
|
|
35
35
|
shape carries `.watch` too, and an author `fn.watch(…)` surviving the SSR effect-strip
|
|
@@ -22,15 +22,22 @@ Bare iteration is the live stream (replay 0); `.tail(n)` seeds from the
|
|
|
22
22
|
retained tail (no-arg = the whole tail). Each iterator mints its own sub id for
|
|
23
23
|
lifecycle routing (end/err), and `hooks.replayed` fires in-band after the
|
|
24
24
|
replay batch so a window reader commits its seed strictly before any live frame.
|
|
25
|
+
|
|
26
|
+
`initialFrame` warm-seeds `lastFrame` at construction (socketProxy passes the
|
|
27
|
+
server's retained frame from `__SSR__.sockets`) so `peek()` returns the SSR value
|
|
28
|
+
at hydration instead of undefined — congruent with the server HTML until the ws
|
|
29
|
+
delivers its own replay of the same frame.
|
|
25
30
|
*/
|
|
26
31
|
export function buildSocketOverChannel<T>(
|
|
27
32
|
name: string,
|
|
28
33
|
resolveChannel: () => SocketChannel,
|
|
34
|
+
initialFrame?: T,
|
|
29
35
|
): Socket<T> {
|
|
30
36
|
/* The latest frame seen across every iterator of this socket — what peek() returns and
|
|
31
37
|
what refresh() re-seeds from the server tail. A plain slot (not reactive): peek(socket)
|
|
32
|
-
is a synchronous snapshot; live reactivity is the for-await / watch(socket) path.
|
|
33
|
-
|
|
38
|
+
is a synchronous snapshot; live reactivity is the for-await / watch(socket) path. Seeded
|
|
39
|
+
from the SSR-retained frame so a not-yet-connected client's peek matches the server. */
|
|
40
|
+
let lastFrame: T | undefined = initialFrame
|
|
34
41
|
function iterate(replay: number | undefined, hooks?: TailHooks): AsyncIterable<T> {
|
|
35
42
|
return {
|
|
36
43
|
[Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
|