@streamotter/contracts 0.1.0-rc.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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +329 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +106 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/limits.d.ts +8 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +76 -0
- package/dist/limits.js.map +1 -0
- package/dist/primitives.d.ts +29 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/primitives.js +123 -0
- package/dist/primitives.js.map +1 -0
- package/dist/protocol.d.ts +32 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +37 -0
- package/dist/protocol.js.map +1 -0
- package/dist/schema.d.ts +34 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +267 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +505 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +40 -0
- package/src/config.ts +311 -0
- package/src/errors.ts +118 -0
- package/src/index.ts +7 -0
- package/src/limits.ts +78 -0
- package/src/primitives.ts +112 -0
- package/src/protocol.ts +41 -0
- package/src/schema.ts +255 -0
- package/src/types.ts +312 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { StreamOtterError } from "./errors.ts";
|
|
2
|
+
import { validateLimits } from "./limits.ts";
|
|
3
|
+
import { IDENTIFIER_PATTERN, isPlainObject } from "./primitives.ts";
|
|
4
|
+
import { pointer, validateParamsSchema, validateSchemaDefinition } from "./schema.ts";
|
|
5
|
+
import type { ConfigIssue, ProjectConfig, Schema } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export interface ConfigValidation { valid: boolean; issues: ConfigIssue[] }
|
|
8
|
+
|
|
9
|
+
/** Keys that belong to deferred V2/V3 features; reported with a clear message. */
|
|
10
|
+
const DEFERRED_FEATURES: Readonly<Record<string, string>> = {
|
|
11
|
+
history: "Retained history is a V2 feature and is not supported in V1.",
|
|
12
|
+
recovery: "Client recovery cursors are a V2 feature and are not supported in V1.",
|
|
13
|
+
retention: "Retention windows are a V2 feature and are not supported in V1.",
|
|
14
|
+
acknowledgement: "Application acknowledgements are a V2 feature and are not supported in V1.",
|
|
15
|
+
resume: "Resume policies are a V2 feature and are not supported in V1.",
|
|
16
|
+
replay: "Replay is a V2 feature and is not supported in V1.",
|
|
17
|
+
commands: "Commands are a V3 feature and are not supported in V1.",
|
|
18
|
+
command: "Commands are a V3 feature and are not supported in V1.",
|
|
19
|
+
gateways: "Multiple gateways are a V2 feature and are not supported in V1.",
|
|
20
|
+
cluster: "Multiple gateways are a V2 feature and are not supported in V1.",
|
|
21
|
+
schemaRegistry: "Schema Registry integration is a V2 feature and is not supported in V1.",
|
|
22
|
+
workspaces: "Team workspaces are a V3 feature and are not supported in V1.",
|
|
23
|
+
environments: "Environments are a V3 feature and are not supported in V1.",
|
|
24
|
+
transports: "Only the Socket.IO transport is supported in V1.",
|
|
25
|
+
transport: "Only the Socket.IO transport is supported in V1."
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
29
|
+
const KAFKA_NAME_PATTERN = /^[A-Za-z0-9._-]{1,249}$/;
|
|
30
|
+
const BROKER_PATTERN = /^[A-Za-z0-9.-]+:[0-9]{1,5}$|^\[[0-9A-Fa-f:.]+\]:[0-9]{1,5}$/;
|
|
31
|
+
|
|
32
|
+
class Checker {
|
|
33
|
+
readonly issues: ConfigIssue[] = [];
|
|
34
|
+
|
|
35
|
+
add(path: string, code: string, message: string): void {
|
|
36
|
+
this.issues.push({ path, code, message });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Reports unknown keys (with deferred-feature messages) and missing required keys. */
|
|
40
|
+
keys(value: Record<string, unknown>, path: string, required: readonly string[], optional: readonly string[] = []): void {
|
|
41
|
+
for (const key of Object.keys(value)) {
|
|
42
|
+
if (required.includes(key) || optional.includes(key)) continue;
|
|
43
|
+
const deferred = DEFERRED_FEATURES[key];
|
|
44
|
+
if (deferred !== undefined) this.add(pointer(path, key), "UNSUPPORTED_FEATURE", deferred);
|
|
45
|
+
else this.add(pointer(path, key), "UNKNOWN_KEY", `Unknown key "${key}".`);
|
|
46
|
+
}
|
|
47
|
+
for (const key of required) {
|
|
48
|
+
if (!Object.hasOwn(value, key) || value[key] === undefined) this.add(pointer(path, key), "REQUIRED", `"${key}" is required.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
object(value: unknown, path: string, label: string): value is Record<string, unknown> {
|
|
53
|
+
if (isPlainObject(value)) return true;
|
|
54
|
+
this.add(path, "INVALID_TYPE", `${label} must be an object.`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
identifier(value: unknown, path: string, label: string): value is string {
|
|
59
|
+
if (typeof value === "string" && IDENTIFIER_PATTERN.test(value)) return true;
|
|
60
|
+
this.add(path, "INVALID_IDENTIFIER", `${label} must match [A-Za-z][A-Za-z0-9_-]{0,63}.`);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
nonEmptyString(value: unknown, path: string, label: string, max = 1024): value is string {
|
|
65
|
+
if (typeof value === "string" && value.length > 0 && value.length <= max) return true;
|
|
66
|
+
this.add(path, "INVALID_VALUE", `${label} must be a non-empty string of at most ${max} characters.`);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
secretRef(value: unknown, path: string): void {
|
|
71
|
+
if (!this.object(value, path, "A secret reference")) return;
|
|
72
|
+
this.keys(value, path, ["env"]);
|
|
73
|
+
if (value["env"] !== undefined && !(typeof value["env"] === "string" && ENV_NAME_PATTERN.test(value["env"]))) {
|
|
74
|
+
this.add(pointer(path, "env"), "INVALID_VALUE", "env must name an environment variable.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isOrigin(value: unknown): value is string {
|
|
80
|
+
if (typeof value !== "string") return false;
|
|
81
|
+
try {
|
|
82
|
+
const url = new URL(value);
|
|
83
|
+
return (url.protocol === "http:" || url.protocol === "https:") && url.origin === value;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Structural, schema, and reference validation of a portable configuration.
|
|
91
|
+
* Never resolves secrets, runs handlers, or connects to brokers.
|
|
92
|
+
*/
|
|
93
|
+
export function validateProjectConfig(input: unknown): ConfigValidation {
|
|
94
|
+
const c = new Checker();
|
|
95
|
+
if (!c.object(input, "", "The configuration")) return { valid: false, issues: c.issues };
|
|
96
|
+
c.keys(input, "", ["configVersion", "projectId", "gateway", "connections", "sources", "schemas", "channels"], ["limits"]);
|
|
97
|
+
|
|
98
|
+
if (input["configVersion"] !== undefined && input["configVersion"] !== 1) {
|
|
99
|
+
c.add("/configVersion", "UNSUPPORTED_FEATURE", "Only configVersion 1 is supported.");
|
|
100
|
+
}
|
|
101
|
+
if (input["projectId"] !== undefined) c.identifier(input["projectId"], "/projectId", "projectId");
|
|
102
|
+
|
|
103
|
+
const gateway = input["gateway"];
|
|
104
|
+
if (gateway !== undefined && c.object(gateway, "/gateway", "gateway")) {
|
|
105
|
+
c.keys(gateway, "/gateway", ["host", "port", "path", "allowedOrigins"]);
|
|
106
|
+
if (gateway["host"] !== undefined) c.nonEmptyString(gateway["host"], "/gateway/host", "host", 253);
|
|
107
|
+
const port = gateway["port"];
|
|
108
|
+
if (port !== undefined && !(typeof port === "number" && Number.isInteger(port) && port >= 0 && port <= 65_535)) {
|
|
109
|
+
c.add("/gateway/port", "INVALID_VALUE", "port must be an integer from 0 to 65535.");
|
|
110
|
+
}
|
|
111
|
+
const path = gateway["path"];
|
|
112
|
+
if (path !== undefined && !(typeof path === "string" && /^\/[A-Za-z0-9._~\/-]*$/.test(path) && path.length <= 256)) {
|
|
113
|
+
c.add("/gateway/path", "INVALID_VALUE", "path must be an absolute URL path without a query or fragment.");
|
|
114
|
+
}
|
|
115
|
+
const origins = gateway["allowedOrigins"];
|
|
116
|
+
if (origins !== undefined) {
|
|
117
|
+
if (!Array.isArray(origins)) {
|
|
118
|
+
c.add("/gateway/allowedOrigins", "INVALID_TYPE", "allowedOrigins must be an array of exact origins.");
|
|
119
|
+
} else {
|
|
120
|
+
origins.forEach((origin, index) => {
|
|
121
|
+
if (!isOrigin(origin)) {
|
|
122
|
+
c.add(pointer("/gateway/allowedOrigins", index), "INVALID_VALUE", "Each allowed origin must be an exact http(s) origin such as https://app.example.com; wildcards are not allowed.");
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
if (new Set(origins).size !== origins.length) c.add("/gateway/allowedOrigins", "DUPLICATE", "allowedOrigins must be unique.");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const connectionIds = new Set<string>();
|
|
131
|
+
const connections = input["connections"];
|
|
132
|
+
if (connections !== undefined && c.object(connections, "/connections", "connections")) {
|
|
133
|
+
for (const [id, profile] of Object.entries(connections)) {
|
|
134
|
+
const path = pointer("/connections", id);
|
|
135
|
+
if (!c.identifier(id, path, "Connection profile IDs")) continue;
|
|
136
|
+
connectionIds.add(id);
|
|
137
|
+
if (!c.object(profile, path, "A connection profile")) continue;
|
|
138
|
+
c.keys(profile, path, ["brokers", "tls"], ["sasl"]);
|
|
139
|
+
const brokers = profile["brokers"];
|
|
140
|
+
if (brokers !== undefined) {
|
|
141
|
+
if (!Array.isArray(brokers) || brokers.length === 0) {
|
|
142
|
+
c.add(pointer(path, "brokers"), "INVALID_VALUE", "brokers must be a non-empty array of host:port strings.");
|
|
143
|
+
} else {
|
|
144
|
+
brokers.forEach((broker, index) => {
|
|
145
|
+
if (typeof broker !== "string" || !BROKER_PATTERN.test(broker)) {
|
|
146
|
+
c.add(pointer(pointer(path, "brokers"), index), "INVALID_VALUE", "Each broker must be host:port.");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const tls = profile["tls"];
|
|
152
|
+
if (tls !== undefined && tls !== false) {
|
|
153
|
+
if (c.object(tls, pointer(path, "tls"), "tls (false or an object)")) {
|
|
154
|
+
c.keys(tls, pointer(path, "tls"), [], ["caFile"]);
|
|
155
|
+
if (tls["caFile"] !== undefined) c.nonEmptyString(tls["caFile"], pointer(pointer(path, "tls"), "caFile"), "caFile", 4096);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const sasl = profile["sasl"];
|
|
159
|
+
if (sasl !== undefined && c.object(sasl, pointer(path, "sasl"), "sasl")) {
|
|
160
|
+
const saslPath = pointer(path, "sasl");
|
|
161
|
+
c.keys(sasl, saslPath, ["mechanism", "username", "password"]);
|
|
162
|
+
const mechanism = sasl["mechanism"];
|
|
163
|
+
if (mechanism !== undefined && mechanism !== "plain" && mechanism !== "scram-sha-256" && mechanism !== "scram-sha-512") {
|
|
164
|
+
c.add(pointer(saslPath, "mechanism"), "UNSUPPORTED_FEATURE", "SASL mechanism must be plain, scram-sha-256, or scram-sha-512 (OAuth is not supported in V1).");
|
|
165
|
+
}
|
|
166
|
+
if (sasl["username"] !== undefined) c.secretRef(sasl["username"], pointer(saslPath, "username"));
|
|
167
|
+
if (sasl["password"] !== undefined) c.secretRef(sasl["password"], pointer(saslPath, "password"));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sourceIds = new Set<string>();
|
|
173
|
+
const kafkaProfiles = new Set<string>();
|
|
174
|
+
const consumerGroups = new Map<string, string>();
|
|
175
|
+
const sources = input["sources"];
|
|
176
|
+
if (sources !== undefined && c.object(sources, "/sources", "sources")) {
|
|
177
|
+
for (const [id, source] of Object.entries(sources)) {
|
|
178
|
+
const path = pointer("/sources", id);
|
|
179
|
+
if (!c.identifier(id, path, "Source IDs")) continue;
|
|
180
|
+
sourceIds.add(id);
|
|
181
|
+
if (!c.object(source, path, "A source")) continue;
|
|
182
|
+
const kind = source["kind"];
|
|
183
|
+
if (kind === "kafka") {
|
|
184
|
+
c.keys(source, path, ["kind", "generation", "connectionRef", "topics", "consumerGroup", "codec", "startFrom"]);
|
|
185
|
+
if (source["generation"] !== undefined) c.identifier(source["generation"], pointer(path, "generation"), "generation");
|
|
186
|
+
const ref = source["connectionRef"];
|
|
187
|
+
if (ref !== undefined) {
|
|
188
|
+
if (typeof ref !== "string" || !connectionIds.has(ref)) {
|
|
189
|
+
c.add(pointer(path, "connectionRef"), "UNKNOWN_REFERENCE", "connectionRef must name a configured connection profile.");
|
|
190
|
+
} else {
|
|
191
|
+
kafkaProfiles.add(ref);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const topics = source["topics"];
|
|
195
|
+
if (topics !== undefined) {
|
|
196
|
+
if (!Array.isArray(topics) || topics.length === 0) {
|
|
197
|
+
c.add(pointer(path, "topics"), "INVALID_VALUE", "topics must be a non-empty array.");
|
|
198
|
+
} else {
|
|
199
|
+
topics.forEach((topic, index) => {
|
|
200
|
+
if (typeof topic !== "string" || !KAFKA_NAME_PATTERN.test(topic)) {
|
|
201
|
+
c.add(pointer(pointer(path, "topics"), index), "INVALID_VALUE", "Topic names may contain letters, digits, '.', '_', and '-'.");
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
if (new Set(topics).size !== topics.length) c.add(pointer(path, "topics"), "DUPLICATE", "topics must be unique.");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const group = source["consumerGroup"];
|
|
208
|
+
if (group !== undefined) {
|
|
209
|
+
if (typeof group !== "string" || !KAFKA_NAME_PATTERN.test(group)) {
|
|
210
|
+
c.add(pointer(path, "consumerGroup"), "INVALID_VALUE", "consumerGroup may contain letters, digits, '.', '_', and '-'.");
|
|
211
|
+
} else {
|
|
212
|
+
const owner = consumerGroups.get(group);
|
|
213
|
+
if (owner !== undefined) {
|
|
214
|
+
c.add(pointer(path, "consumerGroup"), "CONSUMER_GROUP_CONFLICT", `Consumer group "${group}" is already used by source "${owner}"; each source needs a dedicated group.`);
|
|
215
|
+
} else {
|
|
216
|
+
consumerGroups.set(group, id);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (source["codec"] !== undefined && source["codec"] !== "json") {
|
|
221
|
+
c.add(pointer(path, "codec"), "UNSUPPORTED_FEATURE", "Only the json codec is supported in V1.");
|
|
222
|
+
}
|
|
223
|
+
const startFrom = source["startFrom"];
|
|
224
|
+
if (startFrom !== undefined && startFrom !== "latest" && startFrom !== "earliest") {
|
|
225
|
+
c.add(pointer(path, "startFrom"), "INVALID_VALUE", "startFrom must be latest or earliest.");
|
|
226
|
+
}
|
|
227
|
+
} else if (kind === "fixture") {
|
|
228
|
+
c.keys(source, path, ["kind", "generation", "fixtureRef"]);
|
|
229
|
+
if (source["generation"] !== undefined) c.identifier(source["generation"], pointer(path, "generation"), "generation");
|
|
230
|
+
if (source["fixtureRef"] !== undefined) c.identifier(source["fixtureRef"], pointer(path, "fixtureRef"), "fixtureRef");
|
|
231
|
+
} else {
|
|
232
|
+
c.add(pointer(path, "kind"), "UNSUPPORTED_FEATURE", "Source kind must be kafka or fixture in V1.");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (kafkaProfiles.size > 1) {
|
|
237
|
+
c.add("/sources", "MULTIPLE_CONNECTIONS", "V1 supports one Kafka connection profile per project; list multiple brokers of one cluster in that profile instead.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const schemaDefinitions = new Map<string, Schema>();
|
|
241
|
+
const schemas = input["schemas"];
|
|
242
|
+
if (schemas !== undefined && c.object(schemas, "/schemas", "schemas")) {
|
|
243
|
+
for (const [id, schema] of Object.entries(schemas)) {
|
|
244
|
+
const path = pointer("/schemas", id);
|
|
245
|
+
if (!c.identifier(id, path, "Schema IDs")) continue;
|
|
246
|
+
if (validateSchemaDefinition(schema, path, c.issues)) schemaDefinitions.set(id, schema);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const channels = input["channels"];
|
|
251
|
+
if (channels !== undefined && c.object(channels, "/channels", "channels")) {
|
|
252
|
+
for (const [name, channel] of Object.entries(channels)) {
|
|
253
|
+
const path = pointer("/channels", name);
|
|
254
|
+
if (!c.identifier(name, path, "Channel names")) continue;
|
|
255
|
+
if (!c.object(channel, path, "A channel")) continue;
|
|
256
|
+
c.keys(channel, path, ["version", "source", "paramsSchema", "payloadSchema", "handlersRef", "delivery"]);
|
|
257
|
+
const version = channel["version"];
|
|
258
|
+
if (version !== undefined && !(typeof version === "number" && Number.isSafeInteger(version) && version > 0)) {
|
|
259
|
+
c.add(pointer(path, "version"), "INVALID_VALUE", "version must be a positive integer.");
|
|
260
|
+
}
|
|
261
|
+
const source = channel["source"];
|
|
262
|
+
if (source !== undefined && (typeof source !== "string" || !sourceIds.has(source))) {
|
|
263
|
+
c.add(pointer(path, "source"), "UNKNOWN_REFERENCE", "source must name a configured source.");
|
|
264
|
+
}
|
|
265
|
+
for (const key of ["paramsSchema", "payloadSchema"] as const) {
|
|
266
|
+
const ref = channel[key];
|
|
267
|
+
if (ref === undefined) continue;
|
|
268
|
+
if (typeof ref !== "string" || !(schemas !== undefined && isPlainObject(schemas) && Object.hasOwn(schemas, ref))) {
|
|
269
|
+
c.add(pointer(path, key), "UNKNOWN_REFERENCE", `${key} must name a configured schema.`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const paramsRef = channel["paramsSchema"];
|
|
273
|
+
if (typeof paramsRef === "string") {
|
|
274
|
+
const schema = schemaDefinitions.get(paramsRef);
|
|
275
|
+
if (schema !== undefined) validateParamsSchema(schema, pointer("/schemas", paramsRef), c.issues);
|
|
276
|
+
}
|
|
277
|
+
const handlersRef = channel["handlersRef"];
|
|
278
|
+
if (handlersRef !== undefined && handlersRef !== name) {
|
|
279
|
+
c.add(pointer(path, "handlersRef"), "HANDLERS_REF_MISMATCH", "handlersRef must equal the channel name in V1.");
|
|
280
|
+
}
|
|
281
|
+
const delivery = channel["delivery"];
|
|
282
|
+
if (delivery !== undefined && c.object(delivery, pointer(path, "delivery"), "delivery")) {
|
|
283
|
+
const deliveryPath = pointer(path, "delivery");
|
|
284
|
+
c.keys(delivery, deliveryPath, ["kind", "overflow"]);
|
|
285
|
+
if (delivery["kind"] !== undefined && delivery["kind"] !== "state") {
|
|
286
|
+
c.add(pointer(deliveryPath, "kind"), "UNSUPPORTED_FEATURE",
|
|
287
|
+
delivery["kind"] === "events"
|
|
288
|
+
? "Retained event channels (delivery.kind \"events\") are a V2 feature; V1 supports only \"state\"."
|
|
289
|
+
: "delivery.kind must be \"state\" in V1.");
|
|
290
|
+
}
|
|
291
|
+
if (delivery["overflow"] !== undefined && delivery["overflow"] !== "resync") {
|
|
292
|
+
c.add(pointer(deliveryPath, "overflow"), "UNSUPPORTED_FEATURE", "delivery.overflow must be \"resync\" in V1.");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
validateLimits(input["limits"], "/limits", c.issues);
|
|
299
|
+
return { valid: c.issues.length === 0, issues: c.issues };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Throws CONFIG_INVALID with the issues attached when the configuration is invalid. */
|
|
303
|
+
export function assertValidProjectConfig(input: unknown): asserts input is ProjectConfig {
|
|
304
|
+
const { valid, issues } = validateProjectConfig(input);
|
|
305
|
+
if (valid) return;
|
|
306
|
+
const summary = issues.slice(0, 3).map(issue => `${issue.path || "/"}: ${issue.message}`).join("; ");
|
|
307
|
+
throw new StreamOtterError("CONFIG_INVALID", {
|
|
308
|
+
message: `The configuration is invalid (${issues.length} issue${issues.length === 1 ? "" : "s"}): ${summary}`,
|
|
309
|
+
details: { issues: issues.map(issue => ({ path: issue.path, code: issue.code, message: issue.message })) }
|
|
310
|
+
});
|
|
311
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ErrorCode, Json, StreamError } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Public, action-oriented messages. Never include topics, secrets, or payloads. */
|
|
4
|
+
export const PUBLIC_MESSAGES: Readonly<Record<ErrorCode, string>> = {
|
|
5
|
+
UNAUTHENTICATED: "Authentication is required or has expired.",
|
|
6
|
+
FORBIDDEN: "This subscription is not permitted.",
|
|
7
|
+
INVALID_PARAMS: "The channel parameters are invalid.",
|
|
8
|
+
CHANNEL_NOT_FOUND: "The channel is not configured.",
|
|
9
|
+
CHANNEL_VERSION_UNSUPPORTED: "The requested channel version is not deployed.",
|
|
10
|
+
SOURCE_UNAVAILABLE: "The source is unavailable; this view may be stale.",
|
|
11
|
+
INVALID_PAYLOAD: "The data did not match its declared contract.",
|
|
12
|
+
OVERLOADED: "The gateway is over its configured limits; try again later.",
|
|
13
|
+
RESYNC_REQUIRED: "Automatic synchronization stopped; call resync() to try again.",
|
|
14
|
+
UNSUPPORTED_CAPABILITY: "The requested capability is not supported by this gateway.",
|
|
15
|
+
INVALID_REQUEST: "The request is malformed.",
|
|
16
|
+
CONFIG_INVALID: "The configuration is invalid.",
|
|
17
|
+
TIMEOUT: "The operation did not complete before its deadline.",
|
|
18
|
+
CANCELLED: "The operation was cancelled.",
|
|
19
|
+
CLIENT_CLOSED: "The client is closed; create a new client.",
|
|
20
|
+
HANDLER_FAILED: "An application handler failed.",
|
|
21
|
+
REVISION_CONFLICT: "Conflicting data was received for the same revision.",
|
|
22
|
+
TRACE_CURSOR_EXPIRED: "The trace cursor has expired; start from the latest traces.",
|
|
23
|
+
INTERNAL: "An unexpected error occurred."
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DEFAULT_RETRYABLE: Readonly<Record<ErrorCode, boolean>> = {
|
|
27
|
+
UNAUTHENTICATED: true,
|
|
28
|
+
FORBIDDEN: false,
|
|
29
|
+
INVALID_PARAMS: false,
|
|
30
|
+
CHANNEL_NOT_FOUND: false,
|
|
31
|
+
CHANNEL_VERSION_UNSUPPORTED: false,
|
|
32
|
+
SOURCE_UNAVAILABLE: true,
|
|
33
|
+
INVALID_PAYLOAD: false,
|
|
34
|
+
OVERLOADED: true,
|
|
35
|
+
RESYNC_REQUIRED: true,
|
|
36
|
+
UNSUPPORTED_CAPABILITY: false,
|
|
37
|
+
INVALID_REQUEST: false,
|
|
38
|
+
CONFIG_INVALID: false,
|
|
39
|
+
TIMEOUT: true,
|
|
40
|
+
CANCELLED: false,
|
|
41
|
+
CLIENT_CLOSED: false,
|
|
42
|
+
HANDLER_FAILED: true,
|
|
43
|
+
REVISION_CONFLICT: false,
|
|
44
|
+
TRACE_CURSOR_EXPIRED: false,
|
|
45
|
+
INTERNAL: true
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const ERROR_CODES = Object.keys(DEFAULT_RETRYABLE) as readonly ErrorCode[];
|
|
49
|
+
|
|
50
|
+
export function isErrorCode(value: unknown): value is ErrorCode {
|
|
51
|
+
return typeof value === "string" && Object.hasOwn(DEFAULT_RETRYABLE, value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StreamErrorOptions {
|
|
55
|
+
message?: string;
|
|
56
|
+
retryable?: boolean;
|
|
57
|
+
requestId?: string;
|
|
58
|
+
details?: Readonly<Record<string, Json>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An Error that also satisfies the StreamError shape. SDK promises reject with
|
|
63
|
+
* it; its enumerable fields serialize to exactly the public StreamError object.
|
|
64
|
+
*/
|
|
65
|
+
export class StreamOtterError extends Error implements StreamError {
|
|
66
|
+
code: ErrorCode;
|
|
67
|
+
retryable: boolean;
|
|
68
|
+
requestId: string;
|
|
69
|
+
details?: Readonly<Record<string, Json>>;
|
|
70
|
+
|
|
71
|
+
constructor(code: ErrorCode, options: StreamErrorOptions = {}) {
|
|
72
|
+
super(options.message ?? PUBLIC_MESSAGES[code]);
|
|
73
|
+
Object.defineProperty(this, "message", { enumerable: true, writable: true, configurable: true, value: this.message });
|
|
74
|
+
Object.defineProperty(this, "name", { enumerable: false, value: "StreamOtterError" });
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.retryable = options.retryable ?? DEFAULT_RETRYABLE[code];
|
|
77
|
+
this.requestId = options.requestId ?? "";
|
|
78
|
+
if (options.details !== undefined) this.details = options.details;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
toJSON(): StreamError {
|
|
82
|
+
return toStreamError(this);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function streamError(code: ErrorCode, options: StreamErrorOptions = {}): StreamError {
|
|
87
|
+
const error: StreamError = {
|
|
88
|
+
code,
|
|
89
|
+
message: options.message ?? PUBLIC_MESSAGES[code],
|
|
90
|
+
retryable: options.retryable ?? DEFAULT_RETRYABLE[code],
|
|
91
|
+
requestId: options.requestId ?? ""
|
|
92
|
+
};
|
|
93
|
+
if (options.details !== undefined) error.details = options.details;
|
|
94
|
+
return error;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Copies only the public StreamError fields. */
|
|
98
|
+
export function toStreamError(error: StreamError): StreamError {
|
|
99
|
+
const copy: StreamError = { code: error.code, message: error.message, retryable: error.retryable, requestId: error.requestId };
|
|
100
|
+
if (error.details !== undefined) copy.details = error.details;
|
|
101
|
+
return copy;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isStreamError(value: unknown): value is StreamError {
|
|
105
|
+
if (typeof value !== "object" || value === null) return false;
|
|
106
|
+
const candidate = value as Record<string, unknown>;
|
|
107
|
+
return isErrorCode(candidate["code"])
|
|
108
|
+
&& typeof candidate["message"] === "string"
|
|
109
|
+
&& typeof candidate["retryable"] === "boolean"
|
|
110
|
+
&& typeof candidate["requestId"] === "string";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function asStreamOtterError(error: StreamError): StreamOtterError {
|
|
114
|
+
if (error instanceof StreamOtterError) return error;
|
|
115
|
+
const options: StreamErrorOptions = { message: error.message, retryable: error.retryable, requestId: error.requestId };
|
|
116
|
+
if (error.details !== undefined) options.details = error.details;
|
|
117
|
+
return new StreamOtterError(error.code, options);
|
|
118
|
+
}
|
package/src/index.ts
ADDED
package/src/limits.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { pointer } from "./schema.ts";
|
|
2
|
+
import type { ConfigIssue, Limits } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Starting bounds from the specification; not published capacity claims. */
|
|
5
|
+
export const DEFAULT_LIMITS: Readonly<Limits> = Object.freeze({
|
|
6
|
+
maxConnections: 1_000,
|
|
7
|
+
maxSubscriptionsPerConnection: 50,
|
|
8
|
+
maxSourceRecordBytes: 1_048_576,
|
|
9
|
+
maxDataFrameBytes: 65_536,
|
|
10
|
+
maxParamsBytes: 4_096,
|
|
11
|
+
maxPendingFramesPerSubscription: 100,
|
|
12
|
+
maxPendingBytesPerSubscription: 1_048_576,
|
|
13
|
+
maxPendingBytesPerConnection: 4_194_304,
|
|
14
|
+
maxPendingBytesGateway: 67_108_864,
|
|
15
|
+
maxMapOutputs: 100,
|
|
16
|
+
maxConcurrentSnapshots: 32,
|
|
17
|
+
handlerTimeoutMs: 2_000,
|
|
18
|
+
snapshotTimeoutMs: 10_000,
|
|
19
|
+
receiptTimeoutMs: 5_000,
|
|
20
|
+
maxSyncAttempts: 3,
|
|
21
|
+
maxTraceEntries: 10_000,
|
|
22
|
+
maxTraceBytes: 8_388_608,
|
|
23
|
+
maxControlFrameBytes: 16_384,
|
|
24
|
+
controlRequestsPerSecond: 20
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const LIMIT_KEYS = Object.keys(DEFAULT_LIMITS) as readonly (keyof Limits)[];
|
|
28
|
+
|
|
29
|
+
export function resolveLimits(overrides: Partial<Limits> | undefined): Limits {
|
|
30
|
+
return { ...DEFAULT_LIMITS, ...(overrides ?? {}) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Validates override values and cross-field consistency of the resolved limits. */
|
|
34
|
+
export function validateLimits(input: unknown, path: string, issues: ConfigIssue[]): void {
|
|
35
|
+
if (input === undefined) return;
|
|
36
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
37
|
+
issues.push({ path, code: "INVALID_TYPE", message: "limits must be an object." });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const overrides: Partial<Limits> = {};
|
|
41
|
+
let valid = true;
|
|
42
|
+
for (const [key, value] of Object.entries(input)) {
|
|
43
|
+
if (!(LIMIT_KEYS as readonly string[]).includes(key)) {
|
|
44
|
+
issues.push({ path: pointer(path, key), code: "UNKNOWN_KEY", message: `Unknown limit "${key}".` });
|
|
45
|
+
valid = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
49
|
+
issues.push({ path: pointer(path, key), code: "INVALID_VALUE", message: `${key} must be a positive integer.` });
|
|
50
|
+
valid = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
overrides[key as keyof Limits] = value;
|
|
54
|
+
}
|
|
55
|
+
if (!valid) return;
|
|
56
|
+
const limits = resolveLimits(overrides);
|
|
57
|
+
const ordered: [keyof Limits, keyof Limits][] = [
|
|
58
|
+
["maxDataFrameBytes", "maxPendingBytesPerSubscription"],
|
|
59
|
+
["maxPendingBytesPerSubscription", "maxPendingBytesPerConnection"],
|
|
60
|
+
["maxPendingBytesPerConnection", "maxPendingBytesGateway"],
|
|
61
|
+
["maxParamsBytes", "maxControlFrameBytes"]
|
|
62
|
+
];
|
|
63
|
+
for (const [smaller, larger] of ordered) {
|
|
64
|
+
if (limits[smaller] > limits[larger]) {
|
|
65
|
+
issues.push({
|
|
66
|
+
path: pointer(path, smaller),
|
|
67
|
+
code: "INCONSISTENT_LIMITS",
|
|
68
|
+
message: `${smaller} (${limits[smaller]}) must not exceed ${larger} (${limits[larger]}).`
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (limits.maxControlFrameBytes < 1_024) {
|
|
73
|
+
issues.push({ path: pointer(path, "maxControlFrameBytes"), code: "INCONSISTENT_LIMITS", message: "maxControlFrameBytes must be at least 1024." });
|
|
74
|
+
}
|
|
75
|
+
if (limits.maxDataFrameBytes < 1_024) {
|
|
76
|
+
issues.push({ path: pointer(path, "maxDataFrameBytes"), code: "INCONSISTENT_LIMITS", message: "maxDataFrameBytes must be at least 1024." });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Json, Revision } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const MAX_NESTING_DEPTH = 16;
|
|
4
|
+
export const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
5
|
+
export const REVISION_PATTERN = /^(?:0|[1-9][0-9]{0,38})$/;
|
|
6
|
+
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
+
const RFC3339_UTC_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
|
|
8
|
+
|
|
9
|
+
export function isIdentifier(value: unknown): value is string {
|
|
10
|
+
return typeof value === "string" && IDENTIFIER_PATTERN.test(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isUuid(value: unknown): value is string {
|
|
14
|
+
return typeof value === "string" && UUID_PATTERN.test(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isRevision(value: unknown): value is Revision {
|
|
18
|
+
return typeof value === "string" && REVISION_PATTERN.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Numeric comparison of canonical revisions without converting to number. */
|
|
22
|
+
export function compareRevisions(a: Revision, b: Revision): -1 | 0 | 1 {
|
|
23
|
+
if (a.length !== b.length) return a.length < b.length ? -1 : 1;
|
|
24
|
+
if (a === b) return 0;
|
|
25
|
+
return a < b ? -1 : 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Parses a UTC RFC3339 timestamp; returns NaN when malformed. */
|
|
29
|
+
export function parseUtcTimestamp(value: unknown): number {
|
|
30
|
+
if (typeof value !== "string" || !RFC3339_UTC_PATTERN.test(value)) return Number.NaN;
|
|
31
|
+
return Date.parse(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
36
|
+
const proto = Object.getPrototypeOf(value) as unknown;
|
|
37
|
+
return proto === Object.prototype || proto === null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** UTF-8 byte length without allocating an encoded copy. */
|
|
41
|
+
export function utf8ByteLength(text: string): number {
|
|
42
|
+
let bytes = 0;
|
|
43
|
+
for (let i = 0; i < text.length; i++) {
|
|
44
|
+
const code = text.charCodeAt(i);
|
|
45
|
+
if (code < 0x80) bytes += 1;
|
|
46
|
+
else if (code < 0x800) bytes += 2;
|
|
47
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
|
|
48
|
+
const next = text.charCodeAt(i + 1);
|
|
49
|
+
if (next >= 0xdc00 && next <= 0xdfff) { bytes += 4; i++; } else bytes += 3;
|
|
50
|
+
} else bytes += 3;
|
|
51
|
+
}
|
|
52
|
+
return bytes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function codePointLength(text: string): number {
|
|
56
|
+
let length = 0;
|
|
57
|
+
for (let i = 0; i < text.length; i++) {
|
|
58
|
+
const code = text.charCodeAt(i);
|
|
59
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
|
|
60
|
+
const next = text.charCodeAt(i + 1);
|
|
61
|
+
if (next >= 0xdc00 && next <= 0xdfff) i++;
|
|
62
|
+
}
|
|
63
|
+
length++;
|
|
64
|
+
}
|
|
65
|
+
return length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Checks that a value is plain JSON data within the nesting limit: finite
|
|
70
|
+
* numbers, plain objects/arrays, no undefined or functions.
|
|
71
|
+
*/
|
|
72
|
+
export function isJsonValue(value: unknown, depth = 1): value is Json {
|
|
73
|
+
if (depth > MAX_NESTING_DEPTH) return false;
|
|
74
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
75
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
76
|
+
if (Array.isArray(value)) return value.every(item => isJsonValue(item, depth + 1));
|
|
77
|
+
if (isPlainObject(value)) return Object.values(value).every(item => isJsonValue(item, depth + 1));
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Canonical JSON: sorted object keys, -0 normalized to 0, no whitespace.
|
|
83
|
+
* Throws on values that are not JSON data.
|
|
84
|
+
*/
|
|
85
|
+
export function canonicalJson(value: unknown): string {
|
|
86
|
+
return JSON.stringify(canonicalize(value, 1));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function canonicalize(value: unknown, depth: number): Json {
|
|
90
|
+
if (depth > MAX_NESTING_DEPTH) throw new TypeError("Value exceeds the maximum nesting depth");
|
|
91
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
92
|
+
if (typeof value === "number") {
|
|
93
|
+
if (!Number.isFinite(value)) throw new TypeError("Non-finite numbers are not JSON data");
|
|
94
|
+
return Object.is(value, -0) ? 0 : value;
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(value)) return value.map(item => canonicalize(item, depth + 1));
|
|
97
|
+
if (isPlainObject(value)) {
|
|
98
|
+
const sorted: { [key: string]: Json } = {};
|
|
99
|
+
for (const key of Object.keys(value).sort()) {
|
|
100
|
+
const item = value[key];
|
|
101
|
+
if (item === undefined) continue;
|
|
102
|
+
Object.defineProperty(sorted, key, { value: canonicalize(item, depth + 1), enumerable: true, writable: true, configurable: true });
|
|
103
|
+
}
|
|
104
|
+
return sorted;
|
|
105
|
+
}
|
|
106
|
+
throw new TypeError("Value is not JSON data");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Pretty canonical JSON (sorted keys, two-space indent, trailing newline) for files. */
|
|
110
|
+
export function canonicalJsonPretty(value: unknown): string {
|
|
111
|
+
return `${JSON.stringify(JSON.parse(canonicalJson(value)) as Json, null, 2)}\n`;
|
|
112
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Capabilities } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const PROTOCOL_VERSION = 1 as const;
|
|
4
|
+
export const DEFAULT_SOCKET_PATH = "/streamotter/socket.io";
|
|
5
|
+
export const DEFAULT_GATEWAY_PORT = 7400;
|
|
6
|
+
export const DEFAULT_MANAGEMENT_PORT = 7401;
|
|
7
|
+
|
|
8
|
+
export const CAPABILITIES: Capabilities = Object.freeze({
|
|
9
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
10
|
+
configVersions: Object.freeze([1] as const),
|
|
11
|
+
transport: "socket.io",
|
|
12
|
+
deliveryModes: Object.freeze(["state"] as const),
|
|
13
|
+
operations: Object.freeze(["subscribe", "unsubscribe", "resync", "receipt"] as const)
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const EVENTS = Object.freeze({
|
|
17
|
+
hello: "so:hello",
|
|
18
|
+
state: "so:state",
|
|
19
|
+
data: "so:data",
|
|
20
|
+
error: "so:error",
|
|
21
|
+
subscribe: "so:subscribe",
|
|
22
|
+
unsubscribe: "so:unsubscribe",
|
|
23
|
+
resync: "so:resync",
|
|
24
|
+
receipt: "so:receipt"
|
|
25
|
+
} as const);
|
|
26
|
+
|
|
27
|
+
/** Protocol timings fixed by the V1 specification. */
|
|
28
|
+
export const HELLO_TIMEOUT_MS = 10_000;
|
|
29
|
+
export const CONTROL_CALLBACK_TIMEOUT_MS = 5_000;
|
|
30
|
+
export const UNSUBSCRIBE_TIMEOUT_MS = 5_000;
|
|
31
|
+
export const GET_TOKEN_TIMEOUT_MS = 10_000;
|
|
32
|
+
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
|
33
|
+
export const TOKEN_REFRESH_LEAD_MS = 30_000;
|
|
34
|
+
export const RECONNECT_BASE_MS = 500;
|
|
35
|
+
export const RECONNECT_CAP_MS = 30_000;
|
|
36
|
+
export const MAX_TOKEN_BYTES = 8_192;
|
|
37
|
+
export const REQUEST_CACHE_ENTRIES = 256;
|
|
38
|
+
export const REQUEST_CACHE_TTL_MS = 60_000;
|
|
39
|
+
export const STARTUP_DEADLINE_MS = 30_000;
|
|
40
|
+
export const DEFAULT_STOP_TIMEOUT_MS = 10_000;
|
|
41
|
+
export const PREVIEW_TOKEN_TTL_MS = 5 * 60_000;
|