statewire 0.9.2 → 0.11.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 +13 -4
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +263 -66
- 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 +48 -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 +352 -70
- 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 +80 -8
- package/src/types.ts +1 -1
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
|
+
};
|