statewire 0.16.0 → 0.17.1
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 +12 -4
- package/dist/client/connection.d.ts +5 -1
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +22 -6
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +2 -0
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +15 -4
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +235 -52
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +5 -1
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +3 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/document.d.ts +25 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +88 -0
- package/dist/document.js.map +1 -0
- package/dist/host/draft.js +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +27 -9
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +12 -5
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +107 -43
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +9 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host-internal.js +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -1
- package/dist/statewire.d.ts +33 -2
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +34 -3
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fixtures.js +1 -0
- package/dist/transports/fixtures.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +15 -8
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +8 -8
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +1 -1
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +12 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.js +2 -2
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/documents.d.ts +25 -2
- package/dist/wire/documents.d.ts.map +1 -1
- package/dist/wire/documents.js +28 -8
- package/dist/wire/documents.js.map +1 -1
- package/dist/wire/meta.d.ts +3 -18
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +8 -57
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.js +1 -1
- package/dist/wire/protocol.d.ts +55 -0
- package/dist/wire/protocol.d.ts.map +1 -0
- package/dist/wire/protocol.js +221 -0
- package/dist/wire/protocol.js.map +1 -0
- package/dist/wire/version.d.ts +1 -3
- package/dist/wire/version.d.ts.map +1 -1
- package/dist/wire/version.js +1 -3
- package/dist/wire/version.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +43 -13
- package/src/client/core.ts +6 -0
- package/src/client/session.ts +340 -45
- package/src/client/sessionStore.ts +8 -0
- package/src/document.ts +185 -0
- package/src/host/host.ts +1 -0
- package/src/host/http.ts +36 -7
- package/src/host/socket.ts +149 -57
- package/src/host-internal.ts +12 -1
- package/src/index.ts +5 -0
- package/src/statewire.ts +109 -6
- package/src/transports/fixtures.ts +1 -1
- package/src/transports/remote.ts +16 -7
- package/src/transports/sse.ts +18 -8
- package/src/transports/testing.ts +10 -2
- package/src/transports/ws.ts +1 -1
- package/src/types.ts +11 -0
- package/src/wire/documents.ts +126 -8
- package/src/wire/meta.ts +11 -94
- package/src/wire/protocol.ts +327 -0
- package/src/wire/version.ts +0 -2
package/src/document.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { isUnsafeKey, walkStatePath } from "./util/statePath";
|
|
2
|
+
|
|
3
|
+
export namespace StatewireDocument {
|
|
4
|
+
export type Value =
|
|
5
|
+
| null
|
|
6
|
+
| boolean
|
|
7
|
+
| number
|
|
8
|
+
| string
|
|
9
|
+
| Value[]
|
|
10
|
+
| { [key: string]: Value };
|
|
11
|
+
export type MetaEntry = { path: (string | number)[]; type: "date" };
|
|
12
|
+
export type Encoded = { value: Value; meta?: MetaEntry[] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const invalid = (message: string): never => {
|
|
16
|
+
throw new Error(`statewire: protocol error: invalid document: ${message}`);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
20
|
+
typeof value === "object" &&
|
|
21
|
+
value !== null &&
|
|
22
|
+
(Object.getPrototypeOf(value) === Object.prototype ||
|
|
23
|
+
Object.getPrototypeOf(value) === null);
|
|
24
|
+
|
|
25
|
+
const copyValue = (
|
|
26
|
+
value: unknown,
|
|
27
|
+
dates: boolean,
|
|
28
|
+
meta: StatewireDocument.MetaEntry[],
|
|
29
|
+
path: (string | number)[] = [],
|
|
30
|
+
ancestors = new Set<object>(),
|
|
31
|
+
): StatewireDocument.Value => {
|
|
32
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
33
|
+
return value;
|
|
34
|
+
if (typeof value === "number") {
|
|
35
|
+
if (
|
|
36
|
+
!Number.isFinite(value) ||
|
|
37
|
+
(Number.isInteger(value) && !Number.isSafeInteger(value))
|
|
38
|
+
)
|
|
39
|
+
invalid(
|
|
40
|
+
`number at ${JSON.stringify(path)} must be finite and within the safe integer range`,
|
|
41
|
+
);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
if (dates && value instanceof Date) {
|
|
45
|
+
if (
|
|
46
|
+
!Number.isFinite(value.getTime()) ||
|
|
47
|
+
value.getUTCFullYear() < 1 ||
|
|
48
|
+
value.getUTCFullYear() > 9999
|
|
49
|
+
)
|
|
50
|
+
invalid(
|
|
51
|
+
`date at ${JSON.stringify(path)} must have a year from 0001 to 9999`,
|
|
52
|
+
);
|
|
53
|
+
meta.push({ path, type: "date" });
|
|
54
|
+
return value.toISOString();
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(value) && !isObject(value))
|
|
57
|
+
return invalid(`unsupported value at ${JSON.stringify(path)}`);
|
|
58
|
+
if (ancestors.has(value)) invalid(`cycle at ${JSON.stringify(path)}`);
|
|
59
|
+
ancestors.add(value);
|
|
60
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
61
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
62
|
+
if (Array.isArray(value) && key === "length") continue;
|
|
63
|
+
if (typeof key !== "string")
|
|
64
|
+
invalid(`symbol key at ${JSON.stringify(path)}`);
|
|
65
|
+
const descriptor = descriptors[key as string]!;
|
|
66
|
+
if (!("value" in descriptor) || !descriptor.enumerable)
|
|
67
|
+
invalid(
|
|
68
|
+
`property at ${JSON.stringify([...path, key])} must be enumerable data`,
|
|
69
|
+
);
|
|
70
|
+
if (isUnsafeKey(key as string, path.at(-1)))
|
|
71
|
+
invalid(`unsafe key at ${JSON.stringify([...path, key])}`);
|
|
72
|
+
if (
|
|
73
|
+
Array.isArray(value) &&
|
|
74
|
+
(!/^(0|[1-9]\d*)$/.test(key as string) || Number(key) >= value.length)
|
|
75
|
+
)
|
|
76
|
+
invalid(
|
|
77
|
+
`unsupported array property at ${JSON.stringify([...path, key])}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
let result: StatewireDocument.Value;
|
|
81
|
+
if (Array.isArray(value)) {
|
|
82
|
+
result = Array.from({ length: value.length }, (_, index) => {
|
|
83
|
+
if (!Object.hasOwn(value, index))
|
|
84
|
+
invalid(`sparse array at ${JSON.stringify(path)}`);
|
|
85
|
+
return copyValue(
|
|
86
|
+
descriptors[index]!.value,
|
|
87
|
+
dates,
|
|
88
|
+
meta,
|
|
89
|
+
[...path, index],
|
|
90
|
+
ancestors,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
result = Object.fromEntries(
|
|
95
|
+
Object.keys(descriptors).map((key) => [
|
|
96
|
+
key,
|
|
97
|
+
copyValue(
|
|
98
|
+
descriptors[key]!.value,
|
|
99
|
+
dates,
|
|
100
|
+
meta,
|
|
101
|
+
[...path, key],
|
|
102
|
+
ancestors,
|
|
103
|
+
),
|
|
104
|
+
]),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
ancestors.delete(value);
|
|
108
|
+
return result;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Encode a document value as detached JSON data with date metadata. */
|
|
112
|
+
export const encodeDocument = (value: unknown): StatewireDocument.Encoded => {
|
|
113
|
+
const meta: StatewireDocument.MetaEntry[] = [];
|
|
114
|
+
const encoded = copyValue(value, true, meta);
|
|
115
|
+
return meta.length ? { value: encoded, meta } : { value: encoded };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Validate and decode a document into detached values with native dates. */
|
|
119
|
+
export const decodeDocument = (encoded: unknown): unknown => {
|
|
120
|
+
const document = copyValue(encoded, false, []);
|
|
121
|
+
if (
|
|
122
|
+
!isObject(document) ||
|
|
123
|
+
!Object.hasOwn(document, "value") ||
|
|
124
|
+
Object.keys(document).some((key) => key !== "value" && key !== "meta")
|
|
125
|
+
)
|
|
126
|
+
return invalid("expected an object with value and optional meta");
|
|
127
|
+
const value: unknown = document.value;
|
|
128
|
+
if (!Object.hasOwn(document, "meta")) return value;
|
|
129
|
+
if (!Array.isArray(document.meta)) return invalid("meta must be an array");
|
|
130
|
+
return reviveDocumentValue(value, document.meta, {
|
|
131
|
+
rejectUnknown: true,
|
|
132
|
+
canonical: true,
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const reviveDocumentValue = (
|
|
137
|
+
value: unknown,
|
|
138
|
+
meta: unknown,
|
|
139
|
+
{ rejectUnknown = false, canonical = false } = {},
|
|
140
|
+
): unknown => {
|
|
141
|
+
if (!Array.isArray(meta)) return invalid("meta must be an array");
|
|
142
|
+
const seen = new Set<string>();
|
|
143
|
+
for (const entry of meta) {
|
|
144
|
+
if (
|
|
145
|
+
!isObject(entry) ||
|
|
146
|
+
!Array.isArray(entry.path) ||
|
|
147
|
+
(canonical &&
|
|
148
|
+
Object.keys(entry).some((key) => key !== "path" && key !== "type")) ||
|
|
149
|
+
!entry.path.every(
|
|
150
|
+
(segment) =>
|
|
151
|
+
typeof segment === "string" ||
|
|
152
|
+
(typeof segment === "number" &&
|
|
153
|
+
Number.isSafeInteger(segment) &&
|
|
154
|
+
segment >= 0),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return invalid("meta entry requires a path array and type date");
|
|
158
|
+
if (entry.type !== "date") {
|
|
159
|
+
if (rejectUnknown) invalid(`unknown meta type: ${String(entry.type)}`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const path = entry.path as (string | number)[];
|
|
163
|
+
const identity = JSON.stringify(path);
|
|
164
|
+
if (seen.has(identity)) invalid(`duplicate meta path ${identity}`);
|
|
165
|
+
seen.add(identity);
|
|
166
|
+
const walked = walkStatePath(value, path, "resolve");
|
|
167
|
+
if (!walked || typeof walked.leaf !== "string")
|
|
168
|
+
return invalid(`meta path ${identity} must resolve to a string`);
|
|
169
|
+
const date = new Date(walked.leaf);
|
|
170
|
+
if (
|
|
171
|
+
canonical
|
|
172
|
+
? !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(walked.leaf) ||
|
|
173
|
+
!Number.isFinite(date.getTime()) ||
|
|
174
|
+
date.getUTCFullYear() < 1 ||
|
|
175
|
+
date.toISOString() !== walked.leaf
|
|
176
|
+
: !/(?:T| )\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i.test(
|
|
177
|
+
walked.leaf,
|
|
178
|
+
) || !Number.isFinite(date.getTime())
|
|
179
|
+
)
|
|
180
|
+
return invalid(`invalid date at meta path ${identity}`);
|
|
181
|
+
if (walked.parent === undefined) value = date;
|
|
182
|
+
else (walked.parent as Record<string | number, unknown>)[walked.key] = date;
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
};
|
package/src/host/host.ts
CHANGED
package/src/host/http.ts
CHANGED
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
} from "./socket";
|
|
16
16
|
import type { ParsedFrame, Registration, SocketHostState } from "./socket";
|
|
17
17
|
import { MAX_FRAME_BODY_BYTES } from "../wire/limits";
|
|
18
|
-
import {
|
|
18
|
+
import { negotiateProtocols } from "../wire/protocol";
|
|
19
|
+
import type { StatewireProtocol } from "../wire/protocol";
|
|
19
20
|
|
|
20
21
|
export { MAX_FRAME_BODY_BYTES } from "../wire/limits";
|
|
21
22
|
|
|
@@ -139,6 +140,7 @@ const attachSse = (
|
|
|
139
140
|
h: SocketHostState,
|
|
140
141
|
clientId: string,
|
|
141
142
|
auth: unknown,
|
|
143
|
+
selection: StatewireProtocol.Selection,
|
|
142
144
|
): SseAttachment => {
|
|
143
145
|
let controller: ReadableStreamDefaultController<Uint8Array>;
|
|
144
146
|
let connection: Registration["connection"] | null = null;
|
|
@@ -192,7 +194,7 @@ const attachSse = (
|
|
|
192
194
|
}
|
|
193
195
|
},
|
|
194
196
|
};
|
|
195
|
-
const registration = register(h, clientId, socket, false, auth);
|
|
197
|
+
const registration = register(h, clientId, socket, false, auth, selection);
|
|
196
198
|
connection = registration?.connection ?? null;
|
|
197
199
|
if (!open) connection?.close();
|
|
198
200
|
return {
|
|
@@ -213,7 +215,6 @@ const streamResponse = (
|
|
|
213
215
|
status: 200,
|
|
214
216
|
headers: {
|
|
215
217
|
"Content-Type": "text/event-stream",
|
|
216
|
-
[VERSION_HEADER]: PROTOCOL_VERSION,
|
|
217
218
|
...(lease !== null && { [LEASE_HEADER]: lease }),
|
|
218
219
|
},
|
|
219
220
|
});
|
|
@@ -230,6 +231,7 @@ const admitAttachFrame = (
|
|
|
230
231
|
registration.rec,
|
|
231
232
|
frame,
|
|
232
233
|
registration.conn.auth,
|
|
234
|
+
registration.conn.selection,
|
|
233
235
|
);
|
|
234
236
|
if (admission.type === "refused") {
|
|
235
237
|
sendTo(h, registration.conn, JSON.stringify(admission.syn));
|
|
@@ -251,7 +253,12 @@ const getStream = async (
|
|
|
251
253
|
);
|
|
252
254
|
if (mismatch !== null) return mismatch;
|
|
253
255
|
if (atSubscriberLimit(h)) return subscriberLimitResponse();
|
|
254
|
-
const
|
|
256
|
+
const selection = negotiateProtocols(request.headers, h.wireVersion, [
|
|
257
|
+
h.protocol,
|
|
258
|
+
]);
|
|
259
|
+
if ("fin" in selection)
|
|
260
|
+
return Response.json(selection, { status: selection.fin.status });
|
|
261
|
+
const attachment = attachSse(h, client.clientId, authorized.auth, selection);
|
|
255
262
|
const lease = attachment.registration === null ? null : mintLease();
|
|
256
263
|
if (attachment.registration !== null)
|
|
257
264
|
attachment.registration.conn.lease = lease;
|
|
@@ -295,11 +302,26 @@ const postStream = async (
|
|
|
295
302
|
);
|
|
296
303
|
if (mismatch !== null) return mismatch;
|
|
297
304
|
if (atSubscriberLimit(h)) return subscriberLimitResponse();
|
|
298
|
-
const
|
|
305
|
+
const selection = negotiateProtocols(request.headers, h.wireVersion, [
|
|
306
|
+
h.protocol,
|
|
307
|
+
]);
|
|
308
|
+
if ("fin" in selection)
|
|
309
|
+
return Response.json(selection, { status: selection.fin.status });
|
|
310
|
+
const attachment = attachSse(h, client.clientId, authorized.auth, selection);
|
|
299
311
|
if (attachment.registration === null) return streamResponse(attachment, null);
|
|
300
312
|
const lease = mintLease();
|
|
301
313
|
attachment.registration.conn.lease = lease;
|
|
302
|
-
|
|
314
|
+
try {
|
|
315
|
+
admitAttachFrame(h, attachment.registration, frame);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
closeConn(h, attachment.registration.conn, 1008, "invalid-context");
|
|
318
|
+
if (!(error instanceof Error) || error.name !== "StatewireViolation")
|
|
319
|
+
throw error;
|
|
320
|
+
return Response.json(
|
|
321
|
+
{ fin: { reason: "protocol-error", message: error.message } },
|
|
322
|
+
{ status: 400 },
|
|
323
|
+
);
|
|
324
|
+
}
|
|
303
325
|
return streamResponse(attachment, lease);
|
|
304
326
|
};
|
|
305
327
|
|
|
@@ -371,7 +393,14 @@ export const frames = async (
|
|
|
371
393
|
if (!leased.open || h.disposed || h.finished !== null) {
|
|
372
394
|
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
373
395
|
}
|
|
374
|
-
|
|
396
|
+
let admission: ReturnType<typeof admitFrame>;
|
|
397
|
+
try {
|
|
398
|
+
admission = admitFrame(h, rec, frame, leased.auth, leased.selection);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (!(error instanceof Error) || error.name !== "StatewireViolation")
|
|
401
|
+
throw error;
|
|
402
|
+
return violationResponse(h, clientId, error.message);
|
|
403
|
+
}
|
|
375
404
|
if (admission.type === "refused") {
|
|
376
405
|
const retry = admission.syn.syn.retry;
|
|
377
406
|
return Response.json(admission.syn, {
|