@abide/abide 0.53.0 → 0.55.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 +330 -494
- package/CHANGELOG.md +106 -0
- package/README.md +166 -146
- package/package.json +3 -4
- package/src/checkAbide.ts +1 -1
- package/src/lib/server/rpc/defineRpc.ts +2 -1
- package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
- package/src/lib/server/runtime/amendBroadcaster.ts +53 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +54 -20
- 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/SEEDS.ts +33 -0
- package/src/lib/shared/SOCKET_SEED.ts +12 -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 +203 -84
- package/src/lib/shared/cacheReaderSocketSlot.ts +11 -0
- package/src/lib/shared/createCacheStore.ts +8 -0
- package/src/lib/shared/createRemoteFunction.ts +4 -1
- package/src/lib/shared/encodeSeedValue.ts +24 -0
- package/src/lib/shared/jsonSchemaForType.ts +3 -16
- package/src/lib/shared/peek.ts +4 -7
- 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 -6
- package/src/lib/ui/compile/analyzeComponent.ts +0 -2
- package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
- package/src/lib/ui/compile/cachedShadowProgram.ts +39 -0
- package/src/lib/ui/compile/compileComponent.ts +1 -3
- package/src/lib/ui/compile/componentOrdinals.ts +39 -0
- package/src/lib/ui/compile/desugarSignals.ts +65 -36
- package/src/lib/ui/compile/generateBuild.ts +25 -24
- package/src/lib/ui/compile/generateSSR.ts +37 -24
- package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
- package/src/lib/ui/compile/interpolationClassifierForRoot.ts +8 -13
- package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
- package/src/lib/ui/compile/lowerCompoundAssignment.ts +49 -0
- package/src/lib/ui/compile/lowerContext.ts +26 -6
- package/src/lib/ui/compile/lowerDocAccess.ts +19 -31
- package/src/lib/ui/compile/lowerScript.ts +1 -11
- package/src/lib/ui/compile/lowerUpdateExpression.ts +32 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +25 -45
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +10 -15
- package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
- package/src/lib/ui/compile/wrapReactionCellSources.ts +10 -6
- package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
- package/src/lib/ui/compile/wrapSeed.ts +28 -0
- package/src/lib/ui/computed.ts +16 -0
- package/src/lib/ui/createScope.ts +4 -3
- package/src/lib/ui/dom/anchorCursor.ts +2 -1
- package/src/lib/ui/dom/anchoredBranch.ts +14 -3
- package/src/lib/ui/dom/appendStatic.ts +11 -20
- package/src/lib/ui/dom/appendText.ts +41 -43
- package/src/lib/ui/dom/appendTextAt.ts +10 -57
- package/src/lib/ui/dom/awaitBlock.ts +31 -45
- package/src/lib/ui/dom/awaitSubject.ts +118 -0
- package/src/lib/ui/dom/claimText.ts +38 -0
- package/src/lib/ui/dom/cloneStatic.ts +2 -8
- package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
- package/src/lib/ui/dom/discardBoundary.ts +5 -4
- package/src/lib/ui/dom/each.ts +3 -5
- package/src/lib/ui/dom/fillBoundary.ts +2 -1
- package/src/lib/ui/dom/hydrate.ts +5 -12
- package/src/lib/ui/dom/mountChild.ts +73 -5
- package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
- package/src/lib/ui/dom/openMarker.ts +2 -4
- package/src/lib/ui/dom/outlet.ts +4 -3
- package/src/lib/ui/dom/readCell.ts +26 -11
- package/src/lib/ui/dom/skeleton.ts +2 -7
- package/src/lib/ui/dom/switchBlock.ts +2 -1
- package/src/lib/ui/dom/tryBlock.ts +27 -39
- package/src/lib/ui/dom/when.ts +6 -2
- package/src/lib/ui/linked.ts +14 -0
- package/src/lib/ui/resumeSeedScript.ts +1 -1
- package/src/lib/ui/router.ts +12 -16
- package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
- package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
- package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
- package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
- package/src/lib/ui/runtime/RENDER.ts +7 -5
- package/src/lib/ui/runtime/RESUME.ts +5 -7
- package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
- package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
- package/src/lib/ui/runtime/claimChild.ts +5 -7
- package/src/lib/ui/runtime/claimExpected.ts +2 -6
- package/src/lib/ui/runtime/claimMarker.ts +13 -0
- package/src/lib/ui/runtime/claimRun.ts +22 -0
- package/src/lib/ui/runtime/consumeSeed.ts +38 -0
- package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
- package/src/lib/ui/runtime/createComputedNode.ts +1 -0
- package/src/lib/ui/runtime/createDoc.ts +5 -1
- package/src/lib/ui/runtime/createEffectNode.ts +1 -0
- package/src/lib/ui/runtime/createSignalNode.ts +1 -0
- package/src/lib/ui/runtime/parkCursor.ts +10 -0
- package/src/lib/ui/runtime/readNode.ts +21 -1
- package/src/lib/ui/runtime/renderPath.ts +2 -2
- package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
- package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
- package/src/lib/ui/runtime/runNode.ts +14 -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/types/HydrationCursor.ts +8 -0
- package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
- package/src/lib/ui/settleAsyncCells.ts +30 -3
- package/src/lib/ui/socketProxy.ts +17 -1
- package/src/lib/ui/startClient.ts +23 -0
- package/src/lib/ui/tryEncodeResume.ts +6 -15
- package/src/lib/ui/types/Scope.ts +1 -1
- package/src/lib/ui/watch.ts +21 -16
- 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/src/lib/ui/dom/mountStreamedChild.ts +0 -84
- package/src/lib/ui/dom/readCellBlocking.ts +0 -29
|
@@ -74,30 +74,33 @@ type RpcSchemas = {
|
|
|
74
74
|
|
|
75
75
|
/*
|
|
76
76
|
Options every rpc overload accepts: the `schemas` namespace, the `clients` surface
|
|
77
|
-
flags (browser/mcp/cli), the same-origin
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
flags (browser/mcp/cli), the same-origin CSRF exemption (`crossOrigin`), the pre-parse
|
|
78
|
+
body-byte ceiling (`maxBodySize`), the per-surface handler `timeout` (ms), and the
|
|
79
|
+
endpoint `cache` policy (ADR-0020). `cache` is shared by both kinds: a mutating rpc
|
|
80
|
+
whose method is a transport choice (a POST that carries a large `code` payload in its
|
|
81
|
+
body yet is a pure function of its args) still coalesces/memoises like a read — the
|
|
82
|
+
runtime already routes `fn.cache` through `readThrough` regardless of method. What stays
|
|
83
|
+
read-only is `stream` (`RpcReadOpts`) — a write has no replayable value to stream.
|
|
84
|
+
Generic over `Args` so `cache.tags`'s `(args) => string[]` form is typed against the
|
|
85
|
+
rpc's own argument shape. The single canonical source both `defineRpc` and
|
|
86
|
+
`RpcRegistryEntry` project from.
|
|
84
87
|
*/
|
|
85
|
-
type RpcSharedOpts = {
|
|
88
|
+
type RpcSharedOpts<Args> = {
|
|
86
89
|
schemas?: RpcSchemas
|
|
87
90
|
clients?: Partial<ClientFlags>
|
|
88
91
|
crossOrigin?: boolean
|
|
89
92
|
maxBodySize?: number
|
|
90
93
|
timeout?: number
|
|
94
|
+
cache?: CachePolicy<Args>
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
/*
|
|
94
|
-
Read-helper (GET/HEAD) options: the shared base plus the endpoint
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
rpc's own
|
|
98
|
+
Read-helper (GET/HEAD) options: the shared base (including `cache`) plus the endpoint
|
|
99
|
+
`stream` policy (`n`, replay depth) — the one option kind-scoped to replayable reads,
|
|
100
|
+
since a write has no replayable stream. Generic over `Args`, threaded into the shared
|
|
101
|
+
base so `cache.tags`'s `(args) => string[]` form is typed against the rpc's own args.
|
|
98
102
|
*/
|
|
99
|
-
type RpcReadOpts<Args> = RpcSharedOpts & {
|
|
100
|
-
cache?: CachePolicy<Args>
|
|
103
|
+
type RpcReadOpts<Args> = RpcSharedOpts<Args> & {
|
|
101
104
|
stream?: StreamPolicy
|
|
102
105
|
}
|
|
103
106
|
|
|
@@ -147,11 +150,15 @@ export type RpcHelper = {
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
/*
|
|
150
|
-
The mutating helpers (POST/PUT/PATCH/DELETE). The base opts is the shared `RpcSharedOpts
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
The mutating helpers (POST/PUT/PATCH/DELETE). The base opts is the shared `RpcSharedOpts`,
|
|
154
|
+
which carries `cache` — a mutating rpc still accepts a cache policy (a POST that is a pure
|
|
155
|
+
function of its args, using the body only to carry a large payload, coalesces/memoises like
|
|
156
|
+
a read; the runtime honours `fn.cache` regardless of method). Note the method default for a
|
|
157
|
+
write stays coalesce-only: a no-ttl write is dropped on settle (the mutation idiom), so
|
|
158
|
+
retention across the in-flight window needs an explicit `cache.ttl` (or `shared`). Only
|
|
159
|
+
`stream` stays read-only (`RpcReadOpts`) — a write has no replayable stream — so `POST(fn,
|
|
160
|
+
{ stream })` remains a compile error while `POST(fn, { cache })` is legal. Overloads mirror
|
|
161
|
+
the read helpers by argument source (multipart-upload, schema'd, schemaless-with-opts, bare).
|
|
155
162
|
*/
|
|
156
163
|
export type MutatingRpcHelper = {
|
|
157
164
|
<
|
|
@@ -163,16 +170,16 @@ export type MutatingRpcHelper = {
|
|
|
163
170
|
args: StandardSchemaV1.InferOutput<InputSchema> &
|
|
164
171
|
StandardSchemaV1.InferOutput<FilesSchema>,
|
|
165
172
|
) => R | Promise<R>,
|
|
166
|
-
opts: RpcSharedOpts & {
|
|
173
|
+
opts: RpcSharedOpts<StandardSchemaV1.InferInput<InputSchema>> & {
|
|
167
174
|
schemas: { input: InputSchema; files: FilesSchema; output?: StandardSchemaV1 }
|
|
168
175
|
},
|
|
169
176
|
): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, SuccessBody<R>, InferredErrors<R>>
|
|
170
177
|
<R extends Response, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
|
|
171
178
|
fn: (args: StandardSchemaV1.InferOutput<InputSchema>) => R | Promise<R>,
|
|
172
|
-
opts: RpcSharedOpts & {
|
|
179
|
+
opts: RpcSharedOpts<StandardSchemaV1.InferInput<InputSchema>> & {
|
|
173
180
|
schemas: { input: InputSchema; output?: StandardSchemaV1 }
|
|
174
181
|
},
|
|
175
182
|
): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, SuccessBody<R>, InferredErrors<R>>
|
|
176
|
-
<F extends RpcFn>(fn: F, opts: RpcSharedOpts): RpcOf<F>
|
|
183
|
+
<F extends RpcFn>(fn: F, opts: RpcSharedOpts<RpcArgs<F>>): RpcOf<F>
|
|
177
184
|
<F extends RpcFn>(fn: F): RpcOf<F>
|
|
178
185
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
2
|
The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
|
|
3
3
|
`<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
|
|
4
|
-
stores its raw ref-json text into `window.
|
|
4
|
+
stores its raw ref-json text into `window.__abideSeeds.resume` (the resume partition of the
|
|
5
|
+
one seed manifest, ADR-0048 — the store hydration
|
|
5
6
|
reads, decoding each entry at read time) and swaps the resolved markup into the matching
|
|
6
7
|
`<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary — so the pending shell paints
|
|
7
8
|
instantly and each value lands as it arrives, before the client bundle even loads. It
|
|
@@ -12,7 +13,7 @@ inlines cheaply ahead of the document body.
|
|
|
12
13
|
export const SSR_SWAP_SCRIPT =
|
|
13
14
|
"function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
|
|
14
15
|
"var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
|
|
15
|
-
"if(p&&p.nodeName==='SCRIPT'){
|
|
16
|
+
"if(p&&p.nodeName==='SCRIPT'){var s=window.__abideSeeds=window.__abideSeeds||{};(s.resume=s.resume||{})[id]=p.textContent||'';p.remove();}" +
|
|
16
17
|
"while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
|
|
17
18
|
"if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
|
|
18
19
|
"while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
|
|
@@ -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
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { appNameSlot } from '../../shared/appNameSlot.ts'
|
|
2
2
|
import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
|
|
3
3
|
import { docSnapshotsSlot } from '../../shared/docSnapshotsSlot.ts'
|
|
4
|
-
import {
|
|
4
|
+
import { encodeSeedValue } from '../../shared/encodeSeedValue.ts'
|
|
5
5
|
import { formatTraceparent } from '../../shared/formatTraceparent.ts'
|
|
6
6
|
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'
|
|
@@ -115,12 +116,13 @@ export function createUiPageRenderer({
|
|
|
115
116
|
}
|
|
116
117
|
const cells: Record<string, string> = {}
|
|
117
118
|
for (const { key, value } of entries) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
const encoded = encodeSeedValue(
|
|
120
|
+
value,
|
|
121
|
+
`async cell "${key}" resolved value`,
|
|
122
|
+
'it will re-run on the client instead of hydrating warm',
|
|
123
|
+
)
|
|
124
|
+
if (encoded !== undefined) {
|
|
125
|
+
cells[key] = encoded
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
128
|
return Object.keys(cells).length > 0 ? cells : undefined
|
|
@@ -151,16 +153,43 @@ export function createUiPageRenderer({
|
|
|
151
153
|
) {
|
|
152
154
|
continue
|
|
153
155
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
const encoded = encodeSeedValue(
|
|
157
|
+
snapshot,
|
|
158
|
+
`scope "${id}" state snapshot`,
|
|
159
|
+
'the client re-inits its state cold',
|
|
160
|
+
)
|
|
161
|
+
if (encoded !== undefined) {
|
|
162
|
+
docs[id] = encoded
|
|
159
163
|
}
|
|
160
164
|
}
|
|
161
165
|
return Object.keys(docs).length > 0 ? docs : undefined
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
/* The `sockets` partition of __SSR__: each socket whose retained frame this render read via
|
|
169
|
+
`peek(socket)` (drained from the request-scoped `socketTailsSlot` populated by
|
|
170
|
+
`defineSocket.peek`), keyed by socket NAME, ref-json-encoded. Last write per name wins — a
|
|
171
|
+
socket peeked twice ships its latest read. An unserializable frame is dropped (a warn): that
|
|
172
|
+
socket falls back to a cold client peek (undefined) rather than blanking the payload. Undefined
|
|
173
|
+
when no socket was peeked during the render (the common page). */
|
|
174
|
+
function socketTailSnapshots(): Record<string, string> | undefined {
|
|
175
|
+
const entries = socketTailsSlot.get()?.entries ?? []
|
|
176
|
+
if (entries.length === 0) {
|
|
177
|
+
return undefined
|
|
178
|
+
}
|
|
179
|
+
const sockets: Record<string, string> = {}
|
|
180
|
+
for (const { name, value } of entries) {
|
|
181
|
+
const encoded = encodeSeedValue(
|
|
182
|
+
value,
|
|
183
|
+
`socket "${name}" retained frame`,
|
|
184
|
+
'its client peek() reads undefined at hydration instead of the server value',
|
|
185
|
+
)
|
|
186
|
+
if (encoded !== undefined) {
|
|
187
|
+
sockets[name] = encoded
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return Object.keys(sockets).length > 0 ? sockets : undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
164
193
|
/* Build the __SSR__ <script> the client (startClient) reads on boot. The inline
|
|
165
194
|
(settled) cache partition is computed once by the caller and threaded in, so the
|
|
166
195
|
streaming branch can also drain the pending partition over the same render. */
|
|
@@ -177,6 +206,7 @@ export function createUiPageRenderer({
|
|
|
177
206
|
cache: inline,
|
|
178
207
|
cells: resolvedCellCells(),
|
|
179
208
|
docs: docSeedSnapshots(),
|
|
209
|
+
sockets: socketTailSnapshots(),
|
|
180
210
|
base: base || undefined,
|
|
181
211
|
trace: formatTraceparent(store.trace),
|
|
182
212
|
app: appNameSlot.name,
|
|
@@ -392,10 +422,12 @@ export function createUiPageRenderer({
|
|
|
392
422
|
streamed and the client cold-runs it. An unserializable value emits nothing.
|
|
393
423
|
Read from `store` directly since the stream body runs outside the request ALS. */
|
|
394
424
|
for (const { key, value } of store.streamedCells.entries) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
425
|
+
const encoded = encodeSeedValue(
|
|
426
|
+
value,
|
|
427
|
+
`streaming cell "${key}" settled value`,
|
|
428
|
+
'the client cold-runs its seed instead of adopting the streamed value',
|
|
429
|
+
)
|
|
430
|
+
if (encoded === undefined) {
|
|
399
431
|
continue
|
|
400
432
|
}
|
|
401
433
|
controller.enqueue(
|
|
@@ -410,10 +442,12 @@ export function createUiPageRenderer({
|
|
|
410
442
|
if (seededCellKeys.has(key)) {
|
|
411
443
|
continue
|
|
412
444
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
445
|
+
const encoded = encodeSeedValue(
|
|
446
|
+
value,
|
|
447
|
+
`streamed child's async cell "${key}" resolved value`,
|
|
448
|
+
'it will re-run on the client instead of hydrating warm',
|
|
449
|
+
)
|
|
450
|
+
if (encoded === undefined) {
|
|
417
451
|
continue
|
|
418
452
|
}
|
|
419
453
|
controller.enqueue(
|
|
@@ -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,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The ONE hydration seed manifest (ADR-0048): every passive server→client warm-seed
|
|
3
|
+
partition — await resumes, async-cell values, doc snapshots, socket frames — rides a single
|
|
4
|
+
`globalThis.__abideSeeds` global, kind-partitioned. Each partition is a Record of ref-json
|
|
5
|
+
STRINGS decoded lazily at its read site, so the inline pre-bundle scripts (the SSR swap
|
|
6
|
+
script, the resume seed script) can write entries without the codec. The per-kind module
|
|
7
|
+
names (`RESUME`, `CELL_SEED`, `DOC_SEED`, `SOCKET_SEED`) are views onto these partitions,
|
|
8
|
+
so consumers keep their domain name while the wire carries one global.
|
|
9
|
+
|
|
10
|
+
Each partition initializes with its own `??=` — NOT one whole-object create — because the
|
|
11
|
+
vanilla scripts and the bundle race in either order and a script that ran first may have
|
|
12
|
+
created the root with only its own partition; whoever arrives later must fill the gaps and
|
|
13
|
+
adopt the same references. The live streamed-cell machinery (`STREAMED_CELLS`) and the head
|
|
14
|
+
collector buffer (`__abideResumeCache`) deliberately stay separate: they are apply-timing
|
|
15
|
+
phases (ADR-0040), not passive manifests.
|
|
16
|
+
*/
|
|
17
|
+
type SeedPartitions = {
|
|
18
|
+
resume: Record<string, string>
|
|
19
|
+
cells: Record<string, string>
|
|
20
|
+
docs: Record<string, string>
|
|
21
|
+
sockets: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const globalScope = globalThis as { __abideSeeds?: Partial<SeedPartitions> }
|
|
25
|
+
globalScope.__abideSeeds ??= {}
|
|
26
|
+
const root = globalScope.__abideSeeds
|
|
27
|
+
root.resume ??= {}
|
|
28
|
+
root.cells ??= {}
|
|
29
|
+
root.docs ??= {}
|
|
30
|
+
root.sockets ??= {}
|
|
31
|
+
|
|
32
|
+
// @documentation plumbing
|
|
33
|
+
export const SEEDS: SeedPartitions = root as SeedPartitions
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { SEEDS } from './SEEDS.ts'
|
|
2
|
+
|
|
3
|
+
/* The socket warm-seed manifest: a socket's retained latest frame as read via `peek(socket)` during
|
|
4
|
+
SSR, keyed by the socket's name, as a ref-json-encoded STRING (decoded at the read site in
|
|
5
|
+
`socketProxy`). `startClient` drains `__SSR__.sockets` into here before mount; a hydrating
|
|
6
|
+
`socketProxy` reads its key to seed the socket's `lastFrame` WARM, so `peek(socket)` returns the
|
|
7
|
+
SAME retained value the server rendered instead of `undefined` on the not-yet-connected client —
|
|
8
|
+
killing the SSR/hydration divergence that would otherwise discard the server markup and cold-render.
|
|
9
|
+
|
|
10
|
+
The `sockets` partition of the one `__abideSeeds` manifest (ADR-0048, see SEEDS). */
|
|
11
|
+
// @documentation plumbing
|
|
12
|
+
export const SOCKET_SEED: Record<string, string> = SEEDS.sockets
|
|
@@ -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> {
|