statewire 0.13.1 → 0.15.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.
Files changed (95) hide show
  1. package/README.md +29 -78
  2. package/dist/client/connection.d.ts +20 -6
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +120 -52
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +3 -2
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +10 -4
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts +2 -0
  11. package/dist/client/session.d.ts.map +1 -1
  12. package/dist/client/session.js +97 -17
  13. package/dist/client/session.js.map +1 -1
  14. package/dist/host/adapter.d.ts +1 -1
  15. package/dist/host/adapter.d.ts.map +1 -1
  16. package/dist/host/adapter.js +7 -1
  17. package/dist/host/adapter.js.map +1 -1
  18. package/dist/host/host.d.ts +1 -0
  19. package/dist/host/host.d.ts.map +1 -1
  20. package/dist/host/host.js +45 -5
  21. package/dist/host/host.js.map +1 -1
  22. package/dist/host/http.d.ts +2 -1
  23. package/dist/host/http.d.ts.map +1 -1
  24. package/dist/host/http.js +108 -47
  25. package/dist/host/http.js.map +1 -1
  26. package/dist/host/socket.d.ts +33 -8
  27. package/dist/host/socket.d.ts.map +1 -1
  28. package/dist/host/socket.js +388 -58
  29. package/dist/host/socket.js.map +1 -1
  30. package/dist/host/work.d.ts +7 -0
  31. package/dist/host/work.d.ts.map +1 -0
  32. package/dist/host/work.js +53 -0
  33. package/dist/host/work.js.map +1 -0
  34. package/dist/host-internal.d.ts +42 -4
  35. package/dist/host-internal.d.ts.map +1 -1
  36. package/dist/host-internal.js +2 -1
  37. package/dist/host.d.ts +16 -3
  38. package/dist/host.d.ts.map +1 -1
  39. package/dist/host.js +2 -1
  40. package/dist/statewire.d.ts +21 -11
  41. package/dist/statewire.d.ts.map +1 -1
  42. package/dist/statewire.js +2 -2
  43. package/dist/statewire.js.map +1 -1
  44. package/dist/transports/sse.d.ts +1 -4
  45. package/dist/transports/sse.d.ts.map +1 -1
  46. package/dist/transports/sse.js +47 -56
  47. package/dist/transports/sse.js.map +1 -1
  48. package/dist/transports/testing.js +2 -2
  49. package/dist/transports/testing.js.map +1 -1
  50. package/dist/transports/ws.d.ts +1 -1
  51. package/dist/transports/ws.d.ts.map +1 -1
  52. package/dist/transports/ws.js +54 -41
  53. package/dist/transports/ws.js.map +1 -1
  54. package/dist/types.d.ts +3 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/wire/documents.d.ts +20 -0
  58. package/dist/wire/documents.d.ts.map +1 -0
  59. package/dist/wire/documents.js +74 -0
  60. package/dist/wire/documents.js.map +1 -0
  61. package/dist/wire/limits.d.ts +10 -0
  62. package/dist/wire/limits.d.ts.map +1 -0
  63. package/dist/wire/limits.js +11 -0
  64. package/dist/wire/limits.js.map +1 -0
  65. package/dist/wire/meta.d.ts.map +1 -1
  66. package/dist/wire/meta.js +1 -1
  67. package/dist/wire/meta.js.map +1 -1
  68. package/dist/wire/ops.d.ts.map +1 -1
  69. package/dist/wire/ops.js +1 -2
  70. package/dist/wire/ops.js.map +1 -1
  71. package/dist/wire/version.d.ts +7 -0
  72. package/dist/wire/version.d.ts.map +1 -0
  73. package/dist/wire/version.js +8 -0
  74. package/dist/wire/version.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +213 -67
  77. package/src/client/core.ts +10 -3
  78. package/src/client/session.ts +106 -30
  79. package/src/host/adapter.ts +10 -2
  80. package/src/host/host.ts +54 -4
  81. package/src/host/http.ts +168 -64
  82. package/src/host/socket.ts +603 -60
  83. package/src/host/work.ts +50 -0
  84. package/src/host-internal.ts +44 -3
  85. package/src/host.ts +18 -2
  86. package/src/statewire.ts +33 -10
  87. package/src/transports/sse.ts +60 -68
  88. package/src/transports/testing.ts +2 -2
  89. package/src/transports/ws.ts +29 -48
  90. package/src/types.ts +1 -0
  91. package/src/wire/documents.ts +109 -0
  92. package/src/wire/limits.ts +6 -0
  93. package/src/wire/meta.ts +6 -1
  94. package/src/wire/ops.ts +1 -2
  95. package/src/wire/version.ts +3 -0
