statewire 0.9.2 → 0.10.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.
@@ -1,17 +1,20 @@
1
- import { useEffect, useState } from "react";
1
+ import { useContext, useEffect, useState } from "react";
2
2
  import { applyOps } from "../wire/ops";
3
3
  import type { StatewirePath } from "../wire/ops";
4
4
  import type { StatewireCmd, StatewireSyn } from "./connection";
5
- import { encodeParams, encodeValue, reviveResponse } from "../wire/meta";
5
+ import {
6
+ encodeParams,
7
+ encodeValue,
8
+ reviveResponse,
9
+ reviveValue,
10
+ } from "../wire/meta";
6
11
  import { bindCommandsHost } from "../host/host";
7
12
  import type { CommandsInvocation, CommandsListener } from "../host/host";
8
13
  import type { Statewire } from "../types";
9
14
  import type { StatewireClient } from "../statewire";
10
- import { createMemorySessionStore } from "./sessionStore";
11
- import type {
12
- StatewireSessionRecord,
13
- StatewireSessionStore,
14
- StatewireSessionStoreOptions,
15
+ import {
16
+ SessionStorageContext,
17
+ createMemorySessionStore,
15
18
  } from "./sessionStore";
16
19
  import { backoff } from "./timing";
17
20
  import { createMethodProxy } from "../util/methodProxy";
@@ -65,16 +68,19 @@ const rejectedSend = (
65
68
 
66
69
  type AttachState = "detached" | "attached" | "finished";
67
70
 
68
- export type StatewireSessionOptions = StatewireSessionStoreOptions & {
71
+ export type StatewireSessionOptions = {
72
+ sessionStore?: StatewireClient.SessionStorage | undefined;
69
73
  onDone?: (() => void) | undefined;
70
74
  };
71
75
 
72
76
  type Session = {
73
- record: StatewireSessionRecord;
77
+ record: StatewireClient.SessionRecord;
74
78
  ctx: StatewireRemoteProps;
75
79
  recordPersisted: boolean;
76
- store: StatewireSessionStore;
80
+ store: StatewireClient.SessionStorage;
77
81
  ephemeral: boolean;
82
+ /** An ephemeral record died with its attach: the storage is off for good. */
83
+ deactivated: boolean;
78
84
  sessionKnown: boolean;
79
85
  durableSeq: number;
80
86
  resumeAt: number;
@@ -128,9 +134,25 @@ const sleepUntilWake = (s: Session, delayMs: number) =>
128
134
  s.waiters.push(waiter);
129
135
  });
130
136
 
131
- const setRecord = (s: Session, record: StatewireSessionRecord) => {
137
+ const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
132
138
  s.record = record;
133
- if (!s.ephemeral) s.store.save(record);
139
+ if (!s.deactivated) s.store.save(record);
140
+ };
141
+
142
+ const backlog = (s: Session): StatewireClient.SessionCommand[] =>
143
+ s.pending
144
+ .filter((p) => p.seq > s.durableSeq)
145
+ .map((p) => ({ seq: p.seq, ...p.command }));
146
+
147
+ // Storage holds what a reload must resend: a command leaves at its dur, at a
148
+ // server verdict, or at a record reset — never at a local channel death.
149
+ const syncBacklog = (s: Session) => {
150
+ const commands = backlog(s);
151
+ const unchanged =
152
+ commands.length === s.record.commands.length &&
153
+ commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
154
+ if (unchanged) return;
155
+ setRecord(s, { ...s.record, commands });
134
156
  };
135
157
 
136
158
  const setIdle = (s: Session, level: boolean) => {
@@ -223,7 +245,7 @@ const resetRecord = (s: Session, clientId: string) => {
223
245
  }
224
246
  s.pending = [];
225
247
  s.durableSeq = 0;
226
- setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1 });
248
+ setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
227
249
  for (const old of survivors) {
228
250
  s.lifecycle?.onCovered?.(old.inv);
229
251
  enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
@@ -301,6 +323,7 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
301
323
 
302
324
  const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
303
325
  for (const cmd of cmds) handleCmd(s, cmd);
326
+ syncBacklog(s);
304
327
  wake(s);
305
328
  };
306
329
 
@@ -356,6 +379,7 @@ const handleHello = (
356
379
  s.frameFailures = 0;
357
380
  s.resumeAt = 0;
358
381
  s.ephemeral = record === "ephemeral";
382
+ if (s.ephemeral) s.deactivated = true;
359
383
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
360
384
  s.attachLaneSeqs = [new Set(), new Set()];
361
385
  if (record === "resumed") {
@@ -368,6 +392,7 @@ const handleHello = (
368
392
  s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
369
393
  for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
370
394
  snapClocks(s, syn.seq ?? 0, ctxWatermark);
395
+ syncBacklog(s);
371
396
  } else {
372
397
  resetRecord(s, s.record.clientId);
373
398
  }
@@ -382,6 +407,7 @@ const handleOps = (
382
407
  ) => {
383
408
  ingestOps(s, ops);
384
409
  for (const cmd of cmds) handleCmd(s, cmd);
410
+ syncBacklog(s);
385
411
  publishIngested(s);
386
412
  };
387
413
 
@@ -558,6 +584,7 @@ const start = (s: Session) => {
558
584
  s.recordPersisted = true;
559
585
  s.store.save(s.record);
560
586
  }
587
+ resume(s);
561
588
  s.running = true;
562
589
  void pump(s);
563
590
  };
@@ -582,7 +609,6 @@ const enqueue = (
582
609
  reject: (error: StatewireSendError) => void,
583
610
  ) => {
584
611
  const seq = s.record.nextSeq;
585
- setRecord(s, { ...s.record, nextSeq: seq + 1 });
586
612
  const entry: Pending = {
587
613
  seq,
588
614
  command,
@@ -600,10 +626,41 @@ const enqueue = (
600
626
  reject,
601
627
  };
602
628
  s.pending.push(entry);
629
+ setRecord(s, { ...s.record, nextSeq: seq + 1, commands: backlog(s) });
603
630
  s.lifecycle?.onStart(entry.inv);
604
631
  void pump(s);
605
632
  };
606
633
 
634
+ // A stored command cannot be told from one that reached the server: attempted
635
+ // keeps a fresh record settling it lost instead of replaying it blind.
636
+ const resume = (s: Session) => {
637
+ const stored = [...s.record.commands].sort((a, b) => a.seq - b.seq);
638
+ for (const { seq, ...command } of stored) {
639
+ const params =
640
+ command.meta === undefined
641
+ ? command.params
642
+ : (reviveValue([...command.params], command.meta) as unknown[]);
643
+ const entry: Pending = {
644
+ seq,
645
+ command,
646
+ inv: {
647
+ method: command.method,
648
+ params,
649
+ ts: Date.now(),
650
+ seq,
651
+ clientId: s.record.clientId,
652
+ },
653
+ attempted: true,
654
+ posted: false,
655
+ applied: false,
656
+ resolve: () => {},
657
+ reject: () => {},
658
+ };
659
+ s.pending.push(entry);
660
+ s.lifecycle?.onStart(entry.inv);
661
+ }
662
+ };
663
+
607
664
  const sendCommand = (
608
665
  s: Session,
609
666
  method: string,
@@ -653,13 +710,25 @@ export const useStatewireSession = (
653
710
  options: StatewireSessionOptions,
654
711
  ): Session => {
655
712
  const [defaultStore] = useState(createMemorySessionStore);
656
- const store = options.sessionStore ?? defaultStore;
713
+ const mounted = useContext(SessionStorageContext);
714
+ if (options.sessionStore !== undefined && mounted !== undefined) {
715
+ throw new Error(
716
+ "statewire: session storage is set twice — drop the transport's sessionStore or the client's storage.session",
717
+ );
718
+ }
719
+ const store = options.sessionStore ?? mounted ?? defaultStore;
657
720
  const [s] = useState((): Session => {
658
721
  const stored = store.load();
722
+ if (stored !== null && !Array.isArray(stored.commands)) {
723
+ throw new Error(
724
+ "statewire: session storage loaded a record without a commands array",
725
+ );
726
+ }
659
727
  const record = stored ?? {
660
728
  clientId: newClientId(),
661
729
  nextSeq: 1,
662
730
  ctxNextSeq: 1,
731
+ commands: [],
663
732
  };
664
733
  const session: Session = {
665
734
  record,
@@ -679,6 +748,7 @@ export const useStatewireSession = (
679
748
  recordPersisted: stored !== null,
680
749
  store,
681
750
  ephemeral: false,
751
+ deactivated: false,
682
752
  sessionKnown: false,
683
753
  durableSeq: 0,
684
754
  resumeAt: 0,
@@ -1,20 +1,13 @@
1
- export type StatewireSessionRecord = {
2
- clientId: string;
3
- nextSeq: number;
4
- ctxNextSeq: number;
5
- };
6
-
7
- export type StatewireSessionStore = {
8
- load: () => StatewireSessionRecord | null;
9
- save: (record: StatewireSessionRecord) => void;
10
- };
1
+ import { createContext } from "react";
2
+ import type { StatewireClient } from "../statewire";
11
3
 
12
- export type StatewireSessionStoreOptions = {
13
- sessionStore?: StatewireSessionStore | undefined;
14
- };
4
+ /** The session storage `useStatewire` mounts, read by the transport's session. */
5
+ export const SessionStorageContext = createContext<
6
+ StatewireClient.SessionStorage | undefined
7
+ >(undefined);
15
8
 
16
- export const createMemorySessionStore = (): StatewireSessionStore => {
17
- let record: StatewireSessionRecord | null = null;
9
+ export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
10
+ let record: StatewireClient.SessionRecord | null = null;
18
11
  return {
19
12
  load: () => record,
20
13
  save: (next) => {
@@ -0,0 +1,280 @@
1
+ import type { StatewireHost } from "../host";
2
+ import {
3
+ CLIENT_ID_RE,
4
+ admitFrame,
5
+ closeConn,
6
+ flush,
7
+ parseFrame,
8
+ parseFrameValue,
9
+ register,
10
+ sendTo,
11
+ } from "./socket";
12
+ import type { ParsedFrame, Registration, SocketHostState } from "./socket";
13
+
14
+ const CLIENT_ID_HEADER = "Statewire-Client-Id";
15
+ const LEASE_HEADER = "Statewire-Lease";
16
+ const VERSION_HEADER = "Statewire-Version";
17
+ const VERSION = "1";
18
+ const encoder = new TextEncoder();
19
+
20
+ const invalidClientIdReason = (clientId: string): string | null => {
21
+ if (clientId === "") return "id must be non-empty";
22
+ if (clientId.length > 256) return "id must be at most 256 characters";
23
+ if (!CLIENT_ID_RE.test(clientId)) {
24
+ return "id must contain only [A-Za-z0-9._~-]";
25
+ }
26
+ return null;
27
+ };
28
+
29
+ const clientIdResponse = (
30
+ request: Request,
31
+ ): { clientId: string } | { response: Response } => {
32
+ const clientId = request.headers.get(CLIENT_ID_HEADER);
33
+ if (clientId === null) {
34
+ return {
35
+ response: Response.json(
36
+ { detail: `missing ${CLIENT_ID_HEADER} header` },
37
+ { status: 400 },
38
+ ),
39
+ };
40
+ }
41
+ const reason = invalidClientIdReason(clientId);
42
+ if (reason !== null) {
43
+ return {
44
+ response: Response.json(
45
+ { detail: `invalid ${CLIENT_ID_HEADER}: ${reason}` },
46
+ { status: 400 },
47
+ ),
48
+ };
49
+ }
50
+ return { clientId };
51
+ };
52
+
53
+ const headersOf = (request: Request): Record<string, string> =>
54
+ Object.fromEntries(request.headers);
55
+
56
+ const mergedHeaders = (
57
+ request: Request,
58
+ extra: Record<string, string>,
59
+ ): Record<string, string> => {
60
+ const headers = headersOf(request);
61
+ for (const [name, value] of Object.entries(extra)) {
62
+ headers[name.toLowerCase()] = value;
63
+ }
64
+ return headers;
65
+ };
66
+
67
+ const authorize = async (
68
+ h: SocketHostState,
69
+ headers: Record<string, string>,
70
+ ): Promise<Response | null> => {
71
+ try {
72
+ await h.authorize?.({ headers });
73
+ return null;
74
+ } catch (error) {
75
+ if (error instanceof Response) return error;
76
+ throw error;
77
+ }
78
+ };
79
+
80
+ const mintLease = (): string => {
81
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
82
+ return btoa(String.fromCharCode(...bytes))
83
+ .replaceAll("+", "-")
84
+ .replaceAll("/", "_")
85
+ .replace(/=+$/, "");
86
+ };
87
+
88
+ type SseAttachment = {
89
+ body: ReadableStream<Uint8Array>;
90
+ registration: Registration | null;
91
+ };
92
+
93
+ const attachSse = (h: SocketHostState, clientId: string): SseAttachment => {
94
+ let controller: ReadableStreamDefaultController<Uint8Array>;
95
+ let connection: Registration["connection"] | null = null;
96
+ let open = true;
97
+ const body = new ReadableStream({
98
+ start: (next) => {
99
+ controller = next;
100
+ },
101
+ cancel: () => {
102
+ open = false;
103
+ connection?.close();
104
+ },
105
+ });
106
+ const socket = {
107
+ send: (data: string) => {
108
+ if (!open) return;
109
+ const frame = data === "{}" ? ": ping\n\n" : `data: ${data}\n\n`;
110
+ try {
111
+ controller.enqueue(encoder.encode(frame));
112
+ } catch {
113
+ open = false;
114
+ connection?.close();
115
+ }
116
+ },
117
+ close: () => {
118
+ if (!open) return;
119
+ open = false;
120
+ try {
121
+ controller.close();
122
+ } catch {
123
+ return;
124
+ }
125
+ },
126
+ };
127
+ const registration = register(h, clientId, socket);
128
+ connection = registration?.connection ?? null;
129
+ if (!open) connection?.close();
130
+ return { body, registration };
131
+ };
132
+
133
+ const streamResponse = (
134
+ attachment: SseAttachment,
135
+ lease: string | null,
136
+ ): Response =>
137
+ new Response(attachment.body, {
138
+ status: 200,
139
+ headers: {
140
+ "Content-Type": "text/event-stream",
141
+ [VERSION_HEADER]: VERSION,
142
+ ...(lease !== null && { [LEASE_HEADER]: lease }),
143
+ },
144
+ });
145
+
146
+ const admitAttachFrame = (
147
+ h: SocketHostState,
148
+ registration: Registration,
149
+ frame: ParsedFrame,
150
+ ) => {
151
+ if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
152
+ const admission = admitFrame(h, registration.rec, frame);
153
+ if (admission.type === "refused") {
154
+ sendTo(h, registration.conn, JSON.stringify(admission.syn));
155
+ }
156
+ };
157
+
158
+ const getStream = async (
159
+ h: SocketHostState,
160
+ request: Request,
161
+ ): Promise<Response> => {
162
+ const refused = await authorize(h, headersOf(request));
163
+ if (refused !== null) return refused;
164
+ const client = clientIdResponse(request);
165
+ if ("response" in client) return client.response;
166
+ const attachment = attachSse(h, client.clientId);
167
+ const lease = attachment.registration === null ? null : mintLease();
168
+ if (attachment.registration !== null)
169
+ attachment.registration.rec.lease = lease;
170
+ return streamResponse(attachment, lease);
171
+ };
172
+
173
+ const postStream = async (
174
+ h: SocketHostState,
175
+ request: Request,
176
+ ): Promise<Response> => {
177
+ const client = clientIdResponse(request);
178
+ if ("response" in client) return client.response;
179
+ let body: unknown;
180
+ try {
181
+ body = JSON.parse(await request.text());
182
+ } catch {
183
+ return Response.json({ detail: "body must be JSON" }, { status: 400 });
184
+ }
185
+ let frame: ParsedFrame;
186
+ try {
187
+ frame = parseFrameValue(body, true);
188
+ } catch (error) {
189
+ if (!(error instanceof Error) || error.name !== "StatewireViolation") {
190
+ throw error;
191
+ }
192
+ return Response.json({ detail: error.message }, { status: 400 });
193
+ }
194
+ if (frame.headers === null) {
195
+ return Response.json(
196
+ { detail: "first frame must carry headers" },
197
+ { status: 400 },
198
+ );
199
+ }
200
+ const refused = await authorize(h, mergedHeaders(request, frame.headers));
201
+ if (refused !== null) return refused;
202
+ const attachment = attachSse(h, client.clientId);
203
+ if (attachment.registration === null) return streamResponse(attachment, null);
204
+ const lease = mintLease();
205
+ attachment.registration.rec.lease = lease;
206
+ admitAttachFrame(h, attachment.registration, frame);
207
+ return streamResponse(attachment, lease);
208
+ };
209
+
210
+ export const stream = async (
211
+ h: SocketHostState,
212
+ request: Request,
213
+ ): Promise<Response> => {
214
+ if (request.method === "GET") return getStream(h, request);
215
+ if (request.method === "POST") return postStream(h, request);
216
+ return new Response(null, {
217
+ status: 405,
218
+ headers: { Allow: "GET, POST" },
219
+ });
220
+ };
221
+
222
+ const finClient = (
223
+ h: SocketHostState,
224
+ clientId: string,
225
+ fin: StatewireHost.Fin,
226
+ ) => {
227
+ flush(h);
228
+ for (const conn of [...h.conns]) {
229
+ if (conn.clientId !== clientId) continue;
230
+ sendTo(h, conn, JSON.stringify({ fin }));
231
+ closeConn(h, conn);
232
+ }
233
+ };
234
+
235
+ const violationResponse = (
236
+ h: SocketHostState,
237
+ clientId: string,
238
+ reason: string,
239
+ ) => {
240
+ finClient(h, clientId, {
241
+ reason: "error",
242
+ message: `protocol violation: ${reason}`,
243
+ });
244
+ return Response.json({ error: reason }, { status: 400 });
245
+ };
246
+
247
+ export const frames = async (
248
+ h: SocketHostState,
249
+ request: Request,
250
+ ): Promise<Response> => {
251
+ const refused = await authorize(h, headersOf(request));
252
+ if (refused !== null) return refused;
253
+ const client = clientIdResponse(request);
254
+ if ("response" in client) return client.response;
255
+ const rec = h.clients.get(client.clientId);
256
+ const lease = request.headers.get(LEASE_HEADER);
257
+ if (
258
+ h.disposed ||
259
+ h.finished !== null ||
260
+ rec === undefined ||
261
+ lease === null ||
262
+ lease !== rec.lease
263
+ ) {
264
+ return Response.json({ error: "stale-lease" }, { status: 423 });
265
+ }
266
+ let frame: ParsedFrame;
267
+ try {
268
+ frame = parseFrame(await request.text(), false);
269
+ } catch (error) {
270
+ if (!(error instanceof Error) || error.name !== "StatewireViolation") {
271
+ throw error;
272
+ }
273
+ return violationResponse(h, client.clientId, error.message);
274
+ }
275
+ const admission = admitFrame(h, rec, frame);
276
+ if (admission.type === "refused") {
277
+ return Response.json(admission.syn, { status: 409 });
278
+ }
279
+ return Response.json({});
280
+ };