@streamotter/gateway 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 +158 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/internals.d.ts +6 -0
- package/dist/internals.d.ts.map +1 -0
- package/dist/internals.js +6 -0
- package/dist/internals.js.map +1 -0
- package/dist/management/index.d.ts +26 -0
- package/dist/management/index.d.ts.map +1 -0
- package/dist/management/index.js +354 -0
- package/dist/management/index.js.map +1 -0
- package/dist/runtime/budget.d.ts +24 -0
- package/dist/runtime/budget.d.ts.map +1 -0
- package/dist/runtime/budget.js +56 -0
- package/dist/runtime/budget.js.map +1 -0
- package/dist/runtime/core.d.ts +67 -0
- package/dist/runtime/core.d.ts.map +1 -0
- package/dist/runtime/core.js +34 -0
- package/dist/runtime/core.js.map +1 -0
- package/dist/runtime/gateway.d.ts +117 -0
- package/dist/runtime/gateway.d.ts.map +1 -0
- package/dist/runtime/gateway.js +881 -0
- package/dist/runtime/gateway.js.map +1 -0
- package/dist/runtime/identity.d.ts +28 -0
- package/dist/runtime/identity.d.ts.map +1 -0
- package/dist/runtime/identity.js +92 -0
- package/dist/runtime/identity.js.map +1 -0
- package/dist/runtime/session.d.ts +49 -0
- package/dist/runtime/session.d.ts.map +1 -0
- package/dist/runtime/session.js +299 -0
- package/dist/runtime/session.js.map +1 -0
- package/dist/runtime/subscription.d.ts +65 -0
- package/dist/runtime/subscription.d.ts.map +1 -0
- package/dist/runtime/subscription.js +482 -0
- package/dist/runtime/subscription.js.map +1 -0
- package/dist/runtime/traces.d.ts +26 -0
- package/dist/runtime/traces.d.ts.map +1 -0
- package/dist/runtime/traces.js +98 -0
- package/dist/runtime/traces.js.map +1 -0
- package/dist/runtime/util.d.ts +50 -0
- package/dist/runtime/util.d.ts.map +1 -0
- package/dist/runtime/util.js +148 -0
- package/dist/runtime/util.js.map +1 -0
- package/dist/sources/fixture.d.ts +27 -0
- package/dist/sources/fixture.d.ts.map +1 -0
- package/dist/sources/fixture.js +89 -0
- package/dist/sources/fixture.js.map +1 -0
- package/dist/sources/kafka.d.ts +63 -0
- package/dist/sources/kafka.d.ts.map +1 -0
- package/dist/sources/kafka.js +418 -0
- package/dist/sources/kafka.js.map +1 -0
- package/dist/sources/kafkajs-patch.d.ts +11 -0
- package/dist/sources/kafkajs-patch.d.ts.map +1 -0
- package/dist/sources/kafkajs-patch.js +34 -0
- package/dist/sources/kafkajs-patch.js.map +1 -0
- package/dist/sources/types.d.ts +46 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +2 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/transport/socketio.d.ts +44 -0
- package/dist/transport/socketio.d.ts.map +1 -0
- package/dist/transport/socketio.js +80 -0
- package/dist/transport/socketio.js.map +1 -0
- package/dist/transport/types.d.ts +10 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +2 -0
- package/dist/transport/types.js.map +1 -0
- package/package.json +61 -0
- package/src/index.ts +20 -0
- package/src/internals.ts +5 -0
- package/src/management/index.ts +371 -0
- package/src/runtime/budget.ts +60 -0
- package/src/runtime/core.ts +101 -0
- package/src/runtime/gateway.ts +892 -0
- package/src/runtime/identity.ts +99 -0
- package/src/runtime/session.ts +329 -0
- package/src/runtime/subscription.ts +531 -0
- package/src/runtime/traces.ts +102 -0
- package/src/runtime/util.ts +157 -0
- package/src/sources/fixture.ts +95 -0
- package/src/sources/kafka.ts +440 -0
- package/src/sources/kafkajs-patch.ts +41 -0
- package/src/sources/types.ts +42 -0
- package/src/transport/socketio.ts +125 -0
- package/src/transport/types.ts +10 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isJsonValue, isPlainObject, parseUtcTimestamp, type Principal, type Revocation, type SourceRecord
|
|
3
|
+
} from "@streamotter/contracts";
|
|
4
|
+
import { sha256Hex } from "./util.ts";
|
|
5
|
+
|
|
6
|
+
const MAX_IDENTITY_FIELD = 512;
|
|
7
|
+
|
|
8
|
+
function isIdentityField(value: unknown): value is string {
|
|
9
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_IDENTITY_FIELD;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Validates a principal returned by trusted server code; returns a reason when invalid. */
|
|
13
|
+
export function principalProblem(value: unknown, now = Date.now()): string | null {
|
|
14
|
+
if (!isPlainObject(value)) return "principal must be an object";
|
|
15
|
+
for (const key of ["subject", "tenantId", "sessionId"] as const) {
|
|
16
|
+
if (!isIdentityField(value[key])) return `principal.${key} must be a non-empty string`;
|
|
17
|
+
}
|
|
18
|
+
const expiresAt = parseUtcTimestamp(value["expiresAt"]);
|
|
19
|
+
if (!Number.isFinite(expiresAt)) return "principal.expiresAt must be a UTC RFC3339 timestamp";
|
|
20
|
+
if (expiresAt <= now) return "principal.expiresAt must be in the future";
|
|
21
|
+
if (!isPlainObject(value["claims"]) || !isJsonValue(value["claims"])) return "principal.claims must be a JSON object";
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Copies a validated principal so later mutation by application code has no effect. */
|
|
26
|
+
export function freezePrincipal(principal: Principal): Principal {
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
subject: principal.subject,
|
|
29
|
+
tenantId: principal.tenantId,
|
|
30
|
+
sessionId: principal.sessionId,
|
|
31
|
+
expiresAt: principal.expiresAt,
|
|
32
|
+
claims: Object.freeze(structuredClone(principal.claims))
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Opaque, stable key scoped to project, tenant, and subject; changes when the account changes. */
|
|
37
|
+
export function identityKey(projectId: string, principal: Principal): string {
|
|
38
|
+
return sha256Hex(["identity", projectId, principal.tenantId, principal.subject]).slice(0, 32);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sourceRecordId(projectId: string, sourceId: string, generation: string, position: SourceRecord["position"]): string {
|
|
42
|
+
return sha256Hex([projectId, sourceId, generation, position]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function updateEventId(recordId: string, channel: string, version: number, tenantId: string, canonicalParams: string, revision: string): string {
|
|
46
|
+
return sha256Hex([recordId, channel, version, tenantId, canonicalParams, revision]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RevocationEntry { seq: number; at: number; selector: Revocation; canonicalParams: string | null }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Short-lived, in-memory record of revocations so operations that were already
|
|
53
|
+
* pending when a revocation arrived cannot complete with stale access. This is
|
|
54
|
+
* not a durable revocation database: applications update their own policy first.
|
|
55
|
+
*/
|
|
56
|
+
export class RevocationLog {
|
|
57
|
+
readonly #retentionMs: number;
|
|
58
|
+
#entries: RevocationEntry[] = [];
|
|
59
|
+
#seq = 0;
|
|
60
|
+
|
|
61
|
+
constructor(retentionMs: number) {
|
|
62
|
+
this.#retentionMs = retentionMs;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get sequence(): number {
|
|
66
|
+
return this.#seq;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
add(selector: Revocation, canonicalParams: string | null): void {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
this.#entries = this.#entries.filter(entry => now - entry.at <= this.#retentionMs);
|
|
72
|
+
if (this.#entries.length >= 10_000) this.#entries.shift();
|
|
73
|
+
this.#entries.push({ seq: ++this.#seq, at: now, selector, canonicalParams });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True if a revocation recorded after `sinceSeq` matches this principal (and channel, if given). */
|
|
77
|
+
revokedSince(sinceSeq: number, principal: Principal, channel?: { name: string; version: number; canonicalParams: string }): boolean {
|
|
78
|
+
for (const entry of this.#entries) {
|
|
79
|
+
if (entry.seq <= sinceSeq) continue;
|
|
80
|
+
if (matchesPrincipal(entry.selector, principal) && entry.selector.kind !== "channel") return true;
|
|
81
|
+
if (entry.selector.kind === "channel" && channel !== undefined && matchesPrincipal(entry.selector, principal)
|
|
82
|
+
&& entry.selector.channel === channel.name && entry.selector.channelVersion === channel.version
|
|
83
|
+
&& (entry.canonicalParams === null || entry.canonicalParams === channel.canonicalParams)) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function matchesPrincipal(selector: Revocation, principal: Principal): boolean {
|
|
92
|
+
if (selector.tenantId !== principal.tenantId) return false;
|
|
93
|
+
switch (selector.kind) {
|
|
94
|
+
case "session": return selector.sessionId === principal.sessionId;
|
|
95
|
+
case "subject":
|
|
96
|
+
case "channel": return selector.subject === principal.subject;
|
|
97
|
+
default: return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CAPABILITIES, canonicalizeParams, canonicalJson, isIdentifier, isPlainObject, isUuid, parseUtcTimestamp,
|
|
3
|
+
REQUEST_CACHE_ENTRIES, REQUEST_CACHE_TTL_MS, streamError, utf8ByteLength,
|
|
4
|
+
type DataFrame, type ErrorCode, type ErrorFrame, type Json, type Principal, type Result, type StreamError,
|
|
5
|
+
type SubscriptionFrame
|
|
6
|
+
} from "@streamotter/contracts";
|
|
7
|
+
import { ByteBudget } from "./budget.ts";
|
|
8
|
+
import type { ChannelRuntime, GatewayCore, SubscriptionHost } from "./core.ts";
|
|
9
|
+
import { ServerSubscription } from "./subscription.ts";
|
|
10
|
+
import { newId, setLongTimeout, TokenBucket } from "./util.ts";
|
|
11
|
+
import type { ConnectionTransport } from "../transport/types.ts";
|
|
12
|
+
|
|
13
|
+
type Reply = (result: Result<unknown>) => void;
|
|
14
|
+
|
|
15
|
+
interface CachedResult { fingerprint: string; result: Result<unknown>; expiresAt: number }
|
|
16
|
+
|
|
17
|
+
export interface SessionOwner {
|
|
18
|
+
readonly core: GatewayCore;
|
|
19
|
+
channel(name: string): ChannelRuntime | undefined;
|
|
20
|
+
sessionClosed(session: ClientSession): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SUBSCRIBE_KEYS = new Set(["requestId", "subscriptionId", "channel", "channelVersion", "params"]);
|
|
24
|
+
const CONTROL_KEYS = new Set(["requestId", "subscriptionId"]);
|
|
25
|
+
const RECEIPT_KEYS = new Set(["subscriptionId", "epoch", "sequence"]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One authenticated transport connection. Validates every client message, applies
|
|
29
|
+
* per-connection rate and size limits, and owns its subscriptions.
|
|
30
|
+
*/
|
|
31
|
+
export class ClientSession implements SubscriptionHost {
|
|
32
|
+
readonly connectionId = newId();
|
|
33
|
+
readonly principal: Principal;
|
|
34
|
+
readonly identityKey: string;
|
|
35
|
+
readonly previewSessionId: string | null;
|
|
36
|
+
readonly connectionBudget: ByteBudget;
|
|
37
|
+
readonly #owner: SessionOwner;
|
|
38
|
+
readonly #transport: ConnectionTransport;
|
|
39
|
+
readonly #subscriptions = new Map<string, ServerSubscription>();
|
|
40
|
+
readonly #requests = new Map<string, CachedResult>();
|
|
41
|
+
readonly #bucket: TokenBucket;
|
|
42
|
+
readonly #expiresAtMs: number;
|
|
43
|
+
#expiryTimer: { clear(): void } | null = null;
|
|
44
|
+
#closed = false;
|
|
45
|
+
|
|
46
|
+
constructor(options: {
|
|
47
|
+
owner: SessionOwner;
|
|
48
|
+
transport: ConnectionTransport;
|
|
49
|
+
principal: Principal;
|
|
50
|
+
identityKey: string;
|
|
51
|
+
previewSessionId: string | null;
|
|
52
|
+
}) {
|
|
53
|
+
this.#owner = options.owner;
|
|
54
|
+
this.#transport = options.transport;
|
|
55
|
+
this.principal = options.principal;
|
|
56
|
+
this.identityKey = options.identityKey;
|
|
57
|
+
this.previewSessionId = options.previewSessionId;
|
|
58
|
+
const { limits, gatewayBudget } = this.#owner.core;
|
|
59
|
+
this.connectionBudget = new ByteBudget(limits.maxPendingBytesPerConnection, gatewayBudget);
|
|
60
|
+
this.#bucket = new TokenBucket(limits.controlRequestsPerSecond, limits.controlRequestsPerSecond * 2);
|
|
61
|
+
this.#expiresAtMs = parseUtcTimestamp(this.principal.expiresAt);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get closed(): boolean {
|
|
65
|
+
return this.#closed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get subscriptionCount(): number {
|
|
69
|
+
return this.#subscriptions.size;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
subscriptions(): IterableIterator<ServerSubscription> {
|
|
73
|
+
return this.#subscriptions.values();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
open(): void {
|
|
77
|
+
this.#transport.sendHello({
|
|
78
|
+
...CAPABILITIES,
|
|
79
|
+
connectionId: this.connectionId,
|
|
80
|
+
identityKey: this.identityKey,
|
|
81
|
+
authExpiresAt: this.principal.expiresAt
|
|
82
|
+
});
|
|
83
|
+
this.#expiryTimer = setLongTimeout(() => this.#expire(), this.#expiresAtMs - Date.now());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- SubscriptionHost ------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
sendState(frame: SubscriptionFrame): void {
|
|
89
|
+
if (!this.#closed) this.#transport.sendState(frame);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
sendData(frame: DataFrame): void {
|
|
93
|
+
if (!this.#closed) this.#transport.sendData(frame);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
sendError(frame: ErrorFrame): void {
|
|
97
|
+
if (!this.#closed) this.#transport.sendError(frame);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
canDeliver(): boolean {
|
|
101
|
+
if (this.#closed) return false;
|
|
102
|
+
if (Date.now() >= this.#expiresAtMs) {
|
|
103
|
+
queueMicrotask(() => this.#expire());
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
receiptTimedOut(): void {
|
|
110
|
+
this.close(streamError("OVERLOADED", {
|
|
111
|
+
message: "The client did not confirm receipt in time; reconnect to resynchronize.",
|
|
112
|
+
requestId: newId()
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
removeSubscription(subscriptionId: string): void {
|
|
117
|
+
this.#subscriptions.delete(subscriptionId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- protocol handlers -----------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
handleSubscribe(payload: unknown, reply: unknown): void {
|
|
123
|
+
if (this.#closed) return;
|
|
124
|
+
this.#control("so:subscribe", payload, reply, SUBSCRIBE_KEYS, (request, requestId) => {
|
|
125
|
+
const subscriptionId = request["subscriptionId"];
|
|
126
|
+
const channelName = request["channel"];
|
|
127
|
+
const version = request["channelVersion"];
|
|
128
|
+
const params = request["params"];
|
|
129
|
+
if (!isUuid(subscriptionId)) return this.#err("INVALID_REQUEST", requestId, "subscriptionId must be a UUID.");
|
|
130
|
+
if (typeof channelName !== "string" || typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) {
|
|
131
|
+
return this.#err("INVALID_REQUEST", requestId, "channel and channelVersion are required.");
|
|
132
|
+
}
|
|
133
|
+
if (!isPlainObject(params)) return this.#err("INVALID_PARAMS", requestId);
|
|
134
|
+
const channel = isIdentifier(channelName) ? this.#owner.channel(channelName) : undefined;
|
|
135
|
+
if (channel === undefined || channel.version !== version) {
|
|
136
|
+
// Unrecognized channels and versions are publicly indistinguishable from denial.
|
|
137
|
+
this.#owner.core.traces.record({
|
|
138
|
+
requestId, stage: "authorize", outcome: "rejected",
|
|
139
|
+
errorCode: channel === undefined ? "CHANNEL_NOT_FOUND" : "CHANNEL_VERSION_UNSUPPORTED",
|
|
140
|
+
...(channel === undefined ? {} : { channel: channel.name, sourceId: channel.source.id }),
|
|
141
|
+
subscriptionId
|
|
142
|
+
});
|
|
143
|
+
return this.#err("FORBIDDEN", requestId);
|
|
144
|
+
}
|
|
145
|
+
let encoded: string;
|
|
146
|
+
try {
|
|
147
|
+
encoded = canonicalJson(params);
|
|
148
|
+
} catch {
|
|
149
|
+
return this.#err("INVALID_PARAMS", requestId);
|
|
150
|
+
}
|
|
151
|
+
if (utf8ByteLength(encoded) > this.#owner.core.limits.maxParamsBytes) return this.#err("INVALID_PARAMS", requestId, "The channel parameters are too large.");
|
|
152
|
+
const canonical = canonicalizeParams(channel.paramsSchema, params);
|
|
153
|
+
if (!canonical.ok) return this.#err("INVALID_PARAMS", requestId, `The channel parameters are invalid at ${canonical.issue.path}.`);
|
|
154
|
+
|
|
155
|
+
const existing = this.#subscriptions.get(subscriptionId);
|
|
156
|
+
if (existing !== undefined) {
|
|
157
|
+
const contract = JSON.stringify([channel.name, channel.version, canonical.canonical]);
|
|
158
|
+
if (existing.contractKey !== contract) {
|
|
159
|
+
return this.#err("INVALID_REQUEST", requestId, "This subscription ID is already used for a different contract.");
|
|
160
|
+
}
|
|
161
|
+
return { ok: true, requestId, data: { subscriptionId } };
|
|
162
|
+
}
|
|
163
|
+
if (this.#subscriptions.size >= this.#owner.core.limits.maxSubscriptionsPerConnection) {
|
|
164
|
+
return this.#err("OVERLOADED", requestId, "This connection has reached its subscription limit.");
|
|
165
|
+
}
|
|
166
|
+
const subscription = new ServerSubscription({
|
|
167
|
+
id: subscriptionId,
|
|
168
|
+
channel,
|
|
169
|
+
params: canonical.params,
|
|
170
|
+
canonicalParams: canonical.canonical,
|
|
171
|
+
host: this,
|
|
172
|
+
core: this.#owner.core
|
|
173
|
+
});
|
|
174
|
+
this.#subscriptions.set(subscriptionId, subscription);
|
|
175
|
+
return { ok: true, requestId, data: { subscriptionId }, after: () => subscription.start(requestId) };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
handleUnsubscribe(payload: unknown, reply: unknown): void {
|
|
180
|
+
if (this.#closed) return;
|
|
181
|
+
this.#control("so:unsubscribe", payload, reply, CONTROL_KEYS, (request, requestId) => {
|
|
182
|
+
const subscriptionId = request["subscriptionId"];
|
|
183
|
+
if (!isUuid(subscriptionId)) return this.#err("INVALID_REQUEST", requestId, "subscriptionId must be a UUID.");
|
|
184
|
+
const subscription = this.#subscriptions.get(subscriptionId);
|
|
185
|
+
if (subscription !== undefined) {
|
|
186
|
+
subscription.dispose();
|
|
187
|
+
this.#subscriptions.delete(subscriptionId);
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, requestId, data: null };
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
handleResync(payload: unknown, reply: unknown): void {
|
|
194
|
+
if (this.#closed) return;
|
|
195
|
+
this.#control("so:resync", payload, reply, CONTROL_KEYS, (request, requestId) => {
|
|
196
|
+
const subscriptionId = request["subscriptionId"];
|
|
197
|
+
if (!isUuid(subscriptionId)) return this.#err("INVALID_REQUEST", requestId, "subscriptionId must be a UUID.");
|
|
198
|
+
const subscription = this.#subscriptions.get(subscriptionId);
|
|
199
|
+
if (subscription === undefined) return this.#err("INVALID_REQUEST", requestId, "The subscription does not exist on this connection.");
|
|
200
|
+
return { ok: true, requestId, data: null, after: () => subscription.requestResync(requestId) };
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
handleReceipt(payload: unknown): void {
|
|
205
|
+
if (this.#closed) return;
|
|
206
|
+
if (!isPlainObject(payload) || Object.keys(payload).some(key => !RECEIPT_KEYS.has(key))
|
|
207
|
+
|| !isUuid(payload["subscriptionId"]) || typeof payload["epoch"] !== "string" || payload["epoch"].length > 64
|
|
208
|
+
|| typeof payload["sequence"] !== "number" || !Number.isSafeInteger(payload["sequence"]) || payload["sequence"] < 1) {
|
|
209
|
+
this.sendError({ error: streamError("INVALID_REQUEST", { message: "The receipt is malformed.", requestId: newId() }) });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// Unsolicited subscription IDs do not allocate server state.
|
|
213
|
+
this.#subscriptions.get(payload["subscriptionId"])?.onReceipt(payload["epoch"], payload["sequence"]);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
handleUnknown(event: string, args: readonly unknown[]): void {
|
|
217
|
+
if (this.#closed) return;
|
|
218
|
+
const error = streamError("UNSUPPORTED_CAPABILITY", {
|
|
219
|
+
message: `The operation "${event.slice(0, 64)}" is not supported by protocol version 1.`,
|
|
220
|
+
requestId: newId()
|
|
221
|
+
});
|
|
222
|
+
const reply = args[args.length - 1];
|
|
223
|
+
if (typeof reply === "function") (reply as Reply)({ ok: false, requestId: error.requestId, error });
|
|
224
|
+
else this.sendError({ error });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The transport closed underneath us. */
|
|
228
|
+
handleTransportClosed(): void {
|
|
229
|
+
this.#teardown();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Closes the connection, optionally telling the client why first. */
|
|
233
|
+
close(error?: StreamError): void {
|
|
234
|
+
if (this.#closed) return;
|
|
235
|
+
if (error !== undefined) this.#transport.sendError({ error });
|
|
236
|
+
this.#teardown();
|
|
237
|
+
this.#transport.close();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- internals -------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
#expire(): void {
|
|
243
|
+
this.close(streamError("UNAUTHENTICATED", {
|
|
244
|
+
message: "Authentication has expired; reconnect with a new token.",
|
|
245
|
+
retryable: true,
|
|
246
|
+
requestId: newId()
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#teardown(): void {
|
|
251
|
+
if (this.#closed) return;
|
|
252
|
+
this.#closed = true;
|
|
253
|
+
this.#expiryTimer?.clear();
|
|
254
|
+
for (const subscription of this.#subscriptions.values()) subscription.dispose();
|
|
255
|
+
this.#subscriptions.clear();
|
|
256
|
+
this.#requests.clear();
|
|
257
|
+
this.#owner.sessionClosed(this);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#err(code: ErrorCode, requestId: string, message?: string): Result<never> {
|
|
261
|
+
return { ok: false, requestId, error: streamError(code, message === undefined ? { requestId } : { requestId, message }) };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Shared control-request handling: callback presence, rate limit, shape, the
|
|
266
|
+
* bounded idempotency cache, then the operation. The acceptance callback is sent
|
|
267
|
+
* before any state/data frames the operation produces (`after`).
|
|
268
|
+
*/
|
|
269
|
+
#control(
|
|
270
|
+
event: string,
|
|
271
|
+
payload: unknown,
|
|
272
|
+
reply: unknown,
|
|
273
|
+
allowedKeys: ReadonlySet<string>,
|
|
274
|
+
operation: (request: Record<string, unknown>, requestId: string) => Result<unknown> & { after?: () => void }
|
|
275
|
+
): void {
|
|
276
|
+
if (typeof reply !== "function") {
|
|
277
|
+
this.sendError({ error: streamError("INVALID_REQUEST", { message: `${event} requires an acknowledgement callback.`, requestId: newId() }) });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const respond = reply as Reply;
|
|
281
|
+
const requestId = isPlainObject(payload) && isUuid(payload["requestId"]) ? payload["requestId"] : newId();
|
|
282
|
+
if (!this.#bucket.take()) {
|
|
283
|
+
respond(this.#err("OVERLOADED", requestId, "Too many control requests; slow down."));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (!isPlainObject(payload) || !isUuid(payload["requestId"])) {
|
|
287
|
+
respond(this.#err("INVALID_REQUEST", requestId, "requestId must be a UUID."));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const unknownKey = Object.keys(payload).find(key => !allowedKeys.has(key));
|
|
291
|
+
if (unknownKey !== undefined) {
|
|
292
|
+
respond(this.#err("UNSUPPORTED_CAPABILITY", requestId, `"${unknownKey.slice(0, 64)}" is not supported by protocol version 1.`));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
let fingerprint: string;
|
|
296
|
+
try {
|
|
297
|
+
fingerprint = canonicalJson([event, payload as Json]);
|
|
298
|
+
} catch {
|
|
299
|
+
respond(this.#err("INVALID_REQUEST", requestId, "The request is not JSON data."));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
const cached = this.#requests.get(requestId);
|
|
304
|
+
if (cached !== undefined && cached.expiresAt > now) {
|
|
305
|
+
respond(cached.fingerprint === fingerprint
|
|
306
|
+
? cached.result
|
|
307
|
+
: this.#err("INVALID_REQUEST", requestId, "This requestId was already used for a different request."));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const { after, ...result } = operation(payload, requestId);
|
|
311
|
+
this.#remember(requestId, fingerprint, result as Result<unknown>, now);
|
|
312
|
+
respond(result as Result<unknown>);
|
|
313
|
+
after?.();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#remember(requestId: string, fingerprint: string, result: Result<unknown>, now: number): void {
|
|
317
|
+
for (const [key, entry] of this.#requests) {
|
|
318
|
+
if (entry.expiresAt <= now) this.#requests.delete(key);
|
|
319
|
+
else break;
|
|
320
|
+
}
|
|
321
|
+
this.#requests.delete(requestId);
|
|
322
|
+
this.#requests.set(requestId, { fingerprint, result, expiresAt: now + REQUEST_CACHE_TTL_MS });
|
|
323
|
+
while (this.#requests.size > REQUEST_CACHE_ENTRIES) {
|
|
324
|
+
const oldest = this.#requests.keys().next();
|
|
325
|
+
if (oldest.done === true) break;
|
|
326
|
+
this.#requests.delete(oldest.value);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|