experimental-a2 0.0.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 +128 -0
- package/dist/ai-server.browser.d.ts +1 -0
- package/dist/ai-server.browser.js +4 -0
- package/dist/ai-server.d.ts +65 -0
- package/dist/ai-server.js +494 -0
- package/dist/ai.d.ts +282 -0
- package/dist/ai.js +922 -0
- package/dist/cache-indexeddb.d.ts +1 -0
- package/dist/cache-indexeddb.js +0 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.js +410 -0
- package/dist/contract-B0kAXoaL.js +60 -0
- package/dist/contract-DL8btVd9.d.ts +161 -0
- package/dist/devtools-server.browser.d.ts +1 -0
- package/dist/devtools-server.browser.js +4 -0
- package/dist/devtools-server.d.ts +22 -0
- package/dist/devtools-server.js +1087 -0
- package/dist/errors-BJRMd-h6.js +23 -0
- package/dist/errors-xL_JTXsY.d.ts +20 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +119 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/inspection-E7qbD0Xj.js +10 -0
- package/dist/internal-Dm8Ejnud.js +36 -0
- package/dist/log-Dg1I8NRr.d.ts +245 -0
- package/dist/log-memory.d.ts +11 -0
- package/dist/log-memory.js +345 -0
- package/dist/log-polling-RO7kclzR.js +83 -0
- package/dist/log-postgres.d.ts +40 -0
- package/dist/log-postgres.js +628 -0
- package/dist/log-redis.d.ts +31 -0
- package/dist/log-redis.js +711 -0
- package/dist/log-sqlite.d.ts +17 -0
- package/dist/log-sqlite.js +450 -0
- package/dist/log-yJbXUf72.js +5 -0
- package/dist/otel.d.ts +12 -0
- package/dist/otel.js +41 -0
- package/dist/react.d.ts +54 -0
- package/dist/react.js +85 -0
- package/dist/recovery-vercel.d.ts +60 -0
- package/dist/recovery-vercel.js +120 -0
- package/dist/retryable-lazy-DZWmHpii.js +19 -0
- package/dist/server-DYsnKTTy.js +780 -0
- package/dist/server.browser.d.ts +1 -0
- package/dist/server.browser.js +11 -0
- package/dist/server.d.ts +136 -0
- package/dist/server.js +2 -0
- package/dist/telemetry-C78al20p.d.ts +32 -0
- package/dist/validate-XKT4FSNn.js +28 -0
- package/dist/wire-2QpU1EtJ.js +62 -0
- package/docs/01-quickstart.mdx +214 -0
- package/docs/concepts/01-contracts.mdx +138 -0
- package/docs/concepts/02-handlers.mdx +146 -0
- package/docs/concepts/03-durability.mdx +230 -0
- package/docs/concepts/04-state.mdx +133 -0
- package/docs/guides/01-timers.mdx +85 -0
- package/docs/guides/02-cancellation.mdx +107 -0
- package/docs/guides/03-react.mdx +234 -0
- package/docs/guides/04-local-first.mdx +88 -0
- package/docs/guides/05-production.mdx +179 -0
- package/docs/guides/06-ai-agents.mdx +659 -0
- package/docs/guides/07-devtools.mdx +101 -0
- package/docs/guides/08-application-data.mdx +114 -0
- package/docs/index.mdx +282 -0
- package/docs/reference/01-api.mdx +637 -0
- package/docs/reference/02-errors.mdx +77 -0
- package/package.json +111 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
File without changes
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { i as EventDefs, r as ContractEvent, s as Reducer, t as AppendInput } from "./contract-DL8btVd9.js";
|
|
2
|
+
//#region src/client.d.ts
|
|
3
|
+
type ConnectionStatus = "idle" | "connecting" | "live" | "closed";
|
|
4
|
+
/**
|
|
5
|
+
* The connection, as a discriminated union — impossible states are
|
|
6
|
+
* unrepresentable: an error only exists while disconnected,
|
|
7
|
+
* `reconnects` only once a connection has been attempted.
|
|
8
|
+
* "Reconnecting…" is `status === 'connecting' && reconnects > 0`.
|
|
9
|
+
*/
|
|
10
|
+
type Connection = {
|
|
11
|
+
status: "idle";
|
|
12
|
+
} | {
|
|
13
|
+
status: "connecting";
|
|
14
|
+
/** Drops of an established stream so far. `0` = first connect. */
|
|
15
|
+
reconnects: number;
|
|
16
|
+
/** Why the last connection ended; `null` on the first connect. */
|
|
17
|
+
error: Error | null;
|
|
18
|
+
} | {
|
|
19
|
+
status: "live";
|
|
20
|
+
reconnects: number;
|
|
21
|
+
} | {
|
|
22
|
+
status: "closed";
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* What `push` returns: resolves at the server ack (exactly like a
|
|
26
|
+
* plain promise — `await push(...)` gives the acked events), and
|
|
27
|
+
* carries `confirmed` for the later moment when the live stream has
|
|
28
|
+
* delivered the whole batch back and the optimistic overlay entry
|
|
29
|
+
* retired — the view now shows server truth. `confirmed` is lazy:
|
|
30
|
+
* never accessed, never created. A rejected push rejects both.
|
|
31
|
+
*/
|
|
32
|
+
type PushResult<D extends EventDefs> = Promise<ContractEvent<D>[]> & {
|
|
33
|
+
readonly confirmed: Promise<ContractEvent<D>[]>;
|
|
34
|
+
};
|
|
35
|
+
/** One immutable view of the session — stable identity between changes. */
|
|
36
|
+
type SessionSnapshot<D extends EventDefs, S> = {
|
|
37
|
+
/** The live view: server events folded, optimistic pushes applied. */
|
|
38
|
+
state: S;
|
|
39
|
+
/** The observed feed `state` is folded from — server truth plus
|
|
40
|
+
* pending optimistic events (provisional indexes past the frontier). */
|
|
41
|
+
events: ContractEvent<D>[];
|
|
42
|
+
/** The stream frontier: the last server-confirmed index. This is the
|
|
43
|
+
* `lastSeenIndex` cancellation wants. */
|
|
44
|
+
index: number;
|
|
45
|
+
connection: Connection;
|
|
46
|
+
};
|
|
47
|
+
type SessionClient<D extends EventDefs, S> = {
|
|
48
|
+
readonly sessionId: string;
|
|
49
|
+
subscribe(listener: () => void): () => void;
|
|
50
|
+
getSnapshot(): SessionSnapshot<D, S>;
|
|
51
|
+
/**
|
|
52
|
+
* Optimistic append: validates locally against the reducer's event
|
|
53
|
+
* schemas (instant `INVALID_PAYLOAD`, no flicker), applies to the
|
|
54
|
+
* local fold, POSTs, swaps in the ack, rolls back on rejection.
|
|
55
|
+
* Auto-retries only `LOG_UNAVAILABLE`. Resolves with the appended
|
|
56
|
+
* events as the server recorded them.
|
|
57
|
+
*/
|
|
58
|
+
push(...events: AppendInput<D>[]): PushResult<D>;
|
|
59
|
+
/** Open the live stream (idempotent while open). Reconnects with
|
|
60
|
+
* backoff and resumes from the frontier until `close()`. */
|
|
61
|
+
connect(): void;
|
|
62
|
+
/**
|
|
63
|
+
* Stop the live stream. Not terminal: `connect()` starts it again
|
|
64
|
+
* from the current frontier — which is what makes the React
|
|
65
|
+
* StrictMode mount dance (setup → cleanup → setup) work.
|
|
66
|
+
*/
|
|
67
|
+
close(): void;
|
|
68
|
+
};
|
|
69
|
+
type SessionOptions<D extends EventDefs, S> = {
|
|
70
|
+
initialState?: S;
|
|
71
|
+
initialIndex?: number;
|
|
72
|
+
/** Server-rendered history through `initialIndex`. Seeds the event feed. */
|
|
73
|
+
initialEvents?: ContractEvent<D>[];
|
|
74
|
+
};
|
|
75
|
+
type A2Client<D extends EventDefs, S> = {
|
|
76
|
+
session(sessionId: string, options?: SessionOptions<D, S>): SessionClient<D, S>;
|
|
77
|
+
};
|
|
78
|
+
type CreateClientOptions<D extends EventDefs, S> = {
|
|
79
|
+
reducer: Reducer<D, S>;
|
|
80
|
+
/** Base path (or absolute URL) of the route exposing GET/POST. */
|
|
81
|
+
api: string;
|
|
82
|
+
/** Injectable fetch — defaults to the global. */
|
|
83
|
+
fetch?: typeof globalThis.fetch;
|
|
84
|
+
/** How long an idle session keeps its in-memory identity, in
|
|
85
|
+
* milliseconds. Defaults to five minutes; `Infinity` disables GC. */
|
|
86
|
+
gcTime?: number;
|
|
87
|
+
};
|
|
88
|
+
declare function createClient<D extends EventDefs, S>(options: CreateClientOptions<D, S>): A2Client<D, S>;
|
|
89
|
+
//#endregion
|
|
90
|
+
export { A2Client, Connection, ConnectionStatus, CreateClientOptions, PushResult, SessionClient, SessionOptions, SessionSnapshot, createClient };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { n as validateSync } from "./validate-XKT4FSNn.js";
|
|
2
|
+
import { t as A2Error } from "./errors-BJRMd-h6.js";
|
|
3
|
+
import { r as STREAM_TIMINGS } from "./internal-Dm8Ejnud.js";
|
|
4
|
+
import { i as eventFromWire, o as isWireEvent, t as errorFromWire } from "./wire-2QpU1EtJ.js";
|
|
5
|
+
//#region src/client.ts
|
|
6
|
+
/**
|
|
7
|
+
* a2/client — the framework-agnostic session client.
|
|
8
|
+
*
|
|
9
|
+
* Everything the browser needs to read a session live and push
|
|
10
|
+
* optimistically, with no framework attached: the SSE subscription with
|
|
11
|
+
* frontier resume and reconnection, the optimistic push queue with
|
|
12
|
+
* ack/rollback, and the local fold through the same reducer the server
|
|
13
|
+
* uses. `a2/react`'s `createReact` is a thin binding over it, and the
|
|
14
|
+
* store contract (`subscribe`/`getSnapshot`) is exactly what
|
|
15
|
+
* `useSyncExternalStore` wants.
|
|
16
|
+
*
|
|
17
|
+
* The client is a replica, never an access path — the routes it talks
|
|
18
|
+
* to authorize every read and write.
|
|
19
|
+
*/
|
|
20
|
+
const PUSH_ATTEMPTS = 3;
|
|
21
|
+
const RECONNECT_BASE_MS = 500;
|
|
22
|
+
const RECONNECT_MAX_MS = 5e3;
|
|
23
|
+
const DEFAULT_GC_TIME_MS = 3e5;
|
|
24
|
+
function createClient(options) {
|
|
25
|
+
const { reducer, api } = options;
|
|
26
|
+
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
27
|
+
const gcTime = options.gcTime ?? DEFAULT_GC_TIME_MS;
|
|
28
|
+
if (Number.isNaN(gcTime) || gcTime < 0) throw new RangeError("gcTime must be a non-negative number or Infinity");
|
|
29
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
30
|
+
const scheduleGc = (sessionId, delay = gcTime) => {
|
|
31
|
+
const entry = sessions.get(sessionId);
|
|
32
|
+
if (!entry) return;
|
|
33
|
+
clearTimeout(entry.timer);
|
|
34
|
+
if (gcTime === Infinity) return;
|
|
35
|
+
entry.timer = setTimeout(() => {
|
|
36
|
+
if (sessions.get(sessionId) !== entry) return;
|
|
37
|
+
if (entry.runtime.canEvict()) {
|
|
38
|
+
sessions.delete(sessionId);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
scheduleGc(sessionId, Math.max(gcTime, 1e3));
|
|
42
|
+
}, delay);
|
|
43
|
+
entry.timer.unref?.();
|
|
44
|
+
};
|
|
45
|
+
const touch = (sessionId) => {
|
|
46
|
+
scheduleGc(sessionId);
|
|
47
|
+
};
|
|
48
|
+
const makeSession = (sessionId, sessionOptions) => {
|
|
49
|
+
let frontier = sessionOptions?.initialIndex ?? 0;
|
|
50
|
+
let foldedState = sessionOptions?.initialState !== void 0 ? sessionOptions.initialState : reducer.initialState;
|
|
51
|
+
const serverEvents = (sessionOptions?.initialEvents ?? []).filter((event) => event.index <= frontier).toSorted((a, b) => a.index - b.index);
|
|
52
|
+
let pending = [];
|
|
53
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
54
|
+
let snapshot = null;
|
|
55
|
+
let status = "idle";
|
|
56
|
+
let reconnects = 0;
|
|
57
|
+
let lastError = null;
|
|
58
|
+
let inFlightPushes = 0;
|
|
59
|
+
let deferredHydration;
|
|
60
|
+
let hydrationNotification;
|
|
61
|
+
const emit = () => {
|
|
62
|
+
for (const listener of [...listeners]) listener();
|
|
63
|
+
};
|
|
64
|
+
const notify = () => {
|
|
65
|
+
snapshot = null;
|
|
66
|
+
emit();
|
|
67
|
+
};
|
|
68
|
+
const notifyHydrated = () => {
|
|
69
|
+
snapshot = null;
|
|
70
|
+
clearTimeout(hydrationNotification);
|
|
71
|
+
hydrationNotification = setTimeout(emit, 0);
|
|
72
|
+
hydrationNotification.unref?.();
|
|
73
|
+
};
|
|
74
|
+
const mergeServerEvents = (events, throughIndex) => {
|
|
75
|
+
if (!events || events.length === 0) return false;
|
|
76
|
+
const byIndex = new Map(serverEvents.map((event) => [event.index, event]));
|
|
77
|
+
let changed = false;
|
|
78
|
+
for (const event of events) {
|
|
79
|
+
if (event.index > throughIndex) continue;
|
|
80
|
+
if (byIndex.get(event.index)?.id === event.id) continue;
|
|
81
|
+
byIndex.set(event.index, event);
|
|
82
|
+
changed = true;
|
|
83
|
+
}
|
|
84
|
+
if (!changed) return false;
|
|
85
|
+
serverEvents.splice(0, serverEvents.length, ...[...byIndex.values()].toSorted((a, b) => a.index - b.index));
|
|
86
|
+
return true;
|
|
87
|
+
};
|
|
88
|
+
const overlayEvents = () => {
|
|
89
|
+
const acked = pending.filter((p) => p.acked).map((p) => p.acked).toSorted((a, b) => a.index - b.index);
|
|
90
|
+
const maxKnown = acked.at(-1)?.index ?? frontier;
|
|
91
|
+
const unacked = pending.filter((p) => !p.acked).map((p, i) => ({
|
|
92
|
+
id: p.id,
|
|
93
|
+
type: p.type,
|
|
94
|
+
payload: p.payload,
|
|
95
|
+
index: maxKnown + 1 + i,
|
|
96
|
+
sessionId,
|
|
97
|
+
createdAt: p.createdAt
|
|
98
|
+
}));
|
|
99
|
+
return [...acked, ...unacked];
|
|
100
|
+
};
|
|
101
|
+
const connection = () => {
|
|
102
|
+
switch (status) {
|
|
103
|
+
case "idle": return { status: "idle" };
|
|
104
|
+
case "closed": return { status: "closed" };
|
|
105
|
+
case "live": return {
|
|
106
|
+
status: "live",
|
|
107
|
+
reconnects
|
|
108
|
+
};
|
|
109
|
+
case "connecting": return {
|
|
110
|
+
status: "connecting",
|
|
111
|
+
reconnects,
|
|
112
|
+
error: lastError
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const buildSnapshot = () => {
|
|
117
|
+
const overlay = overlayEvents();
|
|
118
|
+
let state = foldedState;
|
|
119
|
+
for (const event of overlay) state = reducer.fold(state, event);
|
|
120
|
+
return {
|
|
121
|
+
state,
|
|
122
|
+
events: [...serverEvents, ...overlay],
|
|
123
|
+
index: frontier,
|
|
124
|
+
connection: connection()
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
/** Pushes awaiting stream confirmation — resolved by `ingest` the
|
|
128
|
+
* moment the frontier passes their batch. */
|
|
129
|
+
let confirmWatchers = [];
|
|
130
|
+
/** The single ingest point: every server-confirmed event, in log
|
|
131
|
+
* order, from the stream. */
|
|
132
|
+
const ingest = (event) => {
|
|
133
|
+
if (event.index <= frontier) return;
|
|
134
|
+
frontier = event.index;
|
|
135
|
+
serverEvents.push(event);
|
|
136
|
+
foldedState = reducer.fold(foldedState, event);
|
|
137
|
+
pending = pending.filter((p) => p.id !== event.id);
|
|
138
|
+
if (confirmWatchers.some((w) => w.index <= frontier)) {
|
|
139
|
+
const due = confirmWatchers.filter((w) => w.index <= frontier);
|
|
140
|
+
confirmWatchers = confirmWatchers.filter((w) => w.index > frontier);
|
|
141
|
+
for (const watcher of due) watcher.resolve();
|
|
142
|
+
}
|
|
143
|
+
notify();
|
|
144
|
+
touch(sessionId);
|
|
145
|
+
};
|
|
146
|
+
const validated = (events) => events.map((event) => {
|
|
147
|
+
const schema = Object.hasOwn(reducer.events, event.type) ? reducer.events[event.type] : void 0;
|
|
148
|
+
if (!schema) throw new A2Error("UNKNOWN_EVENT_TYPE", `no event type '${String(event.type)}' in the reducer's vocabulary`);
|
|
149
|
+
const result = validateSync(schema, event.payload, `event '${String(event.type)}'`);
|
|
150
|
+
if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid payload for event '${String(event.type)}'`, { details: result.issues });
|
|
151
|
+
return {
|
|
152
|
+
id: event.id ?? crypto.randomUUID(),
|
|
153
|
+
type: event.type,
|
|
154
|
+
payload: result.value
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
const post = async (body) => {
|
|
158
|
+
let lastPushError = new A2Error("LOG_UNAVAILABLE", "push failed");
|
|
159
|
+
for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt += 1) {
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetchImpl(api, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "content-type": "application/json" },
|
|
164
|
+
body: JSON.stringify(body)
|
|
165
|
+
});
|
|
166
|
+
if (res.ok) {
|
|
167
|
+
const rows = await res.json();
|
|
168
|
+
if (!Array.isArray(rows) || !rows.every(isWireEvent)) throw new A2Error("LOG_UNAVAILABLE", "push ack was not a list of events");
|
|
169
|
+
return rows.map((row) => eventFromWire(row));
|
|
170
|
+
}
|
|
171
|
+
lastPushError = errorFromWire(await res.json().catch(() => null)) ?? new A2Error("LOG_UNAVAILABLE", `push failed with ${res.status}`);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
lastPushError = err instanceof A2Error ? err : new A2Error("LOG_UNAVAILABLE", "push request failed", { cause: err });
|
|
174
|
+
}
|
|
175
|
+
if (lastPushError.code !== "LOG_UNAVAILABLE") throw lastPushError;
|
|
176
|
+
if (attempt < PUSH_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
|
177
|
+
}
|
|
178
|
+
throw lastPushError;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Decorate the ack promise into a PushResult. `confirmed` is a
|
|
182
|
+
* lazy getter — materialized on first access, so callers that
|
|
183
|
+
* ignore it can't leak an unhandled rejection.
|
|
184
|
+
*/
|
|
185
|
+
const withConfirmed = (ack) => {
|
|
186
|
+
let confirmed;
|
|
187
|
+
return Object.defineProperty(ack, "confirmed", { get() {
|
|
188
|
+
confirmed ??= ack.then((acked) => new Promise((resolve) => {
|
|
189
|
+
const last = acked.at(-1)?.index ?? 0;
|
|
190
|
+
if (frontier >= last) {
|
|
191
|
+
resolve(acked);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
confirmWatchers.push({
|
|
195
|
+
index: last,
|
|
196
|
+
resolve: () => resolve(acked)
|
|
197
|
+
});
|
|
198
|
+
}));
|
|
199
|
+
return confirmed;
|
|
200
|
+
} });
|
|
201
|
+
};
|
|
202
|
+
const push = (...events) => {
|
|
203
|
+
touch(sessionId);
|
|
204
|
+
return withConfirmed((async () => {
|
|
205
|
+
if (events.length === 0) throw new TypeError("push requires at least one event");
|
|
206
|
+
const entries = validated(events).map((e) => ({
|
|
207
|
+
id: e.id,
|
|
208
|
+
type: e.type,
|
|
209
|
+
payload: e.payload,
|
|
210
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
211
|
+
}));
|
|
212
|
+
pending.push(...entries);
|
|
213
|
+
inFlightPushes += 1;
|
|
214
|
+
notify();
|
|
215
|
+
try {
|
|
216
|
+
const acked = await post({
|
|
217
|
+
sessionId,
|
|
218
|
+
events: entries.map(({ id, type, payload }) => ({
|
|
219
|
+
id,
|
|
220
|
+
type,
|
|
221
|
+
payload
|
|
222
|
+
}))
|
|
223
|
+
});
|
|
224
|
+
for (const event of acked) {
|
|
225
|
+
const entry = pending.find((p) => p.id === event.id);
|
|
226
|
+
if (entry) entry.acked = event;
|
|
227
|
+
}
|
|
228
|
+
notify();
|
|
229
|
+
return acked;
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const ids = new Set(entries.map((e) => e.id));
|
|
232
|
+
pending = pending.filter((p) => !ids.has(p.id));
|
|
233
|
+
notify();
|
|
234
|
+
throw err;
|
|
235
|
+
} finally {
|
|
236
|
+
inFlightPushes -= 1;
|
|
237
|
+
if (inFlightPushes === 0 && deferredHydration) {
|
|
238
|
+
const next = deferredHydration;
|
|
239
|
+
deferredHydration = void 0;
|
|
240
|
+
runtime.hydrate(next);
|
|
241
|
+
}
|
|
242
|
+
touch(sessionId);
|
|
243
|
+
}
|
|
244
|
+
})());
|
|
245
|
+
};
|
|
246
|
+
let generation = 0;
|
|
247
|
+
let active = false;
|
|
248
|
+
let abort = null;
|
|
249
|
+
const streamUrl = () => {
|
|
250
|
+
const sep = api.includes("?") ? "&" : "?";
|
|
251
|
+
return `${api}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${frontier}`;
|
|
252
|
+
};
|
|
253
|
+
const consume = async (body, onActivity) => {
|
|
254
|
+
const decoder = new TextDecoder();
|
|
255
|
+
const reader = body.getReader();
|
|
256
|
+
let buffer = "";
|
|
257
|
+
try {
|
|
258
|
+
for (;;) {
|
|
259
|
+
const { done, value } = await reader.read();
|
|
260
|
+
if (done) return;
|
|
261
|
+
onActivity();
|
|
262
|
+
buffer += decoder.decode(value, { stream: true });
|
|
263
|
+
for (;;) {
|
|
264
|
+
const boundary = buffer.indexOf("\n\n");
|
|
265
|
+
if (boundary === -1) break;
|
|
266
|
+
const frame = buffer.slice(0, boundary);
|
|
267
|
+
buffer = buffer.slice(boundary + 2);
|
|
268
|
+
const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
269
|
+
if (!data) continue;
|
|
270
|
+
const parsed = JSON.parse(data);
|
|
271
|
+
if (isWireEvent(parsed)) ingest(eventFromWire(parsed));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
reader.cancel().catch(() => {});
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const runStream = async (run) => {
|
|
279
|
+
let backoff = RECONNECT_BASE_MS;
|
|
280
|
+
while (generation === run) {
|
|
281
|
+
const controller = new AbortController();
|
|
282
|
+
abort = controller;
|
|
283
|
+
let stall;
|
|
284
|
+
let stalled = false;
|
|
285
|
+
const armStall = () => {
|
|
286
|
+
clearTimeout(stall);
|
|
287
|
+
stall = setTimeout(() => {
|
|
288
|
+
stalled = true;
|
|
289
|
+
controller.abort();
|
|
290
|
+
}, STREAM_TIMINGS.stallTimeoutMs);
|
|
291
|
+
stall.unref?.();
|
|
292
|
+
};
|
|
293
|
+
let wasLive = false;
|
|
294
|
+
try {
|
|
295
|
+
const res = await fetchImpl(streamUrl(), {
|
|
296
|
+
headers: { accept: "text/event-stream" },
|
|
297
|
+
signal: controller.signal
|
|
298
|
+
});
|
|
299
|
+
if (!res.ok || !res.body) throw new Error(`stream failed with ${res.status}`);
|
|
300
|
+
if (generation !== run) break;
|
|
301
|
+
backoff = RECONNECT_BASE_MS;
|
|
302
|
+
wasLive = true;
|
|
303
|
+
status = "live";
|
|
304
|
+
lastError = null;
|
|
305
|
+
notify();
|
|
306
|
+
armStall();
|
|
307
|
+
await consume(res.body, armStall);
|
|
308
|
+
if (generation === run) lastError = null;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
if (generation === run) lastError = stalled ? /* @__PURE__ */ new Error(`stream stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`) : err instanceof Error ? err : new Error(String(err));
|
|
311
|
+
} finally {
|
|
312
|
+
clearTimeout(stall);
|
|
313
|
+
}
|
|
314
|
+
if (generation !== run) break;
|
|
315
|
+
if (wasLive) reconnects += 1;
|
|
316
|
+
status = "connecting";
|
|
317
|
+
notify();
|
|
318
|
+
await new Promise((resolve) => {
|
|
319
|
+
let settled = false;
|
|
320
|
+
const finish = () => {
|
|
321
|
+
if (settled) return;
|
|
322
|
+
settled = true;
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
resolve();
|
|
325
|
+
};
|
|
326
|
+
const timer = setTimeout(finish, backoff);
|
|
327
|
+
timer.unref?.();
|
|
328
|
+
abort?.signal.addEventListener("abort", finish);
|
|
329
|
+
});
|
|
330
|
+
backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const runtime = {
|
|
334
|
+
sessionId,
|
|
335
|
+
subscribe(listener) {
|
|
336
|
+
touch(sessionId);
|
|
337
|
+
listeners.add(listener);
|
|
338
|
+
return () => {
|
|
339
|
+
listeners.delete(listener);
|
|
340
|
+
touch(sessionId);
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
getSnapshot() {
|
|
344
|
+
snapshot ??= buildSnapshot();
|
|
345
|
+
return snapshot;
|
|
346
|
+
},
|
|
347
|
+
push,
|
|
348
|
+
connect() {
|
|
349
|
+
touch(sessionId);
|
|
350
|
+
if (active) return;
|
|
351
|
+
active = true;
|
|
352
|
+
status = "connecting";
|
|
353
|
+
notify();
|
|
354
|
+
runStream(generation);
|
|
355
|
+
},
|
|
356
|
+
close() {
|
|
357
|
+
touch(sessionId);
|
|
358
|
+
if (!active) return;
|
|
359
|
+
active = false;
|
|
360
|
+
generation += 1;
|
|
361
|
+
abort?.abort();
|
|
362
|
+
abort = null;
|
|
363
|
+
status = "closed";
|
|
364
|
+
notify();
|
|
365
|
+
},
|
|
366
|
+
hydrate(next) {
|
|
367
|
+
touch(sessionId);
|
|
368
|
+
if (!next) return;
|
|
369
|
+
const nextIndex = next.initialIndex ?? 0;
|
|
370
|
+
if (nextIndex > frontier && inFlightPushes > 0) {
|
|
371
|
+
if (!deferredHydration || nextIndex > (deferredHydration.initialIndex ?? 0)) deferredHydration = next;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const historyChanged = mergeServerEvents(next.initialEvents, Math.min(nextIndex, frontier));
|
|
375
|
+
if (next.initialState === void 0 || nextIndex <= frontier) {
|
|
376
|
+
if (historyChanged) notifyHydrated();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
frontier = nextIndex;
|
|
380
|
+
foldedState = next.initialState;
|
|
381
|
+
mergeServerEvents(next.initialEvents, frontier);
|
|
382
|
+
pending = pending.filter((entry) => !entry.acked || entry.acked.index > frontier);
|
|
383
|
+
if (confirmWatchers.some((watcher) => watcher.index <= frontier)) {
|
|
384
|
+
const due = confirmWatchers.filter((watcher) => watcher.index <= frontier);
|
|
385
|
+
confirmWatchers = confirmWatchers.filter((watcher) => watcher.index > frontier);
|
|
386
|
+
for (const watcher of due) watcher.resolve();
|
|
387
|
+
}
|
|
388
|
+
notifyHydrated();
|
|
389
|
+
},
|
|
390
|
+
canEvict() {
|
|
391
|
+
return listeners.size === 0 && !active && inFlightPushes === 0 && confirmWatchers.length === 0;
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
return runtime;
|
|
395
|
+
};
|
|
396
|
+
return { session(sessionId, sessionOptions) {
|
|
397
|
+
const existing = sessions.get(sessionId);
|
|
398
|
+
if (existing) {
|
|
399
|
+
existing.runtime.hydrate(sessionOptions);
|
|
400
|
+
touch(sessionId);
|
|
401
|
+
return existing.runtime;
|
|
402
|
+
}
|
|
403
|
+
const runtime = makeSession(sessionId, sessionOptions);
|
|
404
|
+
sessions.set(sessionId, { runtime });
|
|
405
|
+
touch(sessionId);
|
|
406
|
+
return runtime;
|
|
407
|
+
} };
|
|
408
|
+
}
|
|
409
|
+
//#endregion
|
|
410
|
+
export { createClient };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { n as validateSync, t as assertSyncSchema } from "./validate-XKT4FSNn.js";
|
|
2
|
+
//#region src/reducer.ts
|
|
3
|
+
/** Internal — reducers are created through `contract.reducer(...).fold(...)`. */
|
|
4
|
+
function makeReducerBuilder(events, options) {
|
|
5
|
+
const { name } = options;
|
|
6
|
+
if (typeof name !== "string" || name.length === 0) throw new TypeError("reducer name must be a non-empty string — it identifies the reducer and keys cached snapshots");
|
|
7
|
+
let initialState = options.initialState;
|
|
8
|
+
if (options.stateSchema) {
|
|
9
|
+
const result = validateSync(options.stateSchema, options.initialState, "the stateSchema");
|
|
10
|
+
if (result.issues) throw new TypeError(`reducer initialState does not match its stateSchema: ${result.issues.map((issue) => issue.message).join("; ")}`);
|
|
11
|
+
initialState = result.value;
|
|
12
|
+
}
|
|
13
|
+
return { fold(fold) {
|
|
14
|
+
if (typeof fold !== "function") throw new TypeError("reducer fold must be a function");
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
events,
|
|
18
|
+
fold,
|
|
19
|
+
initialState,
|
|
20
|
+
stateSchema: options.stateSchema
|
|
21
|
+
};
|
|
22
|
+
} };
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/contract.ts
|
|
26
|
+
/**
|
|
27
|
+
* The contract — a2's primary noun. A named event vocabulary, shared by
|
|
28
|
+
* everything: the server implements it (a2/server's `createServer`),
|
|
29
|
+
* reducers derive from it (`contract.reducer`), the browser types its
|
|
30
|
+
* pushes off it. Isomorphic by construction: this module imports
|
|
31
|
+
* nothing but schema plumbing.
|
|
32
|
+
*
|
|
33
|
+
* The contract says what can be *said*; the server says how it's
|
|
34
|
+
* *reacted to*; a reducer says how it's *seen*.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Define a contract: a name plus the events it understands. Validators
|
|
38
|
+
* must be synchronous — async ones are rejected here, at definition
|
|
39
|
+
* time. The result is a plain, importable, isomorphic value.
|
|
40
|
+
*/
|
|
41
|
+
function contract(options) {
|
|
42
|
+
const { name } = options;
|
|
43
|
+
if (typeof name !== "string" || name.length === 0) throw new TypeError("contract name must be a non-empty string");
|
|
44
|
+
if (name.includes("")) throw new TypeError("contract name contains a reserved control character");
|
|
45
|
+
const defs = options.events;
|
|
46
|
+
for (const [type, schema] of Object.entries(defs)) {
|
|
47
|
+
if (typeof schema?.["~standard"]?.validate !== "function") throw new TypeError(`contract '${name}': the value for '${type}' is not a Standard Schema (expected an object with '~standard')`);
|
|
48
|
+
assertSyncSchema(schema, `event '${type}'`);
|
|
49
|
+
}
|
|
50
|
+
const events = Object.freeze({ ...defs });
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
events,
|
|
54
|
+
reducer(reducerOptions) {
|
|
55
|
+
return makeReducerBuilder(events, reducerOptions);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { contract as t };
|