statewire 0.15.0 → 0.17.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 +14 -6
- package/dist/client/connection.d.ts +5 -1
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +19 -4
- 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 +233 -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/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 +10 -3
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +103 -39
- 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 +5 -3
- package/dist/index.js +3 -3
- 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 +2 -2
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +11 -11
- 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 +14 -6
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +2 -2
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +5 -5
- 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/util/randomId.js +1 -1
- package/dist/util/randomId.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 +27 -7
- package/dist/wire/documents.js.map +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 +339 -42
- package/src/client/sessionStore.ts +8 -0
- package/src/host/host.ts +1 -0
- package/src/host/http.ts +36 -7
- package/src/host/socket.ts +141 -50
- package/src/host-internal.ts +12 -1
- package/src/index.ts +5 -2
- 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 +28 -18
- package/src/transports/testing.ts +12 -4
- package/src/transports/ws.ts +8 -8
- package/src/types.ts +11 -0
- package/src/util/randomId.ts +3 -3
- package/src/wire/documents.ts +126 -8
- package/src/wire/protocol.ts +327 -0
- package/src/wire/version.ts +0 -2
package/src/wire/documents.ts
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { applyOps } from "./ops";
|
|
2
2
|
import { reviveValue } from "./meta";
|
|
3
|
+
import { protocolToken } from "./protocol";
|
|
3
4
|
import type { StatewirePath } from "./ops";
|
|
4
5
|
|
|
5
6
|
export namespace StatewireDocuments {
|
|
6
|
-
export type
|
|
7
|
+
export type CacheEntry = { version: string; value: unknown };
|
|
8
|
+
export type Storage = {
|
|
9
|
+
namespace?: string;
|
|
10
|
+
load: (identity: readonly string[]) => CacheEntry | undefined;
|
|
11
|
+
save: (identity: readonly string[], entry: CacheEntry) => void;
|
|
12
|
+
subscribe?: (listener: () => void) => () => void;
|
|
13
|
+
};
|
|
14
|
+
export type Source = {
|
|
15
|
+
get: (
|
|
16
|
+
protocol: string,
|
|
17
|
+
key?: readonly string[],
|
|
18
|
+
prefix?: readonly string[],
|
|
19
|
+
) => unknown;
|
|
20
|
+
list: () => ReadonlyMap<number, Document>;
|
|
21
|
+
subscribe: (listener: () => void) => () => void;
|
|
22
|
+
};
|
|
23
|
+
export type Document = {
|
|
24
|
+
key: readonly string[] | null;
|
|
25
|
+
protocol?: string;
|
|
26
|
+
main?: boolean;
|
|
27
|
+
value: unknown;
|
|
28
|
+
};
|
|
7
29
|
export type State = {
|
|
8
30
|
mode: "mounted" | "keyless" | null;
|
|
9
31
|
documents: ReadonlyMap<number, Document>;
|
|
@@ -17,8 +39,12 @@ const invalid: (message: string) => never = (message) => {
|
|
|
17
39
|
export const applyDocumentOps = (
|
|
18
40
|
previous: StatewireDocuments.State,
|
|
19
41
|
ops: readonly unknown[],
|
|
42
|
+
protocols?: readonly { name: string; version: string }[],
|
|
43
|
+
strict = false,
|
|
20
44
|
) => {
|
|
21
|
-
let mode =
|
|
45
|
+
let mode =
|
|
46
|
+
previous.mode ??
|
|
47
|
+
(protocols !== undefined && ops.length === 0 ? "mounted" : null);
|
|
22
48
|
const documents = new Map(previous.documents);
|
|
23
49
|
const changedPaths: StatewirePath[] = [];
|
|
24
50
|
for (const raw of ops) {
|
|
@@ -35,10 +61,13 @@ export const applyDocumentOps = (
|
|
|
35
61
|
op.op as string,
|
|
36
62
|
)
|
|
37
63
|
) {
|
|
64
|
+
if (strict) invalid("unknown context operation");
|
|
38
65
|
const { doc: _doc, ...unknown } = op;
|
|
39
66
|
applyOps(undefined, [unknown]);
|
|
40
67
|
continue;
|
|
41
68
|
}
|
|
69
|
+
if (protocols !== undefined && mode === null && op.op !== "mount")
|
|
70
|
+
invalid("negotiated state must start with mounts");
|
|
42
71
|
if (mode === null) {
|
|
43
72
|
if (op.op === "mount") mode = "mounted";
|
|
44
73
|
else if (
|
|
@@ -64,23 +93,73 @@ export const applyDocumentOps = (
|
|
|
64
93
|
if (op.op === "mount") {
|
|
65
94
|
if (documents.has(doc)) invalid("document is already mounted");
|
|
66
95
|
if (
|
|
67
|
-
|
|
68
|
-
|
|
96
|
+
(op.key !== undefined &&
|
|
97
|
+
(!Array.isArray(op.key) ||
|
|
98
|
+
!op.key.every((key) => typeof key === "string"))) ||
|
|
69
99
|
!("value" in op)
|
|
70
100
|
) {
|
|
71
|
-
invalid("mount requires
|
|
101
|
+
invalid("mount requires value and an optional key array");
|
|
72
102
|
}
|
|
103
|
+
if (
|
|
104
|
+
op.protocol !== undefined &&
|
|
105
|
+
(typeof op.protocol !== "string" ||
|
|
106
|
+
!protocolToken.test(op.protocol) ||
|
|
107
|
+
op.protocol === "*")
|
|
108
|
+
)
|
|
109
|
+
invalid("invalid mount protocol");
|
|
110
|
+
if (
|
|
111
|
+
protocols !== undefined &&
|
|
112
|
+
!protocols.some((p) => p.name === op.protocol)
|
|
113
|
+
)
|
|
114
|
+
invalid("mount protocol was not negotiated");
|
|
115
|
+
if (op.main !== undefined && typeof op.main !== "boolean")
|
|
116
|
+
invalid("main must be boolean");
|
|
117
|
+
if (
|
|
118
|
+
op.main === true &&
|
|
119
|
+
[...documents.values()].some(
|
|
120
|
+
(d) => d.protocol === op.protocol && d.main,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
invalid("protocol already has a main document");
|
|
124
|
+
if (
|
|
125
|
+
op.key !== undefined &&
|
|
126
|
+
[...documents.values()].some(
|
|
127
|
+
(d) =>
|
|
128
|
+
d.protocol === op.protocol &&
|
|
129
|
+
JSON.stringify(d.key) === JSON.stringify(op.key),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
invalid("protocol document key is already mounted");
|
|
73
133
|
documents.set(doc, {
|
|
74
|
-
key: op.key,
|
|
134
|
+
key: op.key === undefined ? null : (op.key as string[]),
|
|
135
|
+
...(op.protocol === undefined
|
|
136
|
+
? {}
|
|
137
|
+
: { protocol: op.protocol as string }),
|
|
138
|
+
...(op.main === undefined ? {} : { main: op.main as boolean }),
|
|
75
139
|
value:
|
|
76
|
-
op.meta === undefined
|
|
140
|
+
op.meta === undefined
|
|
141
|
+
? op.value
|
|
142
|
+
: reviveValue(op.value, op.meta, { rejectUnknown: strict }),
|
|
77
143
|
});
|
|
78
144
|
if (doc === 0) changedPaths.push([]);
|
|
79
145
|
} else if (op.op === "unmount") {
|
|
146
|
+
const document = documents.get(doc);
|
|
147
|
+
if (
|
|
148
|
+
protocols !== undefined &&
|
|
149
|
+
document !== undefined &&
|
|
150
|
+
!protocols.some((p) => p.name === document.protocol)
|
|
151
|
+
)
|
|
152
|
+
invalid("document protocol was not negotiated");
|
|
80
153
|
if (!documents.delete(doc)) invalid("document is not mounted");
|
|
81
154
|
if (doc === 0) changedPaths.push([]);
|
|
82
155
|
} else if (op.op === "add" || op.op === "replace" || op.op === "remove") {
|
|
83
156
|
const document = documents.get(doc);
|
|
157
|
+
if (
|
|
158
|
+
protocols !== undefined &&
|
|
159
|
+
document !== undefined &&
|
|
160
|
+
!protocols.some((p) => p.name === document.protocol)
|
|
161
|
+
)
|
|
162
|
+
invalid("document protocol was not negotiated");
|
|
84
163
|
if (!document) invalid("document is not mounted");
|
|
85
164
|
if (
|
|
86
165
|
!Array.isArray(op.path) ||
|
|
@@ -98,7 +177,9 @@ export const applyDocumentOps = (
|
|
|
98
177
|
invalid("operation requires a value");
|
|
99
178
|
const { doc: _doc, meta, ...mutation } = op;
|
|
100
179
|
if (meta !== undefined)
|
|
101
|
-
mutation.value = reviveValue(mutation.value, meta
|
|
180
|
+
mutation.value = reviveValue(mutation.value, meta, {
|
|
181
|
+
rejectUnknown: strict,
|
|
182
|
+
});
|
|
102
183
|
const applied = applyOps(document.value, [mutation]);
|
|
103
184
|
documents.set(doc, { ...document, value: applied.state });
|
|
104
185
|
if (doc === 0) changedPaths.push(...applied.changedPaths);
|
|
@@ -107,3 +188,40 @@ export const applyDocumentOps = (
|
|
|
107
188
|
if (mode === null) invalid("hello missing snapshot");
|
|
108
189
|
return { mode, documents, changedPaths };
|
|
109
190
|
};
|
|
191
|
+
|
|
192
|
+
export const mainDocument = (
|
|
193
|
+
state: StatewireDocuments.State,
|
|
194
|
+
protocol: string,
|
|
195
|
+
) =>
|
|
196
|
+
[...state.documents.values()].find(
|
|
197
|
+
(document) => document.protocol === protocol && document.main,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
export const resolveDocumentKey = (
|
|
201
|
+
key: readonly string[],
|
|
202
|
+
prefix?: readonly string[],
|
|
203
|
+
) =>
|
|
204
|
+
key[0] === "."
|
|
205
|
+
? prefix === undefined
|
|
206
|
+
? undefined
|
|
207
|
+
: [...prefix, ...key.slice(1)]
|
|
208
|
+
: key;
|
|
209
|
+
|
|
210
|
+
export const findDocument = (
|
|
211
|
+
state: StatewireDocuments.State,
|
|
212
|
+
protocol: string,
|
|
213
|
+
key?: readonly string[],
|
|
214
|
+
prefix?: readonly string[],
|
|
215
|
+
) => {
|
|
216
|
+
if (key === undefined) return mainDocument(state, protocol);
|
|
217
|
+
const resolved = resolveDocumentKey(key, prefix);
|
|
218
|
+
return [...state.documents.values()].find(
|
|
219
|
+
(document) =>
|
|
220
|
+
document.protocol === protocol &&
|
|
221
|
+
document.key !== null &&
|
|
222
|
+
(JSON.stringify(document.key) === JSON.stringify(key) ||
|
|
223
|
+
(resolved !== undefined &&
|
|
224
|
+
JSON.stringify(resolveDocumentKey(document.key, prefix)) ===
|
|
225
|
+
JSON.stringify(resolved))),
|
|
226
|
+
);
|
|
227
|
+
};
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
export namespace StatewireProtocol {
|
|
2
|
+
export type Range = { version: string; minVersion?: string };
|
|
3
|
+
export type Offer = Range & { name: string; optional?: boolean };
|
|
4
|
+
export type Selection = {
|
|
5
|
+
version: string;
|
|
6
|
+
protocols: { name: string; version: string }[];
|
|
7
|
+
};
|
|
8
|
+
export type Options = {
|
|
9
|
+
wireVersion?: Range;
|
|
10
|
+
applicationProtocols?: readonly Offer[] | "*";
|
|
11
|
+
protocol?: string;
|
|
12
|
+
keyPrefixes?: Readonly<Record<string, readonly string[]>>;
|
|
13
|
+
};
|
|
14
|
+
export type Definition = Range & {
|
|
15
|
+
name: string;
|
|
16
|
+
requires?: Readonly<Record<string, Range>>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const WIRE_VERSION = "2026-09-07";
|
|
21
|
+
export const DEFAULT_PROTOCOL = "default";
|
|
22
|
+
export const PROTOCOL_HEADER = "Statewire-Protocol";
|
|
23
|
+
export const VERSION_HEADER = "Statewire-Version";
|
|
24
|
+
export const protocolToken = /^[A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]*$/;
|
|
25
|
+
|
|
26
|
+
const invalid = (message: string): never => {
|
|
27
|
+
throw new Error(`statewire: protocol error: ${message}`);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const validateVersion = (value: unknown): string => {
|
|
31
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
32
|
+
return invalid("version must be a YYYY-MM-DD date");
|
|
33
|
+
const parsed = new Date(`${value}T00:00:00.000Z`);
|
|
34
|
+
if (
|
|
35
|
+
!Number.isFinite(parsed.getTime()) ||
|
|
36
|
+
parsed.toISOString().slice(0, 10) !== value ||
|
|
37
|
+
value.startsWith("0000")
|
|
38
|
+
)
|
|
39
|
+
return invalid("invalid version date");
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const validateRange = (range: StatewireProtocol.Range) => {
|
|
44
|
+
const version = validateVersion(range.version);
|
|
45
|
+
const minVersion =
|
|
46
|
+
range.minVersion === undefined
|
|
47
|
+
? version
|
|
48
|
+
: validateVersion(range.minVersion);
|
|
49
|
+
if (minVersion > version) invalid("min-version exceeds version");
|
|
50
|
+
return { version, minVersion };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const inRange = (value: string, range: StatewireProtocol.Range) => {
|
|
54
|
+
const checked = validateRange(range);
|
|
55
|
+
return value >= checked.minVersion && value <= checked.version;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const protocolOffers = (options: StatewireProtocol.Options) =>
|
|
59
|
+
options.applicationProtocols ?? [
|
|
60
|
+
{ name: options.protocol ?? DEFAULT_PROTOCOL, version: WIRE_VERSION },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export const negotiationHeaders = (
|
|
64
|
+
options: StatewireProtocol.Options,
|
|
65
|
+
): Record<string, string> => {
|
|
66
|
+
const suffix = (range: StatewireProtocol.Range) => {
|
|
67
|
+
const { version, minVersion } = validateRange(range);
|
|
68
|
+
return `"${version}"${minVersion === version ? "" : `; min-version="${minVersion}"`}`;
|
|
69
|
+
};
|
|
70
|
+
const offers = protocolOffers(options);
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
if (offers !== "*" && !offers.length)
|
|
73
|
+
invalid("protocol list must not be empty");
|
|
74
|
+
return {
|
|
75
|
+
[VERSION_HEADER]: suffix(options.wireVersion ?? { version: WIRE_VERSION }),
|
|
76
|
+
[PROTOCOL_HEADER]:
|
|
77
|
+
offers === "*"
|
|
78
|
+
? "*"
|
|
79
|
+
: offers
|
|
80
|
+
.map((offer) => {
|
|
81
|
+
if (
|
|
82
|
+
!protocolToken.test(offer.name) ||
|
|
83
|
+
offer.name === "*" ||
|
|
84
|
+
seen.has(offer.name)
|
|
85
|
+
)
|
|
86
|
+
invalid("invalid or duplicate protocol name");
|
|
87
|
+
if (
|
|
88
|
+
offer.optional !== undefined &&
|
|
89
|
+
typeof offer.optional !== "boolean"
|
|
90
|
+
)
|
|
91
|
+
invalid("optional must be boolean");
|
|
92
|
+
seen.add(offer.name);
|
|
93
|
+
return `${offer.name}; version=${suffix(offer)}${offer.optional ? "; optional" : ""}`;
|
|
94
|
+
})
|
|
95
|
+
.join(", "),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const parseItem = (input: string) => {
|
|
100
|
+
const atom = String.raw`"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"|[A-Za-z*][A-Za-z0-9!#$%&'*+.^_\x60|~:/-]*|\?[01]`;
|
|
101
|
+
const text = input.trim();
|
|
102
|
+
const match = new RegExp(`^(?:${atom})`).exec(text);
|
|
103
|
+
if (!match) return invalid("invalid structured item");
|
|
104
|
+
const quoted = match[0].startsWith('"');
|
|
105
|
+
const decode = (raw: string) => raw.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
106
|
+
const value = quoted ? decode(match[0]) : match[0];
|
|
107
|
+
const params = new Map<string, string | boolean>();
|
|
108
|
+
let rest = text.slice(match[0].length);
|
|
109
|
+
while (rest) {
|
|
110
|
+
const parameter = new RegExp(
|
|
111
|
+
`^; *([a-z*][a-z0-9_.*-]*)(?:=(${atom}))?`,
|
|
112
|
+
).exec(rest);
|
|
113
|
+
if (!parameter) return invalid("invalid structured parameters");
|
|
114
|
+
const key = parameter[1]!;
|
|
115
|
+
const raw = parameter[2];
|
|
116
|
+
if (params.has(key)) invalid("duplicate parameter");
|
|
117
|
+
if (raw === undefined || raw === "?1") params.set(key, true);
|
|
118
|
+
else if (raw === "?0") params.set(key, false);
|
|
119
|
+
else if (raw.startsWith('"')) params.set(key, decode(raw));
|
|
120
|
+
else invalid("parameter must be string or boolean");
|
|
121
|
+
rest = rest.slice(parameter[0].length);
|
|
122
|
+
}
|
|
123
|
+
return { value, quoted, params };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const parseNegotiation = (
|
|
127
|
+
headers: Headers,
|
|
128
|
+
): Required<
|
|
129
|
+
Pick<StatewireProtocol.Options, "wireVersion" | "applicationProtocols">
|
|
130
|
+
> => {
|
|
131
|
+
const wire = headers.get(VERSION_HEADER);
|
|
132
|
+
const application = headers.get(PROTOCOL_HEADER);
|
|
133
|
+
if (wire === null || application === null)
|
|
134
|
+
return invalid("negotiation fields are required");
|
|
135
|
+
const item = parseItem(wire);
|
|
136
|
+
if (!item.quoted || [...item.params.keys()].some((k) => k !== "min-version"))
|
|
137
|
+
invalid("invalid Statewire-Version");
|
|
138
|
+
const minVersion = item.params.get("min-version");
|
|
139
|
+
if (minVersion !== undefined && typeof minVersion !== "string")
|
|
140
|
+
return invalid("min-version must be a string");
|
|
141
|
+
const wireVersion = validateRange({
|
|
142
|
+
version: item.value,
|
|
143
|
+
...(minVersion === undefined ? {} : { minVersion }),
|
|
144
|
+
});
|
|
145
|
+
if (application.trim() === "*")
|
|
146
|
+
return { wireVersion, applicationProtocols: "*" };
|
|
147
|
+
const names = new Set<string>();
|
|
148
|
+
const applicationProtocols = application
|
|
149
|
+
.split(",")
|
|
150
|
+
.map((text): StatewireProtocol.Offer => {
|
|
151
|
+
const { value: name, quoted, params } = parseItem(text);
|
|
152
|
+
if (
|
|
153
|
+
quoted ||
|
|
154
|
+
!protocolToken.test(name) ||
|
|
155
|
+
name === "*" ||
|
|
156
|
+
names.has(name)
|
|
157
|
+
)
|
|
158
|
+
invalid("invalid or duplicate protocol name");
|
|
159
|
+
names.add(name);
|
|
160
|
+
if (
|
|
161
|
+
[...params.keys()].some(
|
|
162
|
+
(k) => !["version", "min-version", "optional"].includes(k),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
invalid("unsupported parameter");
|
|
166
|
+
const version = params.get("version");
|
|
167
|
+
const minVersion = params.get("min-version");
|
|
168
|
+
const optional = params.get("optional") ?? false;
|
|
169
|
+
if (
|
|
170
|
+
typeof version !== "string" ||
|
|
171
|
+
(minVersion !== undefined && typeof minVersion !== "string") ||
|
|
172
|
+
typeof optional !== "boolean"
|
|
173
|
+
)
|
|
174
|
+
return invalid("invalid protocol parameters");
|
|
175
|
+
return {
|
|
176
|
+
name,
|
|
177
|
+
...validateRange({
|
|
178
|
+
version,
|
|
179
|
+
...(minVersion === undefined ? {} : { minVersion }),
|
|
180
|
+
}),
|
|
181
|
+
optional,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
return { wireVersion, applicationProtocols };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const validateSelection = (
|
|
188
|
+
value: unknown,
|
|
189
|
+
options: StatewireProtocol.Options,
|
|
190
|
+
): StatewireProtocol.Selection => {
|
|
191
|
+
if (typeof value !== "object" || value === null)
|
|
192
|
+
return invalid("missing negotiation selection");
|
|
193
|
+
const syn = value as Record<string, unknown>;
|
|
194
|
+
const version = validateVersion(syn.version);
|
|
195
|
+
if (!inRange(version, options.wireVersion ?? { version: WIRE_VERSION }))
|
|
196
|
+
invalid("selected wire version is outside offer");
|
|
197
|
+
if (!Array.isArray(syn.protocols))
|
|
198
|
+
return invalid("syn.protocols must be an array");
|
|
199
|
+
const offers = protocolOffers(options);
|
|
200
|
+
const seen = new Set<string>();
|
|
201
|
+
const protocols = syn.protocols.map((entry: unknown) => {
|
|
202
|
+
if (typeof entry !== "object" || entry === null)
|
|
203
|
+
return invalid("invalid selected protocol");
|
|
204
|
+
const item = entry as Record<string, unknown>;
|
|
205
|
+
const name = item.name;
|
|
206
|
+
if (
|
|
207
|
+
typeof name !== "string" ||
|
|
208
|
+
!protocolToken.test(name) ||
|
|
209
|
+
name === "*" ||
|
|
210
|
+
seen.has(name)
|
|
211
|
+
)
|
|
212
|
+
return invalid("invalid selected protocol name");
|
|
213
|
+
const version = validateVersion(item.version);
|
|
214
|
+
seen.add(name);
|
|
215
|
+
if (offers !== "*") {
|
|
216
|
+
const offer = offers.find((o) => o.name === name);
|
|
217
|
+
if (!offer || !inRange(version, offer))
|
|
218
|
+
invalid("selected protocol is outside offer");
|
|
219
|
+
}
|
|
220
|
+
return { name, version };
|
|
221
|
+
});
|
|
222
|
+
if (offers !== "*" && offers.some((o) => !o.optional && !seen.has(o.name)))
|
|
223
|
+
invalid("required protocol missing from selection");
|
|
224
|
+
return { version, protocols };
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export const negotiateProtocols = (
|
|
228
|
+
headers: Headers,
|
|
229
|
+
wire: StatewireProtocol.Range,
|
|
230
|
+
definitions: readonly StatewireProtocol.Definition[],
|
|
231
|
+
):
|
|
232
|
+
| StatewireProtocol.Selection
|
|
233
|
+
| {
|
|
234
|
+
fin: {
|
|
235
|
+
reason: "protocol-error";
|
|
236
|
+
code: string;
|
|
237
|
+
status: number;
|
|
238
|
+
message: string;
|
|
239
|
+
payload?: unknown;
|
|
240
|
+
};
|
|
241
|
+
} => {
|
|
242
|
+
const supported = {
|
|
243
|
+
...validateRange(wire),
|
|
244
|
+
protocols: definitions.map((d) => ({ name: d.name, ...validateRange(d) })),
|
|
245
|
+
};
|
|
246
|
+
const failure = (code: string, message: string) => ({
|
|
247
|
+
fin: {
|
|
248
|
+
reason: "protocol-error" as const,
|
|
249
|
+
code,
|
|
250
|
+
message,
|
|
251
|
+
status: code === "invalid-negotiation" ? 400 : 406,
|
|
252
|
+
...(code === "invalid-negotiation" ? {} : { payload: supported }),
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
let request: ReturnType<typeof parseNegotiation>;
|
|
256
|
+
try {
|
|
257
|
+
request = parseNegotiation(headers);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return failure(
|
|
260
|
+
"invalid-negotiation",
|
|
261
|
+
error instanceof Error ? error.message : "invalid negotiation",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const version = [wire.version, request.wireVersion.version].sort()[0]!;
|
|
265
|
+
if (!inRange(version, wire) || !inRange(version, request.wireVersion))
|
|
266
|
+
return failure("unsupported-version", "Statewire versions do not overlap");
|
|
267
|
+
const offers =
|
|
268
|
+
request.applicationProtocols === "*"
|
|
269
|
+
? definitions.map((d) => ({
|
|
270
|
+
name: d.name,
|
|
271
|
+
version: d.version,
|
|
272
|
+
optional: false,
|
|
273
|
+
}))
|
|
274
|
+
: request.applicationProtocols;
|
|
275
|
+
const registry = new Map(definitions.map((d) => [d.name, d]));
|
|
276
|
+
if (registry.size !== definitions.length)
|
|
277
|
+
throw new Error("statewire: duplicate registered protocol");
|
|
278
|
+
const offered = new Map(offers.map((o) => [o.name, o]));
|
|
279
|
+
const solve = (names: Set<string>) => {
|
|
280
|
+
const active = new Set(names);
|
|
281
|
+
for (const name of active) {
|
|
282
|
+
const definition = registry.get(name);
|
|
283
|
+
if (!definition || !offered.has(name)) return undefined;
|
|
284
|
+
for (const dependency of Object.keys(definition.requires ?? {}))
|
|
285
|
+
active.add(dependency);
|
|
286
|
+
}
|
|
287
|
+
const result = new Map<string, string>();
|
|
288
|
+
for (const name of active) {
|
|
289
|
+
const ranges = [
|
|
290
|
+
registry.get(name)!,
|
|
291
|
+
offered.get(name)!,
|
|
292
|
+
...[...active].flatMap((parent) => {
|
|
293
|
+
const dependency = registry.get(parent)!.requires?.[name];
|
|
294
|
+
return dependency ? [dependency] : [];
|
|
295
|
+
}),
|
|
296
|
+
].map(validateRange);
|
|
297
|
+
const lower = ranges
|
|
298
|
+
.map((r) => r.minVersion)
|
|
299
|
+
.sort()
|
|
300
|
+
.at(-1)!;
|
|
301
|
+
const upper = ranges.map((r) => r.version).sort()[0]!;
|
|
302
|
+
if (lower > upper) return undefined;
|
|
303
|
+
result.set(name, upper);
|
|
304
|
+
}
|
|
305
|
+
return result;
|
|
306
|
+
};
|
|
307
|
+
let selected = solve(
|
|
308
|
+
new Set(offers.filter((o) => !o.optional).map((o) => o.name)),
|
|
309
|
+
);
|
|
310
|
+
if (!selected)
|
|
311
|
+
return failure(
|
|
312
|
+
"unsupported-protocol",
|
|
313
|
+
"required protocol combination is unsupported",
|
|
314
|
+
);
|
|
315
|
+
for (const offer of offers) {
|
|
316
|
+
if (offer.optional)
|
|
317
|
+
selected = solve(new Set([...selected.keys(), offer.name])) ?? selected;
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
version,
|
|
321
|
+
protocols: offers.flatMap((o) =>
|
|
322
|
+
selected!.has(o.name)
|
|
323
|
+
? [{ name: o.name, version: selected!.get(o.name)! }]
|
|
324
|
+
: [],
|
|
325
|
+
),
|
|
326
|
+
};
|
|
327
|
+
};
|
package/src/wire/version.ts
CHANGED