package/src/host/http.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import type { StatewireHost } from "../host";
2
2
  import {
3
3
  CLIENT_ID_RE,
4
+ PRINCIPAL_MISMATCH_MESSAGE,
5
+ SUBSCRIBER_LIMIT_MESSAGE,
4
6
  admitFrame,
7
+ atSubscriberLimit,
8
+ authEquals,
5
9
  closeConn,
6
10
  flush,
7
11
  parseFrame,
@@ -10,12 +14,45 @@ import {
10
14
  sendTo,
11
15
  } from "./socket";
12
16
  import type { ParsedFrame, Registration, SocketHostState } from "./socket";
17
+ import { MAX_FRAME_BODY_BYTES } from "../wire/limits";
18
+ import { PROTOCOL_VERSION, VERSION_HEADER } from "../wire/version";
19
+
20
+ export { MAX_FRAME_BODY_BYTES } from "../wire/limits";
13
21
 
14
22
  const CLIENT_ID_HEADER = "Statewire-Client-Id";
15
23
  const LEASE_HEADER = "Statewire-Lease";
16
- const VERSION_HEADER = "Statewire-Version";
17
- const VERSION = "1";
18
24
  const encoder = new TextEncoder();
25
+ const laggardFin = encoder.encode(
26
+ 'data: {"fin":{"reason":"reconnect","code":"client-fell-behind"}}\n\n',
27
+ );
28
+ const readFrameBody = async (request: Request): Promise<string | null> => {
29
+ const declared = request.headers.get("content-length");
30
+ if (declared !== null && Number(declared) > MAX_FRAME_BODY_BYTES) {
31
+ await request.body?.cancel("frame body too large").catch(() => {});
32
+ return null;
33
+ }
34
+ if (request.body === null) return "";
35
+ const reader = request.body.getReader();
36
+ const chunks: Uint8Array[] = [];
37
+ let size = 0;
38
+ for (;;) {
39
+ const { done, value } = await reader.read();
40
+ if (done) break;
41
+ size += value.length;
42
+ if (size > MAX_FRAME_BODY_BYTES) {
43
+ await reader.cancel("frame body too large").catch(() => {});
44
+ return null;
45
+ }
46
+ chunks.push(value);
47
+ }
48
+ const body = new Uint8Array(size);
49
+ let offset = 0;
50
+ for (const chunk of chunks) {
51
+ body.set(chunk, offset);
52
+ offset += chunk.length;
53
+ }
54
+ return new TextDecoder().decode(body);
55
+ };
19
56
 
20
57
  const invalidClientIdReason = (clientId: string): string | null => {
21
58
  if (clientId === "") return "id must be non-empty";
@@ -53,30 +90,37 @@ const clientIdResponse = (
53
90
  const headersOf = (request: Request): Record<string, string> =>
54
91
  Object.fromEntries(request.headers);
55
92
 
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
93
  const authorize = async (
68
94
  h: SocketHostState,
69
95
  headers: Record<string, string>,
70
- ): Promise<Response | null> => {
96
+ ): Promise<{ auth: unknown } | { refused: Response }> => {
71
97
  try {
72
- await h.authorize?.({ headers });
73
- return null;
98
+ return { auth: await h.authorize?.({ headers }) };
74
99
  } catch (error) {
75
- if (error instanceof Response) return error;
100
+ if (error instanceof Response) return { refused: error };
76
101
  throw error;
77
102
  }
78
103
  };
79
104
 
105
+ const subscriberLimitResponse = (): Response =>
106
+ Response.json({ detail: SUBSCRIBER_LIMIT_MESSAGE }, { status: 503 });
107
+
108
+ // No awaits between this check and register: the binding cannot race.
109
+ const principalMismatchResponse = (
110
+ h: SocketHostState,
111
+ clientId: string,
112
+ auth: unknown,
113
+ ): Response | null => {
114
+ const rec = h.clients.get(clientId);
115
+ if (rec !== undefined && !authEquals(rec.principal, auth)) {
116
+ return Response.json(
117
+ { detail: PRINCIPAL_MISMATCH_MESSAGE },
118
+ { status: 403 },
119
+ );
120
+ }
121
+ return null;
122
+ };
123
+
80
124
  const mintLease = (): string => {
81
125
  const bytes = crypto.getRandomValues(new Uint8Array(16));
82
126
  return btoa(String.fromCharCode(...bytes))
@@ -88,24 +132,48 @@ const mintLease = (): string => {
88
132
  type SseAttachment = {
89
133
  body: ReadableStream<Uint8Array>;
90
134
  registration: Registration | null;
135
+ activateBackpressure: () => void;
91
136
  };
92
137
 
93
- const attachSse = (h: SocketHostState, clientId: string): SseAttachment => {
138
+ const attachSse = (
139
+ h: SocketHostState,
140
+ clientId: string,
141
+ auth: unknown,
142
+ ): SseAttachment => {
94
143
  let controller: ReadableStreamDefaultController<Uint8Array>;
95
144
  let connection: Registration["connection"] | null = null;
96
145
  let open = true;
97
- const body = new ReadableStream({
98
- start: (next) => {
99
- controller = next;
100
- },
101
- cancel: () => {
102
- open = false;
103
- connection?.close();
146
+ let backpressureActive = false;
147
+ const body = new ReadableStream(
148
+ {
149
+ start: (next) => {
150
+ controller = next;
151
+ },
152
+ cancel: () => {
153
+ open = false;
154
+ connection?.close();
155
+ },
104
156
  },
105
- });
157
+ { highWaterMark: h.streamQueueSize },
158
+ );
106
159
  const socket = {
107
160
  send: (data: string) => {
108
161
  if (!open) return;
162
+ if (
163
+ backpressureActive &&
164
+ controller.desiredSize !== null &&
165
+ controller.desiredSize <= 0
166
+ ) {
167
+ open = false;
168
+ try {
169
+ controller.enqueue(laggardFin);
170
+ controller.close();
171
+ } catch {
172
+ /* stream already gone */
173
+ }
174
+ connection?.close();
175
+ return;
176
+ }
109
177
  const frame = data === "{}" ? ": ping\n\n" : `data: ${data}\n\n`;
110
178
  try {
111
179
  controller.enqueue(encoder.encode(frame));
@@ -124,24 +192,32 @@ const attachSse = (h: SocketHostState, clientId: string): SseAttachment => {
124
192
  }
125
193
  },
126
194
  };
127
- const registration = register(h, clientId, socket);
195
+ const registration = register(h, clientId, socket, false, auth);
128
196
  connection = registration?.connection ?? null;
129
197
  if (!open) connection?.close();
130
- return { body, registration };
198
+ return {
199
+ body,
200
+ registration,
201
+ activateBackpressure: () => {
202
+ backpressureActive = true;
203
+ },
204
+ };
131
205
  };
132
206
 
133
207
  const streamResponse = (
134
208
  attachment: SseAttachment,
135
209
  lease: string | null,
136
- ): Response =>
137
- new Response(attachment.body, {
210
+ ): Response => {
211
+ attachment.activateBackpressure();
212
+ return new Response(attachment.body, {
138
213
  status: 200,
139
214
  headers: {
140
215
  "Content-Type": "text/event-stream",
141
- [VERSION_HEADER]: VERSION,
216
+ [VERSION_HEADER]: PROTOCOL_VERSION,
142
217
  ...(lease !== null && { [LEASE_HEADER]: lease }),
143
218
  },
144
219
  });
220
+ };
145
221
 
146
222
  const admitAttachFrame = (
147
223
  h: SocketHostState,
@@ -149,7 +225,12 @@ const admitAttachFrame = (
149
225
  frame: ParsedFrame,
150
226
  ) => {
151
227
  if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
152
- const admission = admitFrame(h, registration.rec, frame);
228
+ const admission = admitFrame(
229
+ h,
230
+ registration.rec,
231
+ frame,
232
+ registration.conn.auth,
233
+ );
153
234
  if (admission.type === "refused") {
154
235
  sendTo(h, registration.conn, JSON.stringify(admission.syn));
155
236
  }
@@ -159,11 +240,18 @@ const getStream = async (
159
240
  h: SocketHostState,
160
241
  request: Request,
161
242
  ): Promise<Response> => {
162
- const refused = await authorize(h, headersOf(request));
163
- if (refused !== null) return refused;
243
+ const authorized = await authorize(h, headersOf(request));
244
+ if ("refused" in authorized) return authorized.refused;
164
245
  const client = clientIdResponse(request);
165
246
  if ("response" in client) return client.response;
166
- const attachment = attachSse(h, client.clientId);
247
+ const mismatch = principalMismatchResponse(
248
+ h,
249
+ client.clientId,
250
+ authorized.auth,
251
+ );
252
+ if (mismatch !== null) return mismatch;
253
+ if (atSubscriberLimit(h)) return subscriberLimitResponse();
254
+ const attachment = attachSse(h, client.clientId, authorized.auth);
167
255
  const lease = attachment.registration === null ? null : mintLease();
168
256
  if (attachment.registration !== null)
169
257
  attachment.registration.conn.lease = lease;
@@ -176,30 +264,38 @@ const postStream = async (
176
264
  ): Promise<Response> => {
177
265
  const client = clientIdResponse(request);
178
266
  if ("response" in client) return client.response;
267
+ const raw = await readFrameBody(request);
268
+ if (raw === null) {
269
+ return Response.json({ detail: "frame body too large" }, { status: 413 });
270
+ }
179
271
  let body: unknown;
180
272
  try {
181
- body = JSON.parse(await request.text());
273
+ body = raw.trim() ? JSON.parse(raw) : null;
182
274
  } catch {
183
275
  return Response.json({ detail: "body must be JSON" }, { status: 400 });
184
276
  }
185
277
  let frame: ParsedFrame;
186
278
  try {
187
- frame = parseFrameValue(body, true);
279
+ frame =
280
+ body === null && !raw.trim()
281
+ ? { ctx: [], cmd: [] }
282
+ : parseFrameValue(body);
188
283
  } catch (error) {
189
284
  if (!(error instanceof Error) || error.name !== "StatewireViolation") {
190
285
  throw error;
191
286
  }
192
287
  return Response.json({ detail: error.message }, { status: 400 });
193
288
  }
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);
289
+ const authorized = await authorize(h, headersOf(request));
290
+ if ("refused" in authorized) return authorized.refused;
291
+ const mismatch = principalMismatchResponse(
292
+ h,
293
+ client.clientId,
294
+ authorized.auth,
295
+ );
296
+ if (mismatch !== null) return mismatch;
297
+ if (atSubscriberLimit(h)) return subscriberLimitResponse();
298
+ const attachment = attachSse(h, client.clientId, authorized.auth);
203
299
  if (attachment.registration === null) return streamResponse(attachment, null);
204
300
  const lease = mintLease();
205
301
  attachment.registration.conn.lease = lease;
@@ -238,8 +334,8 @@ const violationResponse = (
238
334
  reason: string,
239
335
  ) => {
240
336
  finClient(h, clientId, {
241
- reason: "error",
242
- message: `protocol violation: ${reason}`,
337
+ reason: "protocol-error",
338
+ message: reason,
243
339
  });
244
340
  return Response.json({ error: reason }, { status: 400 });
245
341
  };
@@ -248,34 +344,42 @@ export const frames = async (
248
344
  h: SocketHostState,
249
345
  request: Request,
250
346
  ): 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
347
  const lease = request.headers.get(LEASE_HEADER);
257
348
  // A lease lives and dies with its attach: any live attach of the id accepts.
258
- const live =
259
- lease !== null &&
260
- [...h.conns].some(
261
- (conn) =>
262
- conn.open && conn.clientId === client.clientId && conn.lease === lease,
263
- );
264
- if (h.disposed || h.finished !== null || rec === undefined || !live) {
349
+ const leased =
350
+ lease === null
351
+ ? undefined
352
+ : [...h.conns].find((conn) => conn.open && conn.lease === lease);
353
+ if (h.disposed || h.finished !== null || leased === undefined) {
265
354
  return Response.json({ error: "stale-lease" }, { status: 423 });
266
355
  }
356
+ const clientId = leased.clientId;
357
+ const rec = h.clients.get(clientId)!;
358
+ const raw = await readFrameBody(request);
359
+ if (raw === null) {
360
+ return Response.json({ error: "frame body too large" }, { status: 413 });
361
+ }
267
362
  let frame: ParsedFrame;
268
363
  try {
269
- frame = parseFrame(await request.text(), false);
364
+ frame = parseFrame(raw);
270
365
  } catch (error) {
271
366
  if (!(error instanceof Error) || error.name !== "StatewireViolation") {
272
367
  throw error;
273
368
  }
274
- return violationResponse(h, client.clientId, error.message);
369
+ return violationResponse(h, clientId, error.message);
370
+ }
371
+ if (!leased.open || h.disposed || h.finished !== null) {
372
+ return Response.json({ error: "stale-lease" }, { status: 423 });
275
373
  }
276
- const admission = admitFrame(h, rec, frame);
374
+ const admission = admitFrame(h, rec, frame, leased.auth);
277
375
  if (admission.type === "refused") {
278
- return Response.json(admission.syn, { status: 409 });
376
+ const retry = admission.syn.syn.retry;
377
+ return Response.json(admission.syn, {
378
+ status: retry === undefined ? 409 : 429,
379
+ ...(retry !== undefined && {
380
+ headers: { "Retry-After": String(Math.ceil(retry / 1000)) },
381
+ }),
382
+ });
279
383
  }
280
384
  return Response.json({});
281
385
  };