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.
- package/README.md +29 -78
- package/dist/client/connection.d.ts +20 -6
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +120 -52
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +3 -2
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +10 -4
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +2 -0
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +97 -17
- package/dist/client/session.js.map +1 -1
- package/dist/host/adapter.d.ts +1 -1
- package/dist/host/adapter.d.ts.map +1 -1
- package/dist/host/adapter.js +7 -1
- package/dist/host/adapter.js.map +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +45 -5
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts +2 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +108 -47
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +33 -8
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +388 -58
- package/dist/host/socket.js.map +1 -1
- package/dist/host/work.d.ts +7 -0
- package/dist/host/work.d.ts.map +1 -0
- package/dist/host/work.js +53 -0
- package/dist/host/work.js.map +1 -0
- package/dist/host-internal.d.ts +42 -4
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host-internal.js +2 -1
- package/dist/host.d.ts +16 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/statewire.d.ts +21 -11
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +2 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts +1 -4
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +47 -56
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.js +2 -2
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +54 -41
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/documents.d.ts +20 -0
- package/dist/wire/documents.d.ts.map +1 -0
- package/dist/wire/documents.js +74 -0
- package/dist/wire/documents.js.map +1 -0
- package/dist/wire/limits.d.ts +10 -0
- package/dist/wire/limits.d.ts.map +1 -0
- package/dist/wire/limits.js +11 -0
- package/dist/wire/limits.js.map +1 -0
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +1 -1
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -2
- package/dist/wire/ops.js.map +1 -1
- package/dist/wire/version.d.ts +7 -0
- package/dist/wire/version.d.ts.map +1 -0
- package/dist/wire/version.js +8 -0
- package/dist/wire/version.js.map +1 -0
- package/package.json +1 -1
- package/src/client/connection.ts +213 -67
- package/src/client/core.ts +10 -3
- package/src/client/session.ts +106 -30
- package/src/host/adapter.ts +10 -2
- package/src/host/host.ts +54 -4
- package/src/host/http.ts +168 -64
- package/src/host/socket.ts +603 -60
- package/src/host/work.ts +50 -0
- package/src/host-internal.ts +44 -3
- package/src/host.ts +18 -2
- package/src/statewire.ts +33 -10
- package/src/transports/sse.ts +60 -68
- package/src/transports/testing.ts +2 -2
- package/src/transports/ws.ts +29 -48
- package/src/types.ts +1 -0
- package/src/wire/documents.ts +109 -0
- package/src/wire/limits.ts +6 -0
- package/src/wire/meta.ts +6 -1
- package/src/wire/ops.ts +1 -2
- 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<
|
|
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 = (
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 {
|
|
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
|
-
|
|
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]:
|
|
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(
|
|
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
|
|
163
|
-
if (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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
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:
|
|
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
|
|
259
|
-
lease
|
|
260
|
-
|
|
261
|
-
(conn) =>
|
|
262
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
};
|