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.
- package/dist/client/connection.d.ts +1 -0
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +8 -1
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +7 -4
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +59 -11
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +5 -14
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +4 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/host/http.d.ts +7 -0
- package/dist/host/http.d.ts.map +1 -0
- package/dist/host/http.js +175 -0
- package/dist/host/http.js.map +1 -0
- package/dist/host/socket.d.ts +102 -1
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +48 -27
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +5 -0
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/statewire.d.ts +33 -7
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +9 -2
- package/dist/statewire.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +13 -1
- package/src/client/session.ts +85 -15
- package/src/client/sessionStore.ts +8 -15
- package/src/host/http.ts +280 -0
- package/src/host/socket.ts +120 -57
- package/src/host-internal.ts +5 -0
- package/src/statewire.ts +64 -8
- package/src/types.ts +1 -1
package/src/client/session.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
11
|
-
|
|
12
|
-
|
|
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 =
|
|
71
|
+
export type StatewireSessionOptions = {
|
|
72
|
+
sessionStore?: StatewireClient.SessionStorage | undefined;
|
|
69
73
|
onDone?: (() => void) | undefined;
|
|
70
74
|
};
|
|
71
75
|
|
|
72
76
|
type Session = {
|
|
73
|
-
record:
|
|
77
|
+
record: StatewireClient.SessionRecord;
|
|
74
78
|
ctx: StatewireRemoteProps;
|
|
75
79
|
recordPersisted: boolean;
|
|
76
|
-
store:
|
|
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:
|
|
137
|
+
const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
|
|
132
138
|
s.record = record;
|
|
133
|
-
if (!s.
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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 = ():
|
|
17
|
-
let record:
|
|
9
|
+
export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
|
|
10
|
+
let record: StatewireClient.SessionRecord | null = null;
|
|
18
11
|
return {
|
|
19
12
|
load: () => record,
|
|
20
13
|
save: (next) => {
|
package/src/host/http.ts
ADDED
|
@@ -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
|
+
};
|