statewire 0.14.0 → 0.16.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 -80
- package/dist/client/connection.d.ts +19 -6
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +79 -40
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +1 -0
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +5 -0
- 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 +98 -19
- 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 +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +75 -47
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +23 -7
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +275 -74
- 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 +35 -5
- 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/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/statewire.d.ts +13 -4
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts +2 -5
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +50 -59
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.js +4 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +3 -3
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +57 -44
- 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/util/randomId.js +1 -1
- package/dist/util/randomId.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 +159 -52
- package/src/client/core.ts +6 -0
- package/src/client/session.ts +105 -32
- package/src/host/adapter.ts +10 -2
- package/src/host/host.ts +54 -4
- package/src/host/http.ts +132 -66
- package/src/host/socket.ts +410 -81
- package/src/host/work.ts +50 -0
- package/src/host-internal.ts +32 -4
- package/src/host.ts +18 -2
- package/src/index.ts +2 -2
- package/src/statewire.ts +18 -4
- package/src/transports/sse.ts +70 -78
- package/src/transports/testing.ts +4 -4
- package/src/transports/ws.ts +36 -55
- package/src/types.ts +1 -0
- package/src/util/randomId.ts +3 -3
- 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/host.ts
CHANGED
|
@@ -136,6 +136,7 @@ export type CommandsHost = {
|
|
|
136
136
|
submit?: (call: CommandsSubmission) => Promise<unknown>;
|
|
137
137
|
/** Registered command names, read live. */
|
|
138
138
|
methods?: () => string[];
|
|
139
|
+
abort?: () => void;
|
|
139
140
|
};
|
|
140
141
|
|
|
141
142
|
const commandsHosts = new WeakMap<object, CommandsHost>();
|
|
@@ -158,12 +159,15 @@ type QueuedCall = {
|
|
|
158
159
|
|
|
159
160
|
type ActiveInvocation = CommandsInvocation & {
|
|
160
161
|
applied: boolean;
|
|
162
|
+
controller: AbortController;
|
|
163
|
+
settled: boolean;
|
|
161
164
|
origin?: CommandsSubmission;
|
|
162
165
|
};
|
|
163
166
|
|
|
164
167
|
type CommandsCell = {
|
|
165
168
|
handlers: Record<string, (...args: never[]) => unknown> | null;
|
|
166
169
|
queue: QueuedCall[];
|
|
170
|
+
active: Map<ActiveInvocation, QueuedCall>;
|
|
167
171
|
running: boolean;
|
|
168
172
|
listener: CommandsListener | null;
|
|
169
173
|
seq: number;
|
|
@@ -178,6 +182,7 @@ const LOCAL_CALLER: StatewireHost.Caller = Object.freeze({
|
|
|
178
182
|
get clientId(): string {
|
|
179
183
|
throw new Error("statewire: in-process dispatch has no client id");
|
|
180
184
|
},
|
|
185
|
+
auth: undefined,
|
|
181
186
|
context: null,
|
|
182
187
|
isConnected: true,
|
|
183
188
|
});
|
|
@@ -202,6 +207,7 @@ const applyInvocation = (cell: CommandsCell, inv: ActiveInvocation) => {
|
|
|
202
207
|
if (inv.applied) return;
|
|
203
208
|
inv.applied = true;
|
|
204
209
|
cell.listener?.onApplied(inv);
|
|
210
|
+
cell.listener?.onCovered?.(inv);
|
|
205
211
|
inv.origin?.onApplied?.();
|
|
206
212
|
};
|
|
207
213
|
|
|
@@ -211,6 +217,9 @@ const settleInvocation = (
|
|
|
211
217
|
call: QueuedCall,
|
|
212
218
|
settled: Settled,
|
|
213
219
|
) => {
|
|
220
|
+
if (inv.settled) return;
|
|
221
|
+
inv.settled = true;
|
|
222
|
+
cell.active.delete(inv);
|
|
214
223
|
applyInvocation(cell, inv);
|
|
215
224
|
if (settled.type === "resolved") {
|
|
216
225
|
const outcome: CommandsOutcome =
|
|
@@ -229,13 +238,40 @@ const settleInvocation = (
|
|
|
229
238
|
}
|
|
230
239
|
};
|
|
231
240
|
|
|
241
|
+
const abortInvocations = (cell: CommandsCell) => {
|
|
242
|
+
for (const [inv, call] of cell.active) {
|
|
243
|
+
inv.settled = true;
|
|
244
|
+
inv.controller.abort();
|
|
245
|
+
const error = new StatewireSendError({ fate: "crashed", cause: "server" });
|
|
246
|
+
const outcome: CommandsOutcome = { type: "rejected", error };
|
|
247
|
+
cell.listener?.onSettle(inv, outcome);
|
|
248
|
+
inv.origin?.onSettle?.(outcome);
|
|
249
|
+
call.reject(error);
|
|
250
|
+
}
|
|
251
|
+
cell.active.clear();
|
|
252
|
+
};
|
|
253
|
+
|
|
232
254
|
const isThenable = (value: unknown): value is PromiseLike<unknown> =>
|
|
233
255
|
typeof value === "object" &&
|
|
234
256
|
value !== null &&
|
|
235
257
|
typeof (value as { then?: unknown }).then === "function";
|
|
236
258
|
|
|
259
|
+
// A command is an own, enumerable, function-valued property — exactly what
|
|
260
|
+
// `Object.keys` shows, so dispatch, `statewire/commands`, and the reserved-
|
|
261
|
+
// namespace guard share one view; prototype-chain names are not commands.
|
|
262
|
+
const handlerOf = (
|
|
263
|
+
handlers: Record<string, (...args: never[]) => unknown>,
|
|
264
|
+
method: string,
|
|
265
|
+
): ((...args: never[]) => unknown) | undefined => {
|
|
266
|
+
if (!Object.prototype.propertyIsEnumerable.call(handlers, method)) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
const handler = handlers[method];
|
|
270
|
+
return typeof handler === "function" ? handler : undefined;
|
|
271
|
+
};
|
|
272
|
+
|
|
237
273
|
const dispatch = (cell: CommandsCell, call: QueuedCall) => {
|
|
238
|
-
const handler = cell.handlers
|
|
274
|
+
const handler = handlerOf(cell.handlers!, call.method);
|
|
239
275
|
if (handler === undefined) {
|
|
240
276
|
const error = new StatewireSendError({
|
|
241
277
|
fate: "rejected",
|
|
@@ -252,17 +288,22 @@ const dispatch = (cell: CommandsCell, call: QueuedCall) => {
|
|
|
252
288
|
ts: call.ts,
|
|
253
289
|
seq: ++cell.seq,
|
|
254
290
|
applied: false,
|
|
291
|
+
controller: new AbortController(),
|
|
292
|
+
settled: false,
|
|
255
293
|
...(call.origin !== undefined && { origin: call.origin }),
|
|
256
294
|
...(call.origin?.clientId !== undefined && {
|
|
257
295
|
clientId: call.origin.clientId,
|
|
258
296
|
}),
|
|
259
297
|
};
|
|
298
|
+
cell.active.set(inv, call);
|
|
260
299
|
cell.listener?.onStart(inv);
|
|
261
300
|
cell.listener?.onPosted?.(inv);
|
|
262
|
-
cell.listener?.onCovered?.(inv);
|
|
263
301
|
const ctx: StatewireHost.Ctx = {
|
|
264
302
|
caller: call.origin?.caller ?? LOCAL_CALLER,
|
|
265
|
-
|
|
303
|
+
signal: inv.controller.signal,
|
|
304
|
+
applied: () => {
|
|
305
|
+
if (!inv.settled) applyInvocation(cell, inv);
|
|
306
|
+
},
|
|
266
307
|
};
|
|
267
308
|
try {
|
|
268
309
|
const value = (
|
|
@@ -332,6 +373,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
|
|
|
332
373
|
const cell: CommandsCell = {
|
|
333
374
|
handlers: null,
|
|
334
375
|
queue: [],
|
|
376
|
+
active: new Map(),
|
|
335
377
|
running: false,
|
|
336
378
|
listener: null,
|
|
337
379
|
seq: 0,
|
|
@@ -358,10 +400,17 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
|
|
|
358
400
|
}),
|
|
359
401
|
);
|
|
360
402
|
commandsHosts.set(proxy, {
|
|
403
|
+
abort: () => abortInvocations(cell),
|
|
361
404
|
setListener: (listener) => {
|
|
362
405
|
cell.listener = listener;
|
|
363
406
|
},
|
|
364
|
-
methods: () =>
|
|
407
|
+
methods: () => {
|
|
408
|
+
const handlers = cell.handlers;
|
|
409
|
+
if (!handlers) return [];
|
|
410
|
+
return Object.keys(handlers).filter(
|
|
411
|
+
(method) => handlerOf(handlers, method) !== undefined,
|
|
412
|
+
);
|
|
413
|
+
},
|
|
365
414
|
submit: (origin) =>
|
|
366
415
|
new Promise((resolve, reject) => {
|
|
367
416
|
if (cell.unmounted) {
|
|
@@ -394,6 +443,7 @@ export const useStatewireCommands = <THandlers extends Statewire.Commands>(
|
|
|
394
443
|
}
|
|
395
444
|
queueMicrotask(() => {
|
|
396
445
|
if (!cell.unmounted) return;
|
|
446
|
+
abortInvocations(cell);
|
|
397
447
|
const queued = cell.queue;
|
|
398
448
|
cell.queue = [];
|
|
399
449
|
for (const call of queued) {
|
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,14 +14,17 @@ 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();
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
const laggardFin = encoder.encode(
|
|
26
|
+
'data: {"fin":{"reason":"reconnect","code":"client-fell-behind"}}\n\n',
|
|
27
|
+
);
|
|
21
28
|
const readFrameBody = async (request: Request): Promise<string | null> => {
|
|
22
29
|
const declared = request.headers.get("content-length");
|
|
23
30
|
if (declared !== null && Number(declared) > MAX_FRAME_BODY_BYTES) {
|
|
@@ -83,30 +90,37 @@ const clientIdResponse = (
|
|
|
83
90
|
const headersOf = (request: Request): Record<string, string> =>
|
|
84
91
|
Object.fromEntries(request.headers);
|
|
85
92
|
|
|
86
|
-
const mergedHeaders = (
|
|
87
|
-
request: Request,
|
|
88
|
-
extra: Record<string, string>,
|
|
89
|
-
): Record<string, string> => {
|
|
90
|
-
const headers = headersOf(request);
|
|
91
|
-
for (const [name, value] of Object.entries(extra)) {
|
|
92
|
-
headers[name.toLowerCase()] = value;
|
|
93
|
-
}
|
|
94
|
-
return headers;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
93
|
const authorize = async (
|
|
98
94
|
h: SocketHostState,
|
|
99
95
|
headers: Record<string, string>,
|
|
100
|
-
): Promise<
|
|
96
|
+
): Promise<{ auth: unknown } | { refused: Response }> => {
|
|
101
97
|
try {
|
|
102
|
-
await h.authorize?.({ headers });
|
|
103
|
-
return null;
|
|
98
|
+
return { auth: await h.authorize?.({ headers }) };
|
|
104
99
|
} catch (error) {
|
|
105
|
-
if (error instanceof Response) return error;
|
|
100
|
+
if (error instanceof Response) return { refused: error };
|
|
106
101
|
throw error;
|
|
107
102
|
}
|
|
108
103
|
};
|
|
109
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
|
+
|
|
110
124
|
const mintLease = (): string => {
|
|
111
125
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
112
126
|
return btoa(String.fromCharCode(...bytes))
|
|
@@ -118,24 +132,48 @@ const mintLease = (): string => {
|
|
|
118
132
|
type SseAttachment = {
|
|
119
133
|
body: ReadableStream<Uint8Array>;
|
|
120
134
|
registration: Registration | null;
|
|
135
|
+
activateBackpressure: () => void;
|
|
121
136
|
};
|
|
122
137
|
|
|
123
|
-
const attachSse = (
|
|
138
|
+
const attachSse = (
|
|
139
|
+
h: SocketHostState,
|
|
140
|
+
clientId: string,
|
|
141
|
+
auth: unknown,
|
|
142
|
+
): SseAttachment => {
|
|
124
143
|
let controller: ReadableStreamDefaultController<Uint8Array>;
|
|
125
144
|
let connection: Registration["connection"] | null = null;
|
|
126
145
|
let open = true;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
},
|
|
134
156
|
},
|
|
135
|
-
|
|
157
|
+
{ highWaterMark: h.streamQueueSize },
|
|
158
|
+
);
|
|
136
159
|
const socket = {
|
|
137
160
|
send: (data: string) => {
|
|
138
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
|
+
}
|
|
139
177
|
const frame = data === "{}" ? ": ping\n\n" : `data: ${data}\n\n`;
|
|
140
178
|
try {
|
|
141
179
|
controller.enqueue(encoder.encode(frame));
|
|
@@ -154,24 +192,32 @@ const attachSse = (h: SocketHostState, clientId: string): SseAttachment => {
|
|
|
154
192
|
}
|
|
155
193
|
},
|
|
156
194
|
};
|
|
157
|
-
const registration = register(h, clientId, socket);
|
|
195
|
+
const registration = register(h, clientId, socket, false, auth);
|
|
158
196
|
connection = registration?.connection ?? null;
|
|
159
197
|
if (!open) connection?.close();
|
|
160
|
-
return {
|
|
198
|
+
return {
|
|
199
|
+
body,
|
|
200
|
+
registration,
|
|
201
|
+
activateBackpressure: () => {
|
|
202
|
+
backpressureActive = true;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
161
205
|
};
|
|
162
206
|
|
|
163
207
|
const streamResponse = (
|
|
164
208
|
attachment: SseAttachment,
|
|
165
209
|
lease: string | null,
|
|
166
|
-
): Response =>
|
|
167
|
-
|
|
210
|
+
): Response => {
|
|
211
|
+
attachment.activateBackpressure();
|
|
212
|
+
return new Response(attachment.body, {
|
|
168
213
|
status: 200,
|
|
169
214
|
headers: {
|
|
170
215
|
"Content-Type": "text/event-stream",
|
|
171
|
-
[VERSION_HEADER]:
|
|
216
|
+
[VERSION_HEADER]: PROTOCOL_VERSION,
|
|
172
217
|
...(lease !== null && { [LEASE_HEADER]: lease }),
|
|
173
218
|
},
|
|
174
219
|
});
|
|
220
|
+
};
|
|
175
221
|
|
|
176
222
|
const admitAttachFrame = (
|
|
177
223
|
h: SocketHostState,
|
|
@@ -179,7 +225,12 @@ const admitAttachFrame = (
|
|
|
179
225
|
frame: ParsedFrame,
|
|
180
226
|
) => {
|
|
181
227
|
if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
|
|
182
|
-
const admission = admitFrame(
|
|
228
|
+
const admission = admitFrame(
|
|
229
|
+
h,
|
|
230
|
+
registration.rec,
|
|
231
|
+
frame,
|
|
232
|
+
registration.conn.auth,
|
|
233
|
+
);
|
|
183
234
|
if (admission.type === "refused") {
|
|
184
235
|
sendTo(h, registration.conn, JSON.stringify(admission.syn));
|
|
185
236
|
}
|
|
@@ -189,11 +240,18 @@ const getStream = async (
|
|
|
189
240
|
h: SocketHostState,
|
|
190
241
|
request: Request,
|
|
191
242
|
): Promise<Response> => {
|
|
192
|
-
const
|
|
193
|
-
if (refused
|
|
243
|
+
const authorized = await authorize(h, headersOf(request));
|
|
244
|
+
if ("refused" in authorized) return authorized.refused;
|
|
194
245
|
const client = clientIdResponse(request);
|
|
195
246
|
if ("response" in client) return client.response;
|
|
196
|
-
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);
|
|
197
255
|
const lease = attachment.registration === null ? null : mintLease();
|
|
198
256
|
if (attachment.registration !== null)
|
|
199
257
|
attachment.registration.conn.lease = lease;
|
|
@@ -212,28 +270,32 @@ const postStream = async (
|
|
|
212
270
|
}
|
|
213
271
|
let body: unknown;
|
|
214
272
|
try {
|
|
215
|
-
body = JSON.parse(raw);
|
|
273
|
+
body = raw.trim() ? JSON.parse(raw) : null;
|
|
216
274
|
} catch {
|
|
217
275
|
return Response.json({ detail: "body must be JSON" }, { status: 400 });
|
|
218
276
|
}
|
|
219
277
|
let frame: ParsedFrame;
|
|
220
278
|
try {
|
|
221
|
-
frame =
|
|
279
|
+
frame =
|
|
280
|
+
body === null && !raw.trim()
|
|
281
|
+
? { ctx: [], cmd: [] }
|
|
282
|
+
: parseFrameValue(body);
|
|
222
283
|
} catch (error) {
|
|
223
284
|
if (!(error instanceof Error) || error.name !== "StatewireViolation") {
|
|
224
285
|
throw error;
|
|
225
286
|
}
|
|
226
287
|
return Response.json({ detail: error.message }, { status: 400 });
|
|
227
288
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
|
|
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);
|
|
237
299
|
if (attachment.registration === null) return streamResponse(attachment, null);
|
|
238
300
|
const lease = mintLease();
|
|
239
301
|
attachment.registration.conn.lease = lease;
|
|
@@ -272,8 +334,8 @@ const violationResponse = (
|
|
|
272
334
|
reason: string,
|
|
273
335
|
) => {
|
|
274
336
|
finClient(h, clientId, {
|
|
275
|
-
reason: "error",
|
|
276
|
-
message:
|
|
337
|
+
reason: "protocol-error",
|
|
338
|
+
message: reason,
|
|
277
339
|
});
|
|
278
340
|
return Response.json({ error: reason }, { status: 400 });
|
|
279
341
|
};
|
|
@@ -282,38 +344,42 @@ export const frames = async (
|
|
|
282
344
|
h: SocketHostState,
|
|
283
345
|
request: Request,
|
|
284
346
|
): Promise<Response> => {
|
|
285
|
-
const refused = await authorize(h, headersOf(request));
|
|
286
|
-
if (refused !== null) return refused;
|
|
287
|
-
const client = clientIdResponse(request);
|
|
288
|
-
if ("response" in client) return client.response;
|
|
289
|
-
const rec = h.clients.get(client.clientId);
|
|
290
347
|
const lease = request.headers.get(LEASE_HEADER);
|
|
291
348
|
// A lease lives and dies with its attach: any live attach of the id accepts.
|
|
292
|
-
const
|
|
293
|
-
lease
|
|
294
|
-
|
|
295
|
-
(conn) =>
|
|
296
|
-
|
|
297
|
-
);
|
|
298
|
-
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) {
|
|
299
354
|
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
300
355
|
}
|
|
356
|
+
const clientId = leased.clientId;
|
|
357
|
+
const rec = h.clients.get(clientId)!;
|
|
301
358
|
const raw = await readFrameBody(request);
|
|
302
359
|
if (raw === null) {
|
|
303
360
|
return Response.json({ error: "frame body too large" }, { status: 413 });
|
|
304
361
|
}
|
|
305
362
|
let frame: ParsedFrame;
|
|
306
363
|
try {
|
|
307
|
-
frame = parseFrame(raw
|
|
364
|
+
frame = parseFrame(raw);
|
|
308
365
|
} catch (error) {
|
|
309
366
|
if (!(error instanceof Error) || error.name !== "StatewireViolation") {
|
|
310
367
|
throw error;
|
|
311
368
|
}
|
|
312
|
-
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 });
|
|
313
373
|
}
|
|
314
|
-
const admission = admitFrame(h, rec, frame);
|
|
374
|
+
const admission = admitFrame(h, rec, frame, leased.auth);
|
|
315
375
|
if (admission.type === "refused") {
|
|
316
|
-
|
|
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
|
+
});
|
|
317
383
|
}
|
|
318
384
|
return Response.json({});
|
|
319
385
|
};
|