experimental-a2 0.5.1 → 0.6.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 +45 -0
- package/dist/{client-D7mvIXrF.d.ts → client-CdMqi7mC.d.ts} +23 -12
- package/dist/client-CdMqi7mC.d.ts.map +1 -0
- package/dist/{client-BKlyLiOU.js → client-Dj5d3SP_.js} +37 -19
- package/dist/client-Dj5d3SP_.js.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/react.d.ts +41 -7
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +74 -35
- package/dist/react.js.map +1 -1
- package/docs/guides/03-react.mdx +98 -1
- package/docs/guides/09-presence.mdx +5 -2
- package/docs/reference/01-api.mdx +45 -4
- package/package.json +1 -1
- package/src/client.ts +69 -33
- package/src/react.ts +118 -44
- package/dist/client-BKlyLiOU.js.map +0 -1
- package/dist/client-D7mvIXrF.d.ts.map +0 -1
package/dist/react.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { t as createClient } from "./client-
|
|
2
|
+
import { t as createClient } from "./client-Dj5d3SP_.js";
|
|
3
3
|
import { createContext, createElement, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
|
|
4
4
|
//#region src/react.ts
|
|
5
5
|
/**
|
|
@@ -13,10 +13,15 @@ import { createContext, createElement, useContext, useEffect, useMemo, useSyncEx
|
|
|
13
13
|
* renders resolve the same live session. Everything is typed by the
|
|
14
14
|
* reducer value — no type arguments, and nothing here ever touches the
|
|
15
15
|
* machine module.
|
|
16
|
+
*
|
|
17
|
+
* The standalone `useSession(client, sessionId, options?)` is the
|
|
18
|
+
* provider-less primitive for client-first apps: identity-mapped
|
|
19
|
+
* handles, one leased stream per session, and an app-owned hydration
|
|
20
|
+
* input — the hook holds the stream until the app hands it a
|
|
21
|
+
* `{ state, index }` fold.
|
|
16
22
|
*/
|
|
17
23
|
function createReact(options) {
|
|
18
24
|
const Context = createContext(null);
|
|
19
|
-
const providerMounts = /* @__PURE__ */ new WeakMap();
|
|
20
25
|
const resolveSession = "client" in options ? (sessionId, _api, initialState, initialIndex, initialEvents, participant) => options.client.session(sessionId, {
|
|
21
26
|
initialState,
|
|
22
27
|
initialIndex,
|
|
@@ -53,48 +58,82 @@ function createReact(options) {
|
|
|
53
58
|
initialEvents,
|
|
54
59
|
participant
|
|
55
60
|
]);
|
|
56
|
-
|
|
57
|
-
const mounts = providerMounts.get(client) ?? 0;
|
|
58
|
-
providerMounts.set(client, mounts + 1);
|
|
59
|
-
if (mounts === 0) client.connect();
|
|
60
|
-
return () => {
|
|
61
|
-
const remaining = (providerMounts.get(client) ?? 1) - 1;
|
|
62
|
-
if (remaining === 0) {
|
|
63
|
-
providerMounts.delete(client);
|
|
64
|
-
client.close();
|
|
65
|
-
} else providerMounts.set(client, remaining);
|
|
66
|
-
};
|
|
67
|
-
}, [client]);
|
|
61
|
+
useSessionLifecycle(client, true);
|
|
68
62
|
return createElement(Context.Provider, { value: client }, props.children);
|
|
69
63
|
}
|
|
70
|
-
function
|
|
64
|
+
function useBoundSession() {
|
|
71
65
|
const client = useContext(Context);
|
|
72
66
|
if (!client) throw new Error("useSession must be rendered inside its matching SessionProvider");
|
|
73
|
-
|
|
74
|
-
return useMemo(() => {
|
|
75
|
-
const { setPresence } = client;
|
|
76
|
-
const { presence } = snapshot;
|
|
77
|
-
return {
|
|
78
|
-
state: snapshot.state,
|
|
79
|
-
events: snapshot.events,
|
|
80
|
-
index: snapshot.index,
|
|
81
|
-
connection: snapshot.connection,
|
|
82
|
-
push: client.push,
|
|
83
|
-
loadHistory: client.loadHistory,
|
|
84
|
-
history: snapshot.history,
|
|
85
|
-
...setPresence && presence ? {
|
|
86
|
-
presence,
|
|
87
|
-
setPresence
|
|
88
|
-
} : {}
|
|
89
|
-
};
|
|
90
|
-
}, [snapshot, client]);
|
|
67
|
+
return useSessionResult(client);
|
|
91
68
|
}
|
|
92
69
|
return {
|
|
93
70
|
SessionProvider,
|
|
94
|
-
useSession
|
|
71
|
+
useSession: useBoundSession
|
|
95
72
|
};
|
|
96
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Hold a stream lease while mounted and `live` — the client refcounts
|
|
76
|
+
* leases per handle, so however many providers and hooks mount one
|
|
77
|
+
* session, the first connects and the last release closes
|
|
78
|
+
* (StrictMode-safe: the lease is retaken on remount). A standalone
|
|
79
|
+
* hook without a `hydrate` value passes `live: false` and holds the
|
|
80
|
+
* stream — connecting an unhydrated session would replay the whole
|
|
81
|
+
* log, and the library never picks the expensive path silently.
|
|
82
|
+
*/
|
|
83
|
+
function useSessionLifecycle(session, live) {
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!live) return;
|
|
86
|
+
return session.connect();
|
|
87
|
+
}, [session, live]);
|
|
88
|
+
}
|
|
89
|
+
function useSessionResult(session) {
|
|
90
|
+
const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
|
|
91
|
+
return useMemo(() => {
|
|
92
|
+
const { setPresence } = session;
|
|
93
|
+
const { presence } = snapshot;
|
|
94
|
+
return {
|
|
95
|
+
state: snapshot.state,
|
|
96
|
+
events: snapshot.events,
|
|
97
|
+
index: snapshot.index,
|
|
98
|
+
connection: snapshot.connection,
|
|
99
|
+
push: session.push,
|
|
100
|
+
loadHistory: session.loadHistory,
|
|
101
|
+
history: snapshot.history,
|
|
102
|
+
...setPresence && presence ? {
|
|
103
|
+
presence,
|
|
104
|
+
setPresence
|
|
105
|
+
} : {}
|
|
106
|
+
};
|
|
107
|
+
}, [snapshot, session]);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The standalone, provider-less hook — for apps whose components reach
|
|
111
|
+
* sessions ad hoc (a sidebar of channel sessions, a user session read
|
|
112
|
+
* from a menu). Takes the shared client and a session id; the handle
|
|
113
|
+
* is identity-mapped, so every hook and provider mounting the same
|
|
114
|
+
* session shares one runtime and one leased stream. Hydration is
|
|
115
|
+
* app-owned: pass `hydrate` when your fetch lands; until then the
|
|
116
|
+
* stream is held, and your data layer's loading state is the pending
|
|
117
|
+
* state.
|
|
118
|
+
*/
|
|
119
|
+
function useSession(client, sessionId, options) {
|
|
120
|
+
const { participant, hydrate } = options ?? {};
|
|
121
|
+
const session = useMemo(() => client.session(sessionId, {
|
|
122
|
+
...hydrate === void 0 ? {} : {
|
|
123
|
+
initialState: hydrate.state,
|
|
124
|
+
initialIndex: hydrate.index
|
|
125
|
+
},
|
|
126
|
+
...participant === void 0 ? {} : { participant }
|
|
127
|
+
}), [
|
|
128
|
+
client,
|
|
129
|
+
sessionId,
|
|
130
|
+
hydrate,
|
|
131
|
+
participant
|
|
132
|
+
]);
|
|
133
|
+
useSessionLifecycle(session, hydrate !== void 0);
|
|
134
|
+
return useSessionResult(session);
|
|
135
|
+
}
|
|
97
136
|
//#endregion
|
|
98
|
-
export { createReact };
|
|
137
|
+
export { createReact, useSession };
|
|
99
138
|
|
|
100
139
|
//# sourceMappingURL=react.js.map
|
package/dist/react.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["'use client'\n/**\n * experimental-a2/react — React bindings over experimental-a2/client.\n *\n * `createReact({ client })` is a factory (like createContext): call it\n * once in a `'use client'` module with a shared A2 client and export the\n * bound pair. Server components import `SessionProvider` from that module\n * as a client reference; client components import `useSession` from the\n * same file. The shared client also makes imperative pushes and provider\n * renders resolve the same live session. Everything is typed by the\n * reducer value — no type arguments, and nothing here ever touches the\n * machine module.\n */\n\nimport {\n createContext,\n createElement,\n useContext,\n useEffect,\n useMemo,\n useSyncExternalStore,\n type ReactElement,\n type ReactNode,\n} from 'react'\nimport {\n createClient,\n type A2Client,\n type Connection,\n type HistoryState,\n type LoadHistoryOptions,\n type PushResult,\n type SessionClient,\n type SessionClientPresence,\n type SessionSnapshot,\n} from './client.ts'\nimport type {\n AppendInput,\n ContractEvent,\n EventDefs,\n PresenceDefs,\n PresenceMap,\n PresencePatch,\n WithPresence,\n} from './contract.ts'\nimport type { Reducer } from './reducer.ts'\n\nexport type SessionProviderProps<D extends EventDefs, S> = {\n /** Which session to subscribe to. */\n sessionId: string\n /** Base path of the route exposing GET (stream) and POST (push). */\n api: string\n /** The server-rendered fold — the first paint, no client JS needed. */\n initialState: S\n /** The fold's frontier — the stream resumes exactly there. */\n initialIndex: number\n /** Server-rendered event history through `initialIndex`. */\n initialEvents?: ContractEvent<D>[]\n /**\n * This client's presence identity — required to call `setPresence`.\n * Caller-supplied (a user id, a tab nonce, a guest name): A2 does\n * not invent an identity story.\n */\n participant?: string\n children?: ReactNode\n}\n\nexport type BoundSessionProviderProps<D extends EventDefs, S> = Omit<\n SessionProviderProps<D, S>,\n 'api'\n>\n\n/**\n * The presence members of the hook result — intersected in via\n * `WithPresence`, so they exist exactly when the contract declares\n * presence fields (the reducer is the client's typed handle on it).\n */\nexport type UseSessionPresence<P extends PresenceDefs> = {\n /** The replicated presence map, this client included (local echo). */\n presence: PresenceMap<P>\n /**\n * Fire-and-forget: validated locally, echoed into the map\n * immediately, coalesced on the wire to a fixed cadence (a leading\n * send immediately, then at most one merged send per interval;\n * later values win per field), stamped with the frontier at send\n * time as `seen`. Requires the provider's `participant`.\n */\n setPresence: (values: PresencePatch<P>['values']) => void\n}\n\nexport type UseSessionResult<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = WithPresence<P, UseSessionPresence<P>> & {\n /** The live view, folded through the shared reducer. */\n state: S\n /** The raw event feed — observed, seeded, or backscrolled. */\n events: ContractEvent<D>[]\n /** The stream frontier — `lastSeenIndex` for cancellation. */\n index: number\n /**\n * The connection, as a discriminated union — `error` exists only\n * while disconnected; \"reconnecting…\" is\n * `status === 'connecting' && reconnects > 0`.\n */\n connection: Connection\n /**\n * Typed optimistic append. Awaiting it gives the server ack;\n * `.confirmed` resolves when the live stream has delivered the batch\n * back. Rejects with the server's A2Error codes.\n */\n push: (...events: AppendInput<D>[]) => PushResult<D>\n /**\n * Backscroll: fetch a bounded slice of older events into `events`.\n * Defaults page backward 50 at a time from the oldest loaded event.\n * Display data only — `state` and the optimistic overlay never move.\n */\n loadHistory: (options?: LoadHistoryOptions) => Promise<ContractEvent<D>[]>\n /** Backscroll progress: `complete` means the feed reaches index 1. */\n history: HistoryState\n}\n\nexport type A2React<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = {\n SessionProvider: (props: SessionProviderProps<D, S>) => ReactElement\n useSession: () => UseSessionResult<D, S, P>\n}\n\nexport type BoundA2React<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = {\n SessionProvider: (props: BoundSessionProviderProps<D, S>) => ReactElement\n useSession: () => UseSessionResult<D, S, P>\n}\n\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(options: { client: A2Client<D, S, P> }): BoundA2React<D, S, P>\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(options: { reducer: Reducer<D, S, P> }): A2React<D, S, P>\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(\n options: { client: A2Client<D, S, P> } | { reducer: Reducer<D, S, P> },\n): BoundA2React<D, S, P> | A2React<D, S, P> {\n const Context = createContext<SessionClient<D, S, P> | null>(null)\n const providerMounts = new WeakMap<SessionClient<D, S, P> & object, number>()\n const resolveSession: (\n sessionId: string,\n api: string | undefined,\n initialState: S,\n initialIndex: number,\n initialEvents: ContractEvent<D>[] | undefined,\n participant: string | undefined,\n ) => SessionClient<D, S, P> =\n 'client' in options\n ? (\n sessionId,\n _api,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n ) =>\n options.client.session(sessionId, {\n initialState,\n initialIndex,\n ...(initialEvents === undefined ? {} : { initialEvents }),\n ...(participant === undefined ? {} : { participant }),\n })\n : (() => {\n const clients = new Map<string, A2Client<D, S, P>>()\n return (\n sessionId: string,\n api: string | undefined,\n initialState: S,\n initialIndex: number,\n initialEvents: ContractEvent<D>[] | undefined,\n participant: string | undefined,\n ) => {\n if (api === undefined) {\n throw new TypeError(\n 'SessionProvider requires api when createReact binds a reducer',\n )\n }\n let client = clients.get(api)\n if (!client) {\n client = createClient({ reducer: options.reducer, api })\n clients.set(api, client)\n }\n return client.session(sessionId, {\n initialState,\n initialIndex,\n ...(initialEvents === undefined ? {} : { initialEvents }),\n ...(participant === undefined ? {} : { participant }),\n })\n }\n })()\n\n function SessionProvider(\n props: SessionProviderProps<D, S> | BoundSessionProviderProps<D, S>,\n ): ReactElement {\n const {\n sessionId,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n } = props\n const api = 'api' in props ? props.api : undefined\n const client = useMemo(\n () =>\n resolveSession(\n sessionId,\n api,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n ),\n [sessionId, api, initialState, initialIndex, initialEvents, participant],\n )\n useEffect(() => {\n const mounts = providerMounts.get(client) ?? 0\n providerMounts.set(client, mounts + 1)\n if (mounts === 0) client.connect()\n return () => {\n const remaining = (providerMounts.get(client) ?? 1) - 1\n if (remaining === 0) {\n providerMounts.delete(client)\n client.close()\n } else {\n providerMounts.set(client, remaining)\n }\n }\n }, [client])\n return createElement(Context.Provider, { value: client }, props.children)\n }\n\n function useSession(): UseSessionResult<D, S, P> {\n const client = useContext(Context)\n if (!client) {\n throw new Error(\n 'useSession must be rendered inside its matching SessionProvider',\n )\n }\n const snapshot: SessionSnapshot<D, S, P> = useSyncExternalStore(\n client.subscribe,\n client.getSnapshot,\n client.getSnapshot,\n )\n return useMemo(() => {\n // Runtime mirror of the WithPresence surface: the client attaches\n // setPresence (and the snapshot carries presence) exactly when the\n // reducer declares presence fields — no runtime-inert members.\n const { setPresence } = client as Partial<SessionClientPresence<P>>\n const { presence } = snapshot as Partial<{ presence: PresenceMap<P> }>\n return {\n state: snapshot.state,\n events: snapshot.events,\n index: snapshot.index,\n connection: snapshot.connection,\n push: client.push,\n loadHistory: client.loadHistory,\n history: snapshot.history,\n ...(setPresence && presence ? { presence, setPresence } : {}),\n } as UseSessionResult<D, S, P>\n }, [snapshot, client])\n }\n\n return { SessionProvider, useSession }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAsJA,SAAgB,YAKd,SAC0C;CAC1C,MAAM,UAAU,cAA6C,IAAI;CACjE,MAAM,iCAAiB,IAAI,QAAiD;CAC5E,MAAM,iBAQJ,YAAY,WAEN,WACA,MACA,cACA,cACA,eACA,gBAEA,QAAQ,OAAO,QAAQ,WAAW;EAChC;EACA;EACA,GAAI,kBAAkB,KAAA,IAAY,CAAC,IAAI,EAAE,cAAc;EACvD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC,WACI;EACL,MAAM,0BAAU,IAAI,IAA+B;EACnD,QACE,WACA,KACA,cACA,cACA,eACA,gBACG;GACH,IAAI,QAAQ,KAAA,GACV,MAAM,IAAI,UACR,+DACF;GAEF,IAAI,SAAS,QAAQ,IAAI,GAAG;GAC5B,IAAI,CAAC,QAAQ;IACX,SAAS,aAAa;KAAE,SAAS,QAAQ;KAAS;IAAI,CAAC;IACvD,QAAQ,IAAI,KAAK,MAAM;GACzB;GACA,OAAO,OAAO,QAAQ,WAAW;IAC/B;IACA;IACA,GAAI,kBAAkB,KAAA,IAAY,CAAC,IAAI,EAAE,cAAc;IACvD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;GACrD,CAAC;EACH;CACF,EAAA,CAAG;CAET,SAAS,gBACP,OACc;EACd,MAAM,EACJ,WACA,cACA,cACA,eACA,gBACE;EACJ,MAAM,MAAM,SAAS,QAAQ,MAAM,MAAM,KAAA;EACzC,MAAM,SAAS,cAEX,eACE,WACA,KACA,cACA,cACA,eACA,WACF,GACF;GAAC;GAAW;GAAK;GAAc;GAAc;GAAe;EAAW,CACzE;EACA,gBAAgB;GACd,MAAM,SAAS,eAAe,IAAI,MAAM,KAAK;GAC7C,eAAe,IAAI,QAAQ,SAAS,CAAC;GACrC,IAAI,WAAW,GAAG,OAAO,QAAQ;GACjC,aAAa;IACX,MAAM,aAAa,eAAe,IAAI,MAAM,KAAK,KAAK;IACtD,IAAI,cAAc,GAAG;KACnB,eAAe,OAAO,MAAM;KAC5B,OAAO,MAAM;IACf,OACE,eAAe,IAAI,QAAQ,SAAS;GAExC;EACF,GAAG,CAAC,MAAM,CAAC;EACX,OAAO,cAAc,QAAQ,UAAU,EAAE,OAAO,OAAO,GAAG,MAAM,QAAQ;CAC1E;CAEA,SAAS,aAAwC;EAC/C,MAAM,SAAS,WAAW,OAAO;EACjC,IAAI,CAAC,QACH,MAAM,IAAI,MACR,iEACF;EAEF,MAAM,WAAqC,qBACzC,OAAO,WACP,OAAO,aACP,OAAO,WACT;EACA,OAAO,cAAc;GAInB,MAAM,EAAE,gBAAgB;GACxB,MAAM,EAAE,aAAa;GACrB,OAAO;IACL,OAAO,SAAS;IAChB,QAAQ,SAAS;IACjB,OAAO,SAAS;IAChB,YAAY,SAAS;IACrB,MAAM,OAAO;IACb,aAAa,OAAO;IACpB,SAAS,SAAS;IAClB,GAAI,eAAe,WAAW;KAAE;KAAU;IAAY,IAAI,CAAC;GAC7D;EACF,GAAG,CAAC,UAAU,MAAM,CAAC;CACvB;CAEA,OAAO;EAAE;EAAiB;CAAW;AACvC"}
|
|
1
|
+
{"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["'use client'\n/**\n * experimental-a2/react — React bindings over experimental-a2/client.\n *\n * `createReact({ client })` is a factory (like createContext): call it\n * once in a `'use client'` module with a shared A2 client and export the\n * bound pair. Server components import `SessionProvider` from that module\n * as a client reference; client components import `useSession` from the\n * same file. The shared client also makes imperative pushes and provider\n * renders resolve the same live session. Everything is typed by the\n * reducer value — no type arguments, and nothing here ever touches the\n * machine module.\n *\n * The standalone `useSession(client, sessionId, options?)` is the\n * provider-less primitive for client-first apps: identity-mapped\n * handles, one leased stream per session, and an app-owned hydration\n * input — the hook holds the stream until the app hands it a\n * `{ state, index }` fold.\n */\n\nimport {\n createContext,\n createElement,\n useContext,\n useEffect,\n useMemo,\n useSyncExternalStore,\n type ReactElement,\n type ReactNode,\n} from 'react'\nimport {\n createClient,\n type A2Client,\n type Connection,\n type HistoryState,\n type LoadHistoryOptions,\n type PushResult,\n type SessionClient,\n type SessionClientPresence,\n type SessionSnapshot,\n} from './client.ts'\nimport type {\n AppendInput,\n ContractEvent,\n EventDefs,\n PresenceDefs,\n PresenceMap,\n PresencePatch,\n WithPresence,\n} from './contract.ts'\nimport type { Reducer } from './reducer.ts'\n\nexport type UseSessionOptions<S> = {\n /** Overrides the client's `participant` for this session — for apps\n * whose identity is only known at mount time. Bound once per\n * session handle. */\n participant?: string\n /**\n * The server fold this session mounts from: the `{ state, index }`\n * pair `session.state(reducer)` returns, fetched by the app through\n * its own route. Atomic on purpose — the fold and its frontier\n * travel together or not at all. While `undefined` (the app's fetch\n * has not landed), the hook holds the stream; when the value\n * arrives, the session hydrates through the ordinary re-hydration\n * seam and the stream connects at that frontier. To fold from the log's start\n * deliberately, hand the fold's true starting point:\n * `{ state: reducer.initialState, index: 0 }` — state at index 0 is\n * the reducer's seed by definition, so the explicit replay needs no\n * special vocabulary. There is no pending/ready echo on the result:\n * whether the fold has been handed over is this option — the\n * caller's own input — so the data layer's loading state IS the\n * pending state.\n */\n hydrate?: { state: S; index: number } | undefined\n}\n\nexport type SessionProviderProps<D extends EventDefs, S> = {\n /** Which session to subscribe to. */\n sessionId: string\n /** Base path of the route exposing GET (stream) and POST (push). */\n api: string\n /** The server-rendered fold — the first paint, no client JS needed. */\n initialState: S\n /** The fold's frontier — the stream resumes exactly there. */\n initialIndex: number\n /** Server-rendered event history through `initialIndex`. */\n initialEvents?: ContractEvent<D>[]\n /** Overrides the client's `participant` for this session — for apps\n * whose identity is only known at mount time. */\n participant?: string\n children?: ReactNode\n}\n\nexport type BoundSessionProviderProps<D extends EventDefs, S> = Omit<\n SessionProviderProps<D, S>,\n 'api'\n>\n\n/**\n * The presence members of the hook result — intersected in via\n * `WithPresence`, so they exist exactly when the contract declares\n * presence fields (the reducer is the client's typed handle on it).\n */\nexport type UseSessionPresence<P extends PresenceDefs> = {\n /** The replicated presence map, this client included (local echo). */\n presence: PresenceMap<P>\n /**\n * Fire-and-forget: validated locally, echoed into the map\n * immediately, coalesced on the wire to a fixed cadence (a leading\n * send immediately, then at most one merged send per interval;\n * later values win per field), stamped with the frontier at send\n * time as `seen`. Requires the provider's `participant`.\n */\n setPresence: (values: PresencePatch<P>['values']) => void\n}\n\nexport type UseSessionResult<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = WithPresence<P, UseSessionPresence<P>> & {\n /** The live view, folded through the shared reducer. */\n state: S\n /** The raw event feed — observed, seeded, or backscrolled. */\n events: ContractEvent<D>[]\n /** The stream frontier — `lastSeenIndex` for cancellation. */\n index: number\n /**\n * The connection, as a discriminated union — `error` exists only\n * while disconnected; \"reconnecting…\" is\n * `status === 'connecting' && reconnects > 0`.\n */\n connection: Connection\n /**\n * Typed optimistic append. Awaiting it gives the server ack;\n * `.confirmed` resolves when the live stream has delivered the batch\n * back. Rejects with the server's A2Error codes.\n */\n push: (...events: AppendInput<D>[]) => PushResult<D>\n /**\n * Backscroll: fetch a bounded slice of older events into `events`.\n * Defaults page backward 50 at a time from the oldest loaded event.\n * Display data only — `state` and the optimistic overlay never move.\n */\n loadHistory: (options?: LoadHistoryOptions) => Promise<ContractEvent<D>[]>\n /** Backscroll progress: `complete` means the feed reaches index 1. */\n history: HistoryState\n}\n\nexport type A2React<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = {\n SessionProvider: (props: SessionProviderProps<D, S>) => ReactElement\n useSession: () => UseSessionResult<D, S, P>\n}\n\nexport type BoundA2React<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = {\n SessionProvider: (props: BoundSessionProviderProps<D, S>) => ReactElement\n useSession: () => UseSessionResult<D, S, P>\n}\n\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(options: { client: A2Client<D, S, P> }): BoundA2React<D, S, P>\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(options: { reducer: Reducer<D, S, P> }): A2React<D, S, P>\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(\n options: { client: A2Client<D, S, P> } | { reducer: Reducer<D, S, P> },\n): BoundA2React<D, S, P> | A2React<D, S, P> {\n const Context = createContext<SessionClient<D, S, P> | null>(null)\n const resolveSession: (\n sessionId: string,\n api: string | undefined,\n initialState: S,\n initialIndex: number,\n initialEvents: ContractEvent<D>[] | undefined,\n participant: string | undefined,\n ) => SessionClient<D, S, P> =\n 'client' in options\n ? (\n sessionId,\n _api,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n ) =>\n options.client.session(sessionId, {\n initialState,\n initialIndex,\n ...(initialEvents === undefined ? {} : { initialEvents }),\n ...(participant === undefined ? {} : { participant }),\n })\n : (() => {\n const clients = new Map<string, A2Client<D, S, P>>()\n return (\n sessionId: string,\n api: string | undefined,\n initialState: S,\n initialIndex: number,\n initialEvents: ContractEvent<D>[] | undefined,\n participant: string | undefined,\n ) => {\n if (api === undefined) {\n throw new TypeError(\n 'SessionProvider requires api when createReact binds a reducer',\n )\n }\n let client = clients.get(api)\n if (!client) {\n client = createClient({ reducer: options.reducer, api })\n clients.set(api, client)\n }\n return client.session(sessionId, {\n initialState,\n initialIndex,\n ...(initialEvents === undefined ? {} : { initialEvents }),\n ...(participant === undefined ? {} : { participant }),\n })\n }\n })()\n\n function SessionProvider(\n props: SessionProviderProps<D, S> | BoundSessionProviderProps<D, S>,\n ): ReactElement {\n const {\n sessionId,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n } = props\n const api = 'api' in props ? props.api : undefined\n const client = useMemo(\n () =>\n resolveSession(\n sessionId,\n api,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n ),\n [sessionId, api, initialState, initialIndex, initialEvents, participant],\n )\n useSessionLifecycle(client, true)\n return createElement(Context.Provider, { value: client }, props.children)\n }\n\n function useBoundSession(): UseSessionResult<D, S, P> {\n const client = useContext(Context)\n if (!client) {\n throw new Error(\n 'useSession must be rendered inside its matching SessionProvider',\n )\n }\n return useSessionResult(client)\n }\n\n return { SessionProvider, useSession: useBoundSession }\n}\n\n// ── the shared session lifecycle ───────────────────────────────\n\n/**\n * Hold a stream lease while mounted and `live` — the client refcounts\n * leases per handle, so however many providers and hooks mount one\n * session, the first connects and the last release closes\n * (StrictMode-safe: the lease is retaken on remount). A standalone\n * hook without a `hydrate` value passes `live: false` and holds the\n * stream — connecting an unhydrated session would replay the whole\n * log, and the library never picks the expensive path silently.\n */\nfunction useSessionLifecycle<D extends EventDefs, S, P extends PresenceDefs>(\n session: SessionClient<D, S, P>,\n live: boolean,\n): void {\n useEffect(() => {\n if (!live) return\n return session.connect()\n }, [session, live])\n}\n\nfunction useSessionResult<D extends EventDefs, S, P extends PresenceDefs>(\n session: SessionClient<D, S, P>,\n): UseSessionResult<D, S, P> {\n const snapshot: SessionSnapshot<D, S, P> = useSyncExternalStore(\n session.subscribe,\n session.getSnapshot,\n session.getSnapshot,\n )\n return useMemo(() => {\n // Runtime mirror of the WithPresence surface: the client attaches\n // setPresence (and the snapshot carries presence) exactly when the\n // reducer declares presence fields — no runtime-inert members.\n const { setPresence } = session as Partial<SessionClientPresence<P>>\n const { presence } = snapshot as Partial<{ presence: PresenceMap<P> }>\n return {\n state: snapshot.state,\n events: snapshot.events,\n index: snapshot.index,\n connection: snapshot.connection,\n push: session.push,\n loadHistory: session.loadHistory,\n history: snapshot.history,\n ...(setPresence && presence ? { presence, setPresence } : {}),\n } as UseSessionResult<D, S, P>\n }, [snapshot, session])\n}\n\n/**\n * The standalone, provider-less hook — for apps whose components reach\n * sessions ad hoc (a sidebar of channel sessions, a user session read\n * from a menu). Takes the shared client and a session id; the handle\n * is identity-mapped, so every hook and provider mounting the same\n * session shares one runtime and one leased stream. Hydration is\n * app-owned: pass `hydrate` when your fetch lands; until then the\n * stream is held, and your data layer's loading state is the pending\n * state.\n */\nexport function useSession<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(\n client: A2Client<D, S, P>,\n sessionId: string,\n options?: UseSessionOptions<S>,\n): UseSessionResult<D, S, P> {\n const { participant, hydrate } = options ?? {}\n const session = useMemo(\n () =>\n client.session(sessionId, {\n ...(hydrate === undefined\n ? {}\n : { initialState: hydrate.state, initialIndex: hydrate.index }),\n ...(participant === undefined ? {} : { participant }),\n }),\n [client, sessionId, hydrate, participant],\n )\n useSessionLifecycle(session, hydrate !== undefined)\n return useSessionResult(session)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAiLA,SAAgB,YAKd,SAC0C;CAC1C,MAAM,UAAU,cAA6C,IAAI;CACjE,MAAM,iBAQJ,YAAY,WAEN,WACA,MACA,cACA,cACA,eACA,gBAEA,QAAQ,OAAO,QAAQ,WAAW;EAChC;EACA;EACA,GAAI,kBAAkB,KAAA,IAAY,CAAC,IAAI,EAAE,cAAc;EACvD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC,WACI;EACL,MAAM,0BAAU,IAAI,IAA+B;EACnD,QACE,WACA,KACA,cACA,cACA,eACA,gBACG;GACH,IAAI,QAAQ,KAAA,GACV,MAAM,IAAI,UACR,+DACF;GAEF,IAAI,SAAS,QAAQ,IAAI,GAAG;GAC5B,IAAI,CAAC,QAAQ;IACX,SAAS,aAAa;KAAE,SAAS,QAAQ;KAAS;IAAI,CAAC;IACvD,QAAQ,IAAI,KAAK,MAAM;GACzB;GACA,OAAO,OAAO,QAAQ,WAAW;IAC/B;IACA;IACA,GAAI,kBAAkB,KAAA,IAAY,CAAC,IAAI,EAAE,cAAc;IACvD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;GACrD,CAAC;EACH;CACF,EAAA,CAAG;CAET,SAAS,gBACP,OACc;EACd,MAAM,EACJ,WACA,cACA,cACA,eACA,gBACE;EACJ,MAAM,MAAM,SAAS,QAAQ,MAAM,MAAM,KAAA;EACzC,MAAM,SAAS,cAEX,eACE,WACA,KACA,cACA,cACA,eACA,WACF,GACF;GAAC;GAAW;GAAK;GAAc;GAAc;GAAe;EAAW,CACzE;EACA,oBAAoB,QAAQ,IAAI;EAChC,OAAO,cAAc,QAAQ,UAAU,EAAE,OAAO,OAAO,GAAG,MAAM,QAAQ;CAC1E;CAEA,SAAS,kBAA6C;EACpD,MAAM,SAAS,WAAW,OAAO;EACjC,IAAI,CAAC,QACH,MAAM,IAAI,MACR,iEACF;EAEF,OAAO,iBAAiB,MAAM;CAChC;CAEA,OAAO;EAAE;EAAiB,YAAY;CAAgB;AACxD;;;;;;;;;;AAaA,SAAS,oBACP,SACA,MACM;CACN,gBAAgB;EACd,IAAI,CAAC,MAAM;EACX,OAAO,QAAQ,QAAQ;CACzB,GAAG,CAAC,SAAS,IAAI,CAAC;AACpB;AAEA,SAAS,iBACP,SAC2B;CAC3B,MAAM,WAAqC,qBACzC,QAAQ,WACR,QAAQ,aACR,QAAQ,WACV;CACA,OAAO,cAAc;EAInB,MAAM,EAAE,gBAAgB;EACxB,MAAM,EAAE,aAAa;EACrB,OAAO;GACL,OAAO,SAAS;GAChB,QAAQ,SAAS;GACjB,OAAO,SAAS;GAChB,YAAY,SAAS;GACrB,MAAM,QAAQ;GACd,aAAa,QAAQ;GACrB,SAAS,SAAS;GAClB,GAAI,eAAe,WAAW;IAAE;IAAU;GAAY,IAAI,CAAC;EAC7D;CACF,GAAG,CAAC,UAAU,OAAO,CAAC;AACxB;;;;;;;;;;;AAYA,SAAgB,WAKd,QACA,WACA,SAC2B;CAC3B,MAAM,EAAE,aAAa,YAAY,WAAW,CAAC;CAC7C,MAAM,UAAU,cAEZ,OAAO,QAAQ,WAAW;EACxB,GAAI,YAAY,KAAA,IACZ,CAAC,IACD;GAAE,cAAc,QAAQ;GAAO,cAAc,QAAQ;EAAM;EAC/D,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC,GACH;EAAC;EAAQ;EAAW;EAAS;CAAW,CAC1C;CACA,oBAAoB,SAAS,YAAY,KAAA,CAAS;CAClD,OAAO,iBAAiB,OAAO;AACjC"}
|
package/docs/guides/03-react.mdx
CHANGED
|
@@ -218,7 +218,11 @@ That identity is an in-memory L1, not another source of truth. Repeated
|
|
|
218
218
|
`session(id)` calls reuse it while active and for five idle minutes by
|
|
219
219
|
default. A newer server fold advances it, pending pushes stay overlaid,
|
|
220
220
|
and a stale server render cannot rewind it. The optional IndexedDB cache
|
|
221
|
-
is the L2: it survives reloads; the memory runtime does not.
|
|
221
|
+
is the L2: it survives reloads; the memory runtime does not. The
|
|
222
|
+
stream is shared the same way: `connect()` takes a lease and returns
|
|
223
|
+
its release, so however many hooks, providers, or plain calls hold one
|
|
224
|
+
session open, the first lease connects and the last release closes
|
|
225
|
+
(`close()` is the hard stop).
|
|
222
226
|
|
|
223
227
|
One detail worth knowing: every push carries a client-generated event id.
|
|
224
228
|
That id is how the ack finds its optimistic entry, and it makes retrying
|
|
@@ -234,6 +238,99 @@ revisits paint from the local copy, the stream resumes from the cached
|
|
|
234
238
|
frontier, and offline pushes queue and replay. See
|
|
235
239
|
[Local-first](/guides/local-first).
|
|
236
240
|
|
|
241
|
+
## Client-first sessions
|
|
242
|
+
|
|
243
|
+
Everything above assumes a server component hands the first fold down.
|
|
244
|
+
A client-rendered app often has nothing to hand: a sidebar showing ten
|
|
245
|
+
channel sessions, a user session read from deep inside a menu, panes
|
|
246
|
+
that mount from client-side navigation. No server fold, and no natural
|
|
247
|
+
provider boundary for components that reach sessions ad hoc.
|
|
248
|
+
|
|
249
|
+
For that shape, `experimental-a2/react` exports a standalone hook. Any
|
|
250
|
+
component can mount any session, no provider needed:
|
|
251
|
+
|
|
252
|
+
```tsx app/orders/[orderId]/order-badge.tsx
|
|
253
|
+
'use client'
|
|
254
|
+
import { useSession } from 'experimental-a2/react'
|
|
255
|
+
import { ordersClient } from './session'
|
|
256
|
+
|
|
257
|
+
export function OrderBadge({ orderId }: { orderId: string }) {
|
|
258
|
+
// your fetch (SWR, a router loader, a parent's payload): anything
|
|
259
|
+
// that lands the { state, index } your route returns (below), e.g.
|
|
260
|
+
// const { data } = useSWR(`/api/orders/${orderId}/state`)
|
|
261
|
+
const data = undefined // still loading
|
|
262
|
+
|
|
263
|
+
const { state } = useSession(ordersClient, orderId, { hydrate: data })
|
|
264
|
+
if (!data) return <span>…</span> // your loading state IS the pending state
|
|
265
|
+
return <span>{state.status}</span>
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The fold comes from your own route, and the route is one line of A2:
|
|
270
|
+
|
|
271
|
+
```ts app/api/orders/[orderId]/state/route.ts
|
|
272
|
+
import { ordersServer } from '@/server/orders'
|
|
273
|
+
import { ordersReducer } from '@/reducer'
|
|
274
|
+
|
|
275
|
+
export async function GET(
|
|
276
|
+
_req: Request,
|
|
277
|
+
{ params }: { params: Promise<{ orderId: string }> },
|
|
278
|
+
) {
|
|
279
|
+
const { orderId } = await params
|
|
280
|
+
// here's where you'd do auth, or any other checks
|
|
281
|
+
return Response.json(
|
|
282
|
+
await ordersServer.session(orderId).state(ordersReducer),
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Note what is not on the wire: a reducer name. The route calls
|
|
288
|
+
`session.state(reducer)` with the reducer by reference, the same
|
|
289
|
+
module your client bundle folds with. That matters because renaming a
|
|
290
|
+
reducer is the [snapshot invalidation knob](/concepts/state): if the
|
|
291
|
+
name traveled on the wire, every rename would open a version-skew
|
|
292
|
+
window between deployed clients and servers. By reference, the knob
|
|
293
|
+
stays free. And unlike a history slice, which is immutable once read
|
|
294
|
+
and earns a standard wire shape, a state read is a moving snapshot;
|
|
295
|
+
there is nothing for the library to standardize, so the read stays
|
|
296
|
+
behind your route, under your auth and caching policy.
|
|
297
|
+
|
|
298
|
+
`hydrate` is one atomic option: the `{ state, index }` pair
|
|
299
|
+
`session.state(reducer)` returns. State and its frontier travel
|
|
300
|
+
together or not at all; a half-present handoff cannot compile. While
|
|
301
|
+
it is `undefined` (your fetch has not landed), the hook holds the
|
|
302
|
+
stream and `state` is the reducer's `initialState`. There is no
|
|
303
|
+
pending flag on the result: whether the fold has been handed over is
|
|
304
|
+
your own input, so your data layer's loading state is the pending
|
|
305
|
+
state. When the value arrives, the session hydrates
|
|
306
|
+
and the stream connects at that frontier. The hold is deliberate:
|
|
307
|
+
connecting without a fold means replaying the whole log through the
|
|
308
|
+
browser, and the library never picks the expensive path silently. When
|
|
309
|
+
a full replay is what you want (a short log, a debug view), hand the
|
|
310
|
+
fold's true starting point:
|
|
311
|
+
`hydrate: { state: ordersReducer.initialState, index: 0 }`. State at
|
|
312
|
+
index 0 is the reducer's seed by definition, so the explicit replay
|
|
313
|
+
needs no special vocabulary; the stream then delivers every event
|
|
314
|
+
live.
|
|
315
|
+
|
|
316
|
+
The rest behaves like the provider. The handle is identity-mapped
|
|
317
|
+
(every hook and provider mounting the same session shares one runtime)
|
|
318
|
+
and each mount holds a lease on the shared stream (see
|
|
319
|
+
[the client identity note](#the-client-component)). A later, further
|
|
320
|
+
fold handed to the same session
|
|
321
|
+
advances it and a stale one is ignored, the same never-move-backward
|
|
322
|
+
rule as everywhere else. For presence, identity comes from the
|
|
323
|
+
client's `participant` (set once on `createClient`) or the hook's
|
|
324
|
+
`participant` option, which overrides it. The
|
|
325
|
+
result is exactly the shape the bound hook returns. There is no
|
|
326
|
+
loading or error member: the
|
|
327
|
+
library performs no fetch here, so your data layer's states are the
|
|
328
|
+
states, retried however your data layer retries.
|
|
329
|
+
|
|
330
|
+
`SessionProvider` keeps its required `initialState`/`initialIndex`.
|
|
331
|
+
The provider is the server-handoff tool; the hook is the client-first
|
|
332
|
+
tool.
|
|
333
|
+
|
|
237
334
|
## Keep the backend out of the bundle
|
|
238
335
|
|
|
239
336
|
The split is structural, not disciplinary. Core `experimental-a2` (the contract,
|
|
@@ -94,8 +94,11 @@ presence-only push acks `[]`.
|
|
|
94
94
|
|
|
95
95
|
## The browser
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
nonce, the playground's guest name).
|
|
97
|
+
Presence needs a `participant` id (yours to mint: a user id, a tab
|
|
98
|
+
nonce, the playground's guest name). State it once on
|
|
99
|
+
`createClient({ participant })` when the app knows it at module scope,
|
|
100
|
+
or pass it to the provider, whose `participant` prop overrides the
|
|
101
|
+
client's. The hook grows two members. The
|
|
99
102
|
session module is the usual pair from
|
|
100
103
|
[Live UI](/guides/react#the-session-module), bound to a reducer that
|
|
101
104
|
carries the canvas contract:
|
|
@@ -650,7 +650,7 @@ transition and the destination should adopt the same optimistic session.
|
|
|
650
650
|
| `initialState` | server-rendered state |
|
|
651
651
|
| `initialIndex` | the fold's frontier, where the stream resumes |
|
|
652
652
|
| `initialEvents` | optional earlier raw events for a history UI |
|
|
653
|
-
| `participant` |
|
|
653
|
+
| `participant` | overrides the client's `participant`; one of the two is required to call `setPresence` |
|
|
654
654
|
|
|
655
655
|
Opens the stream on mount, closes it on unmount, reconnects with
|
|
656
656
|
backoff from the current frontier. `participant` binds at the session's
|
|
@@ -706,8 +706,9 @@ invert). No ack, no `confirmed`, no retry.
|
|
|
706
706
|
Both members exist only when the contract declares `presence`; their
|
|
707
707
|
value and field types come from its schemas, through the reducer, with
|
|
708
708
|
no type arguments (the reducer is the client's typed handle on the
|
|
709
|
-
contract; it still never folds presence). `setPresence` requires
|
|
710
|
-
|
|
709
|
+
contract; it still never folds presence). `setPresence` requires a
|
|
710
|
+
`participant`: the client's default, or the provider's override. If
|
|
711
|
+
the map holds only your own echo with
|
|
711
712
|
participants active, the GET route forgot `presence: true`. See
|
|
712
713
|
[Presence](/guides/presence).
|
|
713
714
|
|
|
@@ -754,6 +755,37 @@ connection as dead (aborts it and reconnects), so `live` means bytes
|
|
|
754
755
|
are actually flowing, not "the socket hasn't errored yet". See
|
|
755
756
|
[Live UI](/guides/react).
|
|
756
757
|
|
|
758
|
+
### `useSession(client, sessionId, options?)`
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
useSession(client: A2Client, sessionId: string, options?: {
|
|
762
|
+
participant?: string // overrides the client's participant
|
|
763
|
+
hydrate?: { state: S; index: number }
|
|
764
|
+
}): UseSessionResult
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
The standalone, provider-less hook. It returns the same result shape
|
|
768
|
+
as the bound hook, for apps whose components reach sessions ad hoc (a
|
|
769
|
+
sidebar of channel sessions, a user session read from a menu). The
|
|
770
|
+
handle is identity-mapped: every hook and provider mounting the same
|
|
771
|
+
session shares one runtime. The stream is refcounted: the first mount
|
|
772
|
+
connects, the last unmount closes (StrictMode-safe).
|
|
773
|
+
|
|
774
|
+
`hydrate` is the hydration input, one atomic option: the
|
|
775
|
+
`{ state, index }` pair `session.state(reducer)` returns, fetched by
|
|
776
|
+
your app through its own route and handed over whenever it lands.
|
|
777
|
+
While it is `undefined`, the hook holds the stream (connecting without
|
|
778
|
+
a fold would replay the whole log). There is no pending flag on the
|
|
779
|
+
result: whether the fold has been handed over is your own input, so
|
|
780
|
+
your data layer's loading state is the pending state.
|
|
781
|
+
When it arrives, the session hydrates under the usual
|
|
782
|
+
never-move-backward rule and the stream connects at that frontier.
|
|
783
|
+
For a deliberate full replay, hand the fold's true starting point:
|
|
784
|
+
`{ state: reducer.initialState, index: 0 }`. State at index 0 is the
|
|
785
|
+
reducer's seed by definition, so the explicit replay needs no special
|
|
786
|
+
vocabulary. See
|
|
787
|
+
[Client-first sessions](/guides/react#client-first-sessions).
|
|
788
|
+
|
|
757
789
|
## `experimental-a2/ai`
|
|
758
790
|
|
|
759
791
|
### `agent(options)`
|
|
@@ -1035,6 +1067,7 @@ createClient(options: {
|
|
|
1035
1067
|
reducer: Reducer
|
|
1036
1068
|
api: ClientApi
|
|
1037
1069
|
gcTime?: number // idle session lifetime; 5 minutes by default
|
|
1070
|
+
participant?: string // default presence identity for every session
|
|
1038
1071
|
}): A2Client
|
|
1039
1072
|
|
|
1040
1073
|
type ClientApi =
|
|
@@ -1049,11 +1082,19 @@ queue with ack/rollback, and the local fold. `client.session(id, {
|
|
|
1049
1082
|
initialState?, initialIndex?, initialEvents?, participant? })` returns a
|
|
1050
1083
|
handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
|
|
1051
1084
|
contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
|
|
1085
|
+
`connect()` takes a lease on the live stream and returns its release:
|
|
1086
|
+
leases refcount per handle (the first connects, releasing the last
|
|
1087
|
+
closes, releasing twice is a no-op), so independent consumers of one
|
|
1088
|
+
identity-mapped handle never fight over the stream. `close()` is the
|
|
1089
|
+
hard stop: it drops every outstanding lease and closes now; a later
|
|
1090
|
+
`connect()` starts fresh.
|
|
1052
1091
|
Snapshots carry `state`, `events`, `index`, `history`, and `connection`
|
|
1053
1092
|
(the same fields `useSession` exposes), and `push` returns the same
|
|
1054
1093
|
ack-then-`confirmed` result. On contracts that declare `presence` the handle also carries
|
|
1055
1094
|
`setPresence()` and snapshots carry the `presence` map, exactly like
|
|
1056
|
-
the hook; `participant` is the identity `setPresence` sends under
|
|
1095
|
+
the hook; `participant` is the identity `setPresence` sends under:
|
|
1096
|
+
stated once on `createClient` as the default for every handle, or per
|
|
1097
|
+
session as the override. Use
|
|
1057
1098
|
it directly from any other framework, or none.
|
|
1058
1099
|
|
|
1059
1100
|
Within one `A2Client`, repeated `session(id)` calls return the same live
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -177,13 +177,19 @@ export type SessionClient<
|
|
|
177
177
|
* this throws a TypeError there.
|
|
178
178
|
*/
|
|
179
179
|
loadHistory(options?: LoadHistoryOptions): Promise<ContractEvent<D>[]>
|
|
180
|
-
/** Open the live stream (idempotent while open). Reconnects with
|
|
181
|
-
* backoff and resumes from the frontier until `close()`. */
|
|
182
|
-
connect(): void
|
|
183
180
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
181
|
+
* Take a lease on the live stream and return its release. Leases
|
|
182
|
+
* refcount per handle — the first connects, releasing the last
|
|
183
|
+
* closes — so independent consumers of one identity-mapped handle
|
|
184
|
+
* (two hooks, a hook plus vanilla code) never fight over the
|
|
185
|
+
* stream. Releasing twice is a no-op. While any lease is held the
|
|
186
|
+
* stream reconnects with backoff and resumes from the frontier.
|
|
187
|
+
*/
|
|
188
|
+
connect(): () => void
|
|
189
|
+
/**
|
|
190
|
+
* The hard stop: drops every outstanding lease and closes the
|
|
191
|
+
* stream now. Not terminal — a later `connect()` starts fresh from
|
|
192
|
+
* the current frontier.
|
|
187
193
|
*/
|
|
188
194
|
close(): void
|
|
189
195
|
}
|
|
@@ -193,11 +199,8 @@ export type SessionOptions<D extends EventDefs, S> = {
|
|
|
193
199
|
initialIndex?: number
|
|
194
200
|
/** Server-rendered history through `initialIndex`. Seeds the event feed. */
|
|
195
201
|
initialEvents?: ContractEvent<D>[]
|
|
196
|
-
/**
|
|
197
|
-
*
|
|
198
|
-
* Caller-supplied (a user id, a tab nonce, a guest name): A2 does
|
|
199
|
-
* not invent an identity story. Bound once per session handle.
|
|
200
|
-
*/
|
|
202
|
+
/** Overrides the client's `participant` for this session handle.
|
|
203
|
+
* Bound once per handle. */
|
|
201
204
|
participant?: string
|
|
202
205
|
}
|
|
203
206
|
|
|
@@ -263,6 +266,14 @@ export type CreateClientOptions<
|
|
|
263
266
|
/** How long an idle session keeps its in-memory identity, in
|
|
264
267
|
* milliseconds. Defaults to five minutes; `Infinity` disables GC. */
|
|
265
268
|
gcTime?: number
|
|
269
|
+
/**
|
|
270
|
+
* This client's presence identity — required to call `setPresence`.
|
|
271
|
+
* Caller-supplied (a user id, a tab nonce, a guest name): A2 does
|
|
272
|
+
* not invent an identity story; the app states it once here. The
|
|
273
|
+
* default for every session handle this client creates;
|
|
274
|
+
* `session(id, { participant })` overrides it per handle.
|
|
275
|
+
*/
|
|
276
|
+
participant?: string
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
const PUSH_ATTEMPTS = 3
|
|
@@ -1022,7 +1033,7 @@ export function createClient<
|
|
|
1022
1033
|
// stream; the own entry is the local echo — server copies of self
|
|
1023
1034
|
// are ignored (the echo is at least as new, and comparing it to
|
|
1024
1035
|
// server stamps would put two clocks in one order).
|
|
1025
|
-
let participant = sessionOptions?.participant
|
|
1036
|
+
let participant = sessionOptions?.participant ?? options.participant
|
|
1026
1037
|
// Participant and field keys arrive off the wire, so the map and
|
|
1027
1038
|
// every entry are built null-prototype — see `nullProtoRecord`.
|
|
1028
1039
|
let presenceState: PresenceMap = nullProtoRecord()
|
|
@@ -1292,7 +1303,7 @@ export function createClient<
|
|
|
1292
1303
|
const setPresence = (values: PresencePatch<P>['values']): void => {
|
|
1293
1304
|
if (participant === undefined) {
|
|
1294
1305
|
throw new TypeError(
|
|
1295
|
-
'setPresence requires a participant — pass one in the session options (the SessionProvider participant prop)',
|
|
1306
|
+
'setPresence requires a participant — pass one to createClient({ participant }) or in the session options (the SessionProvider/useSession participant prop)',
|
|
1296
1307
|
)
|
|
1297
1308
|
}
|
|
1298
1309
|
const validated: Record<string, unknown> = nullProtoRecord()
|
|
@@ -1600,11 +1611,34 @@ export function createClient<
|
|
|
1600
1611
|
|
|
1601
1612
|
// ── the live stream ──────────────────────────────────────────
|
|
1602
1613
|
// A run-generation model instead of a terminal `closed` flag:
|
|
1603
|
-
//
|
|
1614
|
+
// stopping bumps the generation (the running loop notices and
|
|
1604
1615
|
// exits), connect() starts a fresh one. Reentrant by design.
|
|
1616
|
+
// Consumers hold leases: connect() counts one and returns its
|
|
1617
|
+
// release, the last release stops the stream, and close() is the
|
|
1618
|
+
// hard stop — it drops every lease and stops now (bumping
|
|
1619
|
+
// `leaseEpoch` makes the outstanding releases inert).
|
|
1605
1620
|
let generation = 0
|
|
1606
1621
|
let active = false
|
|
1607
1622
|
let abort: AbortController | null = null
|
|
1623
|
+
let leases = 0
|
|
1624
|
+
let leaseEpoch = 0
|
|
1625
|
+
|
|
1626
|
+
const stopStream = (): void => {
|
|
1627
|
+
touch(sessionId)
|
|
1628
|
+
if (!active) return
|
|
1629
|
+
active = false
|
|
1630
|
+
generation += 1 // the running loop notices and exits
|
|
1631
|
+
abort?.abort()
|
|
1632
|
+
abort = null
|
|
1633
|
+
// A pending trailing send dies with the stream — losing one is
|
|
1634
|
+
// fine by definition; sets made while closed accumulate in
|
|
1635
|
+
// `presenceResend` and repaint after the next connect.
|
|
1636
|
+
clearTimeout(presenceTimer)
|
|
1637
|
+
presenceTimer = undefined
|
|
1638
|
+
presenceBuffer = null
|
|
1639
|
+
status = 'closed'
|
|
1640
|
+
notify()
|
|
1641
|
+
}
|
|
1608
1642
|
|
|
1609
1643
|
const runStream = async (run: number): Promise<void> => {
|
|
1610
1644
|
let backoff = STREAM_TIMINGS.reconnectBaseMs
|
|
@@ -1727,27 +1761,29 @@ export function createClient<
|
|
|
1727
1761
|
loadHistory,
|
|
1728
1762
|
connect() {
|
|
1729
1763
|
touch(sessionId)
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1764
|
+
leases += 1
|
|
1765
|
+
const epoch = leaseEpoch
|
|
1766
|
+
if (!active) {
|
|
1767
|
+
active = true
|
|
1768
|
+
status = 'connecting'
|
|
1769
|
+
notify()
|
|
1770
|
+
void runStream(generation)
|
|
1771
|
+
}
|
|
1772
|
+
let released = false
|
|
1773
|
+
return () => {
|
|
1774
|
+
// Idempotent, and inert after a hard close() — a stale
|
|
1775
|
+
// release must not touch leases taken since.
|
|
1776
|
+
if (released || leaseEpoch !== epoch) return
|
|
1777
|
+
released = true
|
|
1778
|
+
touch(sessionId)
|
|
1779
|
+
leases -= 1
|
|
1780
|
+
if (leases === 0) stopStream()
|
|
1781
|
+
}
|
|
1735
1782
|
},
|
|
1736
1783
|
close() {
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
generation += 1 // the running loop notices and exits
|
|
1741
|
-
abort?.abort()
|
|
1742
|
-
abort = null
|
|
1743
|
-
// A pending trailing send dies with the stream — losing one is
|
|
1744
|
-
// fine by definition; sets made while closed accumulate in
|
|
1745
|
-
// `presenceResend` and repaint after the next connect.
|
|
1746
|
-
clearTimeout(presenceTimer)
|
|
1747
|
-
presenceTimer = undefined
|
|
1748
|
-
presenceBuffer = null
|
|
1749
|
-
status = 'closed'
|
|
1750
|
-
notify()
|
|
1784
|
+
leases = 0
|
|
1785
|
+
leaseEpoch += 1
|
|
1786
|
+
stopStream()
|
|
1751
1787
|
},
|
|
1752
1788
|
hydrate(next) {
|
|
1753
1789
|
touch(sessionId)
|