experimental-a2 0.3.0 → 0.5.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/CHANGELOG.md +207 -0
- package/dist/ai-CFNeCrRl.d.ts +334 -0
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +49 -9
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +123 -49
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +233 -86
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +599 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +7 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client-BKlyLiOU.js +1008 -0
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/client-D7mvIXrF.d.ts +191 -0
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +152 -0
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +340 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +3 -1
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +4 -3
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +226 -43
- package/dist/devtools-server.js.map +1 -0
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +135 -28
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +647 -61
- package/dist/http.js.map +1 -0
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/internal-DstsI6Re.js +211 -0
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +3 -2
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +47 -15
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +30 -15
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +79 -0
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +501 -0
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +56 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +228 -0
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/server-C72KOw51.js +1457 -0
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/server-DUF9pjsx.d.ts +261 -0
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +10 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +12 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/{log-memory.js → store-memory.js} +145 -34
- package/dist/store-memory.js.map +1 -0
- package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/{log-postgres.js → store-postgres.js} +214 -24
- package/dist/store-postgres.js.map +1 -0
- package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
- package/dist/store-redis-http.js.map +1 -0
- package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/{log-redis.js → store-redis.js} +101 -39
- package/dist/store-redis.js.map +1 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
- package/dist/store-sqlite.js.map +1 -0
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +6 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +103 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/wire-BFQmSJ-9.js +284 -0
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/01-quickstart.mdx +3 -3
- package/docs/concepts/01-contracts.mdx +18 -13
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +21 -21
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -49
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +76 -57
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +310 -61
- package/docs/guides/06-ai-agents.mdx +252 -74
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +263 -0
- package/docs/guides/10-transports.mdx +140 -0
- package/docs/index.mdx +18 -14
- package/docs/reference/01-api.mdx +839 -110
- package/docs/reference/02-errors.mdx +13 -6
- package/package.json +24 -7
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
- package/dist/internal-gCd5qMry.js +0 -44
- package/dist/log-memory.d.ts +0 -11
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-BcLa4RFL.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
package/src/react.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
/**
|
|
3
|
+
* experimental-a2/react — React bindings over experimental-a2/client.
|
|
4
|
+
*
|
|
5
|
+
* `createReact({ client })` is a factory (like createContext): call it
|
|
6
|
+
* once in a `'use client'` module with a shared A2 client and export the
|
|
7
|
+
* bound pair. Server components import `SessionProvider` from that module
|
|
8
|
+
* as a client reference; client components import `useSession` from the
|
|
9
|
+
* same file. The shared client also makes imperative pushes and provider
|
|
10
|
+
* renders resolve the same live session. Everything is typed by the
|
|
11
|
+
* reducer value — no type arguments, and nothing here ever touches the
|
|
12
|
+
* machine module.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createContext,
|
|
17
|
+
createElement,
|
|
18
|
+
useContext,
|
|
19
|
+
useEffect,
|
|
20
|
+
useMemo,
|
|
21
|
+
useSyncExternalStore,
|
|
22
|
+
type ReactElement,
|
|
23
|
+
type ReactNode,
|
|
24
|
+
} from 'react'
|
|
25
|
+
import {
|
|
26
|
+
createClient,
|
|
27
|
+
type A2Client,
|
|
28
|
+
type Connection,
|
|
29
|
+
type HistoryState,
|
|
30
|
+
type LoadHistoryOptions,
|
|
31
|
+
type PushResult,
|
|
32
|
+
type SessionClient,
|
|
33
|
+
type SessionClientPresence,
|
|
34
|
+
type SessionSnapshot,
|
|
35
|
+
} from './client.ts'
|
|
36
|
+
import type {
|
|
37
|
+
AppendInput,
|
|
38
|
+
ContractEvent,
|
|
39
|
+
EventDefs,
|
|
40
|
+
PresenceDefs,
|
|
41
|
+
PresenceMap,
|
|
42
|
+
PresencePatch,
|
|
43
|
+
WithPresence,
|
|
44
|
+
} from './contract.ts'
|
|
45
|
+
import type { Reducer } from './reducer.ts'
|
|
46
|
+
|
|
47
|
+
export type SessionProviderProps<D extends EventDefs, S> = {
|
|
48
|
+
/** Which session to subscribe to. */
|
|
49
|
+
sessionId: string
|
|
50
|
+
/** Base path of the route exposing GET (stream) and POST (push). */
|
|
51
|
+
api: string
|
|
52
|
+
/** The server-rendered fold — the first paint, no client JS needed. */
|
|
53
|
+
initialState: S
|
|
54
|
+
/** The fold's frontier — the stream resumes exactly there. */
|
|
55
|
+
initialIndex: number
|
|
56
|
+
/** Server-rendered event history through `initialIndex`. */
|
|
57
|
+
initialEvents?: ContractEvent<D>[]
|
|
58
|
+
/**
|
|
59
|
+
* This client's presence identity — required to call `setPresence`.
|
|
60
|
+
* Caller-supplied (a user id, a tab nonce, a guest name): A2 does
|
|
61
|
+
* not invent an identity story.
|
|
62
|
+
*/
|
|
63
|
+
participant?: string
|
|
64
|
+
children?: ReactNode
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type BoundSessionProviderProps<D extends EventDefs, S> = Omit<
|
|
68
|
+
SessionProviderProps<D, S>,
|
|
69
|
+
'api'
|
|
70
|
+
>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The presence members of the hook result — intersected in via
|
|
74
|
+
* `WithPresence`, so they exist exactly when the contract declares
|
|
75
|
+
* presence fields (the reducer is the client's typed handle on it).
|
|
76
|
+
*/
|
|
77
|
+
export type UseSessionPresence<P extends PresenceDefs> = {
|
|
78
|
+
/** The replicated presence map, this client included (local echo). */
|
|
79
|
+
presence: PresenceMap<P>
|
|
80
|
+
/**
|
|
81
|
+
* Fire-and-forget: validated locally, echoed into the map
|
|
82
|
+
* immediately, coalesced on the wire to a fixed cadence (a leading
|
|
83
|
+
* send immediately, then at most one merged send per interval;
|
|
84
|
+
* later values win per field), stamped with the frontier at send
|
|
85
|
+
* time as `seen`. Requires the provider's `participant`.
|
|
86
|
+
*/
|
|
87
|
+
setPresence: (values: PresencePatch<P>['values']) => void
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type UseSessionResult<
|
|
91
|
+
D extends EventDefs,
|
|
92
|
+
S,
|
|
93
|
+
P extends PresenceDefs = Record<never, never>,
|
|
94
|
+
> = WithPresence<P, UseSessionPresence<P>> & {
|
|
95
|
+
/** The live view, folded through the shared reducer. */
|
|
96
|
+
state: S
|
|
97
|
+
/** The raw event feed — observed, seeded, or backscrolled. */
|
|
98
|
+
events: ContractEvent<D>[]
|
|
99
|
+
/** The stream frontier — `lastSeenIndex` for cancellation. */
|
|
100
|
+
index: number
|
|
101
|
+
/**
|
|
102
|
+
* The connection, as a discriminated union — `error` exists only
|
|
103
|
+
* while disconnected; "reconnecting…" is
|
|
104
|
+
* `status === 'connecting' && reconnects > 0`.
|
|
105
|
+
*/
|
|
106
|
+
connection: Connection
|
|
107
|
+
/**
|
|
108
|
+
* Typed optimistic append. Awaiting it gives the server ack;
|
|
109
|
+
* `.confirmed` resolves when the live stream has delivered the batch
|
|
110
|
+
* back. Rejects with the server's A2Error codes.
|
|
111
|
+
*/
|
|
112
|
+
push: (...events: AppendInput<D>[]) => PushResult<D>
|
|
113
|
+
/**
|
|
114
|
+
* Backscroll: fetch a bounded slice of older events into `events`.
|
|
115
|
+
* Defaults page backward 50 at a time from the oldest loaded event.
|
|
116
|
+
* Display data only — `state` and the optimistic overlay never move.
|
|
117
|
+
*/
|
|
118
|
+
loadHistory: (options?: LoadHistoryOptions) => Promise<ContractEvent<D>[]>
|
|
119
|
+
/** Backscroll progress: `complete` means the feed reaches index 1. */
|
|
120
|
+
history: HistoryState
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type A2React<
|
|
124
|
+
D extends EventDefs,
|
|
125
|
+
S,
|
|
126
|
+
P extends PresenceDefs = Record<never, never>,
|
|
127
|
+
> = {
|
|
128
|
+
SessionProvider: (props: SessionProviderProps<D, S>) => ReactElement
|
|
129
|
+
useSession: () => UseSessionResult<D, S, P>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type BoundA2React<
|
|
133
|
+
D extends EventDefs,
|
|
134
|
+
S,
|
|
135
|
+
P extends PresenceDefs = Record<never, never>,
|
|
136
|
+
> = {
|
|
137
|
+
SessionProvider: (props: BoundSessionProviderProps<D, S>) => ReactElement
|
|
138
|
+
useSession: () => UseSessionResult<D, S, P>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createReact<
|
|
142
|
+
D extends EventDefs,
|
|
143
|
+
S,
|
|
144
|
+
P extends PresenceDefs = Record<never, never>,
|
|
145
|
+
>(options: { client: A2Client<D, S, P> }): BoundA2React<D, S, P>
|
|
146
|
+
export function createReact<
|
|
147
|
+
D extends EventDefs,
|
|
148
|
+
S,
|
|
149
|
+
P extends PresenceDefs = Record<never, never>,
|
|
150
|
+
>(options: { reducer: Reducer<D, S, P> }): A2React<D, S, P>
|
|
151
|
+
export function createReact<
|
|
152
|
+
D extends EventDefs,
|
|
153
|
+
S,
|
|
154
|
+
P extends PresenceDefs = Record<never, never>,
|
|
155
|
+
>(
|
|
156
|
+
options: { client: A2Client<D, S, P> } | { reducer: Reducer<D, S, P> },
|
|
157
|
+
): BoundA2React<D, S, P> | A2React<D, S, P> {
|
|
158
|
+
const Context = createContext<SessionClient<D, S, P> | null>(null)
|
|
159
|
+
const providerMounts = new WeakMap<SessionClient<D, S, P> & object, number>()
|
|
160
|
+
const resolveSession: (
|
|
161
|
+
sessionId: string,
|
|
162
|
+
api: string | undefined,
|
|
163
|
+
initialState: S,
|
|
164
|
+
initialIndex: number,
|
|
165
|
+
initialEvents: ContractEvent<D>[] | undefined,
|
|
166
|
+
participant: string | undefined,
|
|
167
|
+
) => SessionClient<D, S, P> =
|
|
168
|
+
'client' in options
|
|
169
|
+
? (
|
|
170
|
+
sessionId,
|
|
171
|
+
_api,
|
|
172
|
+
initialState,
|
|
173
|
+
initialIndex,
|
|
174
|
+
initialEvents,
|
|
175
|
+
participant,
|
|
176
|
+
) =>
|
|
177
|
+
options.client.session(sessionId, {
|
|
178
|
+
initialState,
|
|
179
|
+
initialIndex,
|
|
180
|
+
...(initialEvents === undefined ? {} : { initialEvents }),
|
|
181
|
+
...(participant === undefined ? {} : { participant }),
|
|
182
|
+
})
|
|
183
|
+
: (() => {
|
|
184
|
+
const clients = new Map<string, A2Client<D, S, P>>()
|
|
185
|
+
return (
|
|
186
|
+
sessionId: string,
|
|
187
|
+
api: string | undefined,
|
|
188
|
+
initialState: S,
|
|
189
|
+
initialIndex: number,
|
|
190
|
+
initialEvents: ContractEvent<D>[] | undefined,
|
|
191
|
+
participant: string | undefined,
|
|
192
|
+
) => {
|
|
193
|
+
if (api === undefined) {
|
|
194
|
+
throw new TypeError(
|
|
195
|
+
'SessionProvider requires api when createReact binds a reducer',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
let client = clients.get(api)
|
|
199
|
+
if (!client) {
|
|
200
|
+
client = createClient({ reducer: options.reducer, api })
|
|
201
|
+
clients.set(api, client)
|
|
202
|
+
}
|
|
203
|
+
return client.session(sessionId, {
|
|
204
|
+
initialState,
|
|
205
|
+
initialIndex,
|
|
206
|
+
...(initialEvents === undefined ? {} : { initialEvents }),
|
|
207
|
+
...(participant === undefined ? {} : { participant }),
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
})()
|
|
211
|
+
|
|
212
|
+
function SessionProvider(
|
|
213
|
+
props: SessionProviderProps<D, S> | BoundSessionProviderProps<D, S>,
|
|
214
|
+
): ReactElement {
|
|
215
|
+
const {
|
|
216
|
+
sessionId,
|
|
217
|
+
initialState,
|
|
218
|
+
initialIndex,
|
|
219
|
+
initialEvents,
|
|
220
|
+
participant,
|
|
221
|
+
} = props
|
|
222
|
+
const api = 'api' in props ? props.api : undefined
|
|
223
|
+
const client = useMemo(
|
|
224
|
+
() =>
|
|
225
|
+
resolveSession(
|
|
226
|
+
sessionId,
|
|
227
|
+
api,
|
|
228
|
+
initialState,
|
|
229
|
+
initialIndex,
|
|
230
|
+
initialEvents,
|
|
231
|
+
participant,
|
|
232
|
+
),
|
|
233
|
+
[sessionId, api, initialState, initialIndex, initialEvents, participant],
|
|
234
|
+
)
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
const mounts = providerMounts.get(client) ?? 0
|
|
237
|
+
providerMounts.set(client, mounts + 1)
|
|
238
|
+
if (mounts === 0) client.connect()
|
|
239
|
+
return () => {
|
|
240
|
+
const remaining = (providerMounts.get(client) ?? 1) - 1
|
|
241
|
+
if (remaining === 0) {
|
|
242
|
+
providerMounts.delete(client)
|
|
243
|
+
client.close()
|
|
244
|
+
} else {
|
|
245
|
+
providerMounts.set(client, remaining)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}, [client])
|
|
249
|
+
return createElement(Context.Provider, { value: client }, props.children)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function useSession(): UseSessionResult<D, S, P> {
|
|
253
|
+
const client = useContext(Context)
|
|
254
|
+
if (!client) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
'useSession must be rendered inside its matching SessionProvider',
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
const snapshot: SessionSnapshot<D, S, P> = useSyncExternalStore(
|
|
260
|
+
client.subscribe,
|
|
261
|
+
client.getSnapshot,
|
|
262
|
+
client.getSnapshot,
|
|
263
|
+
)
|
|
264
|
+
return useMemo(() => {
|
|
265
|
+
// Runtime mirror of the WithPresence surface: the client attaches
|
|
266
|
+
// setPresence (and the snapshot carries presence) exactly when the
|
|
267
|
+
// reducer declares presence fields — no runtime-inert members.
|
|
268
|
+
const { setPresence } = client as Partial<SessionClientPresence<P>>
|
|
269
|
+
const { presence } = snapshot as Partial<{ presence: PresenceMap<P> }>
|
|
270
|
+
return {
|
|
271
|
+
state: snapshot.state,
|
|
272
|
+
events: snapshot.events,
|
|
273
|
+
index: snapshot.index,
|
|
274
|
+
connection: snapshot.connection,
|
|
275
|
+
push: client.push,
|
|
276
|
+
loadHistory: client.loadHistory,
|
|
277
|
+
history: snapshot.history,
|
|
278
|
+
...(setPresence && presence ? { presence, setPresence } : {}),
|
|
279
|
+
} as UseSessionResult<D, S, P>
|
|
280
|
+
}, [snapshot, client])
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { SessionProvider, useSession }
|
|
284
|
+
}
|
package/src/reducer.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ContractEvent, EventDefs, PresenceDefs } from './contract.ts'
|
|
2
|
+
import type { StandardSchemaV1 } from './standard-schema.ts'
|
|
3
|
+
import { validateSync } from './validate.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A pure fold over a session's events — `(state, event) => state` —
|
|
7
|
+
* derived from a contract via `contract.reducer(options).fold(fn)`.
|
|
8
|
+
*
|
|
9
|
+
* The name is the reducer's identity. Cached folds (server snapshots,
|
|
10
|
+
* the browser cache) are keyed by it, which makes it the invalidation
|
|
11
|
+
* knob too: changed the fold's logic? Change the name. Snapshots under
|
|
12
|
+
* the old name are ignored and the next read refolds from raw events.
|
|
13
|
+
* See docs/concepts/04-state.mdx.
|
|
14
|
+
*/
|
|
15
|
+
export type Reducer<
|
|
16
|
+
D extends EventDefs = EventDefs,
|
|
17
|
+
S = unknown,
|
|
18
|
+
P extends PresenceDefs = Record<never, never>,
|
|
19
|
+
> = {
|
|
20
|
+
/** Identity and cache key. Rename when the fold's logic changes. */
|
|
21
|
+
readonly name: string
|
|
22
|
+
/** The contract's vocabulary — types every event in the fold. */
|
|
23
|
+
readonly events: Readonly<D>
|
|
24
|
+
/**
|
|
25
|
+
* The contract's presence vocabulary — carried because the reducer
|
|
26
|
+
* is the client's typed handle on the contract. The fold never sees
|
|
27
|
+
* presence.
|
|
28
|
+
*/
|
|
29
|
+
readonly presence: Readonly<P>
|
|
30
|
+
readonly fold: (state: S, event: ContractEvent<D>) => S
|
|
31
|
+
readonly initialState: S
|
|
32
|
+
/**
|
|
33
|
+
* When present: `initialState` was validated against it at
|
|
34
|
+
* definition, and any cached fold a2 rehydrates (server snapshots,
|
|
35
|
+
* the browser cache) is checked against it — failing snapshots are
|
|
36
|
+
* discarded and refolded, catching shape drift a stale name can't.
|
|
37
|
+
*/
|
|
38
|
+
readonly stateSchema: StandardSchemaV1<unknown, S> | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The second step of `contract.reducer` — see the method's doc. */
|
|
42
|
+
export type ReducerBuilder<
|
|
43
|
+
D extends EventDefs,
|
|
44
|
+
S,
|
|
45
|
+
P extends PresenceDefs = Record<never, never>,
|
|
46
|
+
> = {
|
|
47
|
+
fold(fold: (state: S, event: ContractEvent<D>) => S): Reducer<D, S, P>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Internal — reducers are created through `contract.reducer(...).fold(...)`. */
|
|
51
|
+
export function makeReducerBuilder<
|
|
52
|
+
D extends EventDefs,
|
|
53
|
+
P extends PresenceDefs,
|
|
54
|
+
Seed,
|
|
55
|
+
S,
|
|
56
|
+
>(
|
|
57
|
+
events: Readonly<D>,
|
|
58
|
+
presence: Readonly<P>,
|
|
59
|
+
options: {
|
|
60
|
+
name: string
|
|
61
|
+
initialState: Seed
|
|
62
|
+
stateSchema?: StandardSchemaV1<Seed, S>
|
|
63
|
+
},
|
|
64
|
+
): ReducerBuilder<D, S, P> {
|
|
65
|
+
const { name } = options
|
|
66
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
'reducer name must be a non-empty string — it identifies the reducer and keys cached snapshots',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Without a schema, S = Seed (the type parameter default), so the
|
|
73
|
+
// seed is the state. With one, the seed is the schema's input and the
|
|
74
|
+
// validated output (defaults applied) becomes the fold's actual seed.
|
|
75
|
+
let initialState = options.initialState as unknown as S
|
|
76
|
+
if (options.stateSchema) {
|
|
77
|
+
const result = validateSync(
|
|
78
|
+
options.stateSchema,
|
|
79
|
+
options.initialState,
|
|
80
|
+
'the stateSchema',
|
|
81
|
+
)
|
|
82
|
+
if (result.issues) {
|
|
83
|
+
throw new TypeError(
|
|
84
|
+
`reducer initialState does not match its stateSchema: ${result.issues
|
|
85
|
+
.map((issue) => issue.message)
|
|
86
|
+
.join('; ')}`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
initialState = result.value as S
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
fold(fold) {
|
|
94
|
+
if (typeof fold !== 'function') {
|
|
95
|
+
throw new TypeError('reducer fold must be a function')
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
name,
|
|
99
|
+
events,
|
|
100
|
+
presence,
|
|
101
|
+
fold,
|
|
102
|
+
initialState,
|
|
103
|
+
stateSchema: options.stateSchema as
|
|
104
|
+
StandardSchemaV1<unknown, S> | undefined,
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One lazy in-flight attempt: concurrent callers share it, fulfilled values
|
|
3
|
+
* stay cached, and rejected attempts are evicted so a later call can retry.
|
|
4
|
+
*/
|
|
5
|
+
export type RetryableLazy<T> = {
|
|
6
|
+
get(): Promise<T>
|
|
7
|
+
peek(): Promise<T> | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const retryableLazy = <T>(
|
|
11
|
+
initialize: () => Promise<T>,
|
|
12
|
+
): RetryableLazy<T> => {
|
|
13
|
+
let current: Promise<T> | null = null
|
|
14
|
+
|
|
15
|
+
const get = (): Promise<T> => {
|
|
16
|
+
if (current) return current
|
|
17
|
+
|
|
18
|
+
const attempt = Promise.resolve().then(initialize)
|
|
19
|
+
current = attempt
|
|
20
|
+
void attempt.catch(() => {
|
|
21
|
+
if (current === attempt) current = null
|
|
22
|
+
})
|
|
23
|
+
return attempt
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { get, peek: () => current }
|
|
27
|
+
}
|