@ultimat3/realtime 29.4.0 → 30.1.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.
@@ -0,0 +1,258 @@
1
+ // Addressing ONE connected device from any process: where it is (a TTL'd entry in the bus's shared
2
+ // set, written by the node holding its socket) and how a frame reaches it and an answer comes back
3
+ // (a subject per node, correlated by id). The transport is the realtime bus every role already
4
+ // holds, so a job on a worker reaches a device on a web pod with no second channel.
5
+
6
+ import { type Clock, logger, systemClock } from '@ultimat3/core';
7
+ import { DeviceOfflineError, DeviceRequestTimeoutError } from './device-errors';
8
+ import {
9
+ type BusClaim,
10
+ type BusMessage,
11
+ type BusReply,
12
+ type BusRequest,
13
+ decodeBusMessage,
14
+ } from './device-wire';
15
+ import type { Transport, TransportSubscription } from './fanout';
16
+ import type { JsonValue } from './json';
17
+ import { encodeToken } from './nats-kv';
18
+ import type { Scheduler } from './thundering-herd';
19
+
20
+ /** What the node holding sockets does with what the bus brings it. */
21
+ export interface DeviceBusHandlers {
22
+ /** A request for a device this node may hold; answer through `reply`. */
23
+ onRequest(request: BusRequest): void;
24
+ /** A newer socket for a device opened on `claim.node`. */
25
+ onClaim(claim: BusClaim): void;
26
+ }
27
+
28
+ export interface DeviceBusOptions {
29
+ readonly transport: Transport;
30
+ readonly path: string;
31
+ readonly nodeId: string;
32
+ readonly schedule: Scheduler;
33
+ readonly clock?: Clock | undefined;
34
+ readonly newId: () => string;
35
+ }
36
+
37
+ interface Pending {
38
+ readonly deviceId: string;
39
+ readonly resolve: (frame: JsonValue) => void;
40
+ readonly reject: (error: unknown) => void;
41
+ readonly cancel: () => void;
42
+ }
43
+
44
+ /** Where a device's registration lives. JSON, so no spelling of a tenant or id can collide. */
45
+ const locationKey = (path: string, tenant: string, deviceId: string): string =>
46
+ `device.${JSON.stringify([path, tenant, deviceId])}`;
47
+
48
+ /** The holder's half of a registration: which socket, opened when (the newest one wins). */
49
+ interface Location {
50
+ readonly socketId: string;
51
+ readonly since: number;
52
+ }
53
+
54
+ const parseLocation = (value: string): Location | undefined => {
55
+ try {
56
+ const parsed: unknown = JSON.parse(value);
57
+ if (typeof parsed !== 'object' || parsed === null) return undefined;
58
+ const { socketId, since } = parsed as { socketId?: unknown; since?: unknown };
59
+ return typeof socketId === 'string' && typeof since === 'number'
60
+ ? { socketId, since }
61
+ : undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ };
66
+
67
+ export class DeviceBus {
68
+ readonly #options: DeviceBusOptions;
69
+ readonly #clock: Clock;
70
+ readonly #prefix: string;
71
+ /** The bus's own subject, never a `channel()`: no browser subscribes to it. */
72
+ readonly #claimSubject: string;
73
+ readonly #pending = new Map<string, Pending>();
74
+ #subscriptions: TransportSubscription[] = [];
75
+
76
+ constructor(options: DeviceBusOptions) {
77
+ this.#options = options;
78
+ this.#clock = options.clock ?? systemClock;
79
+ // The path is declared text and a subject token may not carry `/`: base64url, as presence does.
80
+ this.#prefix = `x.device.${encodeToken(options.path)}`;
81
+ this.#claimSubject = `${this.#prefix}.claim`;
82
+ }
83
+
84
+ /** Requests in flight from THIS process — what a leak test reads. */
85
+ get pendingCount(): number {
86
+ return this.#pending.size;
87
+ }
88
+
89
+ async start(handlers: DeviceBusHandlers | null): Promise<void> {
90
+ const own = await this.#options.transport.subscribe(
91
+ this.#nodeSubject(this.#options.nodeId),
92
+ (payload) => this.#receive(payload, handlers),
93
+ );
94
+ this.#subscriptions.push(own);
95
+ if (handlers === null) return;
96
+ try {
97
+ this.#subscriptions.push(
98
+ await this.#options.transport.subscribe(this.#claimSubject, (payload) =>
99
+ this.#receive(payload, handlers),
100
+ ),
101
+ );
102
+ } catch (error) {
103
+ // The caller never holds a bus whose start threw, so nothing else would release `own`.
104
+ this.stop();
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ /** Every request still waiting is told the device went away: none is left to its deadline. */
110
+ stop(): void {
111
+ for (const subscription of this.#subscriptions) subscription.unsubscribe();
112
+ this.#subscriptions = [];
113
+ for (const [id, pending] of this.#pending) {
114
+ this.#settle(id, pending, { kind: 'offline', id, reason: 'this process stopped' });
115
+ }
116
+ }
117
+
118
+ async register(tenant: string, deviceId: string, socketId: string, ttlMs: number): Promise<void> {
119
+ const location: Location = { socketId, since: this.#clock.now().getTime() };
120
+ await this.#options.transport.shared.put(
121
+ locationKey(this.#options.path, tenant, deviceId),
122
+ this.#options.nodeId,
123
+ JSON.stringify(location),
124
+ ttlMs,
125
+ );
126
+ const claim: BusClaim = {
127
+ kind: 'claim',
128
+ tenant,
129
+ deviceId,
130
+ node: this.#options.nodeId,
131
+ socketId,
132
+ };
133
+ await this.#options.transport.publish(this.#claimSubject, JSON.stringify(claim));
134
+ }
135
+
136
+ /** The heartbeat's write: kept alive, or written again when the bus had already let it go. */
137
+ async refresh(tenant: string, deviceId: string, socketId: string, ttlMs: number): Promise<void> {
138
+ const key = locationKey(this.#options.path, tenant, deviceId);
139
+ const alive = await this.#options.transport.shared.touch(key, this.#options.nodeId, ttlMs);
140
+ if (alive) return;
141
+ const location: Location = { socketId, since: this.#clock.now().getTime() };
142
+ await this.#options.transport.shared.put(
143
+ key,
144
+ this.#options.nodeId,
145
+ JSON.stringify(location),
146
+ ttlMs,
147
+ );
148
+ }
149
+
150
+ async unregister(tenant: string, deviceId: string): Promise<void> {
151
+ await this.#options.transport.shared.drop(
152
+ locationKey(this.#options.path, tenant, deviceId),
153
+ this.#options.nodeId,
154
+ );
155
+ }
156
+
157
+ /**
158
+ * One frame to one device and its answer. Located first, so a device nobody holds is
159
+ * `X_DEVICE_OFFLINE` at once; a holder that finds no socket says so, and a device that never
160
+ * answers is `X_DEVICE_REQUEST_TIMEOUT` with its pending entry gone.
161
+ */
162
+ async request(
163
+ tenant: string,
164
+ deviceId: string,
165
+ frame: JsonValue,
166
+ deadlineMs: number,
167
+ ): Promise<JsonValue> {
168
+ const holder = await this.#locate(tenant, deviceId);
169
+ if (holder === null) {
170
+ throw new DeviceOfflineError({
171
+ path: this.#options.path,
172
+ deviceId,
173
+ reason: 'no node holds a socket for it',
174
+ });
175
+ }
176
+ const id = this.#options.newId();
177
+ const answer = new Promise<JsonValue>((resolve, reject) => {
178
+ const cancel = this.#options.schedule(() => {
179
+ if (!this.#pending.delete(id)) return;
180
+ reject(new DeviceRequestTimeoutError({ path: this.#options.path, deviceId, deadlineMs }));
181
+ }, deadlineMs);
182
+ this.#pending.set(id, { deviceId, resolve, reject, cancel });
183
+ });
184
+ const request: BusRequest = {
185
+ kind: 'request',
186
+ id,
187
+ from: this.#options.nodeId,
188
+ tenant,
189
+ deviceId,
190
+ deadlineMs,
191
+ frame,
192
+ };
193
+ try {
194
+ await this.#options.transport.publish(this.#nodeSubject(holder), JSON.stringify(request));
195
+ } catch (error) {
196
+ const pending = this.#pending.get(id);
197
+ this.#pending.delete(id);
198
+ pending?.cancel();
199
+ throw error;
200
+ }
201
+ return await answer;
202
+ }
203
+
204
+ /** The holder's answer, onto the requesting node's own subject. */
205
+ async reply(to: string, reply: BusReply): Promise<void> {
206
+ await this.#options.transport.publish(this.#nodeSubject(to), JSON.stringify(reply));
207
+ }
208
+
209
+ async #locate(tenant: string, deviceId: string): Promise<string | null> {
210
+ const entries = await this.#options.transport.shared.entries(
211
+ locationKey(this.#options.path, tenant, deviceId),
212
+ );
213
+ let newest: { node: string; since: number } | null = null;
214
+ for (const entry of entries) {
215
+ const location = parseLocation(entry.value);
216
+ if (location === undefined) continue;
217
+ if (newest === null || location.since > newest.since) {
218
+ newest = { node: entry.member, since: location.since };
219
+ }
220
+ }
221
+ return newest?.node ?? null;
222
+ }
223
+
224
+ #nodeSubject(node: string): string {
225
+ return `${this.#prefix}.node.${node}`;
226
+ }
227
+
228
+ #receive(payload: string, handlers: DeviceBusHandlers | null): void {
229
+ const message: BusMessage | undefined = decodeBusMessage(payload);
230
+ if (message === undefined) {
231
+ // Another build's envelope, or a stray publisher on this prefix: said once per message, and
232
+ // never answered — a reply to a message that could not be read has nobody to go to.
233
+ logger.warn('device.bus_message_unreadable', { path: this.#options.path });
234
+ return;
235
+ }
236
+ if (message.kind === 'request') handlers?.onRequest(message);
237
+ else if (message.kind === 'claim') handlers?.onClaim(message);
238
+ else {
239
+ const pending = this.#pending.get(message.id);
240
+ if (pending !== undefined) this.#settle(message.id, pending, message);
241
+ }
242
+ }
243
+
244
+ #settle(id: string, pending: Pending, reply: BusReply): void {
245
+ this.#pending.delete(id);
246
+ pending.cancel();
247
+ if (reply.kind === 'response') pending.resolve(reply.frame);
248
+ else {
249
+ pending.reject(
250
+ new DeviceOfflineError({
251
+ path: this.#options.path,
252
+ deviceId: pending.deviceId,
253
+ reason: reply.reason,
254
+ }),
255
+ );
256
+ }
257
+ }
258
+ }
@@ -0,0 +1,32 @@
1
+ // The FILE that called `deviceSocket()`: the one fact that tells `x dev`'s rescan an edited
2
+ // declaration (same module, re-evaluated) from another module taking its path. The policy
3
+ // package's `declaration-site.ts` reads a stack for the same reason — nothing else names the
4
+ // declaring module once its top level is running.
5
+
6
+ /** `\` → `/`: a Windows frame and `import.meta.path` must compare on both platforms. */
7
+ const toPosix = (text: string): string => text.replaceAll('\\', '/');
8
+
9
+ /** The path of a frame (`at f (/a/b.ts:1:2)` or `at /a/b.ts:1:2`), without its line and column. */
10
+ const fileOf = (frame: string): string | null =>
11
+ /(?:\(|at )([^()]+?):\d+:\d+\)?$/.exec(frame.trim())?.[1] ?? null;
12
+
13
+ /**
14
+ * The first file in `stack` that is none of `internal`, or `null` when no frame names one — then
15
+ * nothing can be compared, and the caller decides as it did before it could. JSC drops the frame
16
+ * of a strict-mode tail call, so an app wrapper that `return`s `deviceSocket(…)` reads as its own
17
+ * caller: the same answer on every evaluation of one module, which is all the comparison needs.
18
+ */
19
+ export function pickDeclarer(stack: string, internal: readonly string[]): string | null {
20
+ const own = new Set(internal.map(toPosix));
21
+ for (const frame of stack.split('\n').slice(1)) {
22
+ const file = fileOf(frame);
23
+ if (file !== null && !own.has(toPosix(file))) return toPosix(file);
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /** Not a throw: the `Error` exists only for its stack. Line and column drop — an edit moves them. */
29
+ export function declarerFile(internal: readonly string[]): string | null {
30
+ const stack = new Error().stack;
31
+ return stack === undefined ? null : pickDeclarer(stack, [import.meta.path, ...internal]);
32
+ }
@@ -0,0 +1,89 @@
1
+ // Every refusal a `deviceSocket()` makes. The three device codes are its own; an upgrade refusal
2
+ // reuses the sync node's socket codes (the condition is the same one), with a fix that names the
3
+ // device declaration instead of `syncNode`, because that is the file the reader has to edit.
4
+
5
+ import { renderFixShellArg } from '@ultimat3/core';
6
+ import { RealtimeError } from './realtime-error';
7
+
8
+ /** The longest `deadlineMs` a request may wait — here, so the timeout's `fix:` never prints past it. */
9
+ export const DEVICE_MAX_DEADLINE_MS = 600_000;
10
+
11
+ /** The device's id rendered for a pasteable call: verbatim when shell-safe, a placeholder otherwise. */
12
+ const idArg = (deviceId: string): string => renderFixShellArg(deviceId, '<deviceId>');
13
+
14
+ /**
15
+ * No socket is open for this device, for this tenant, on any node. Answered at once — never by
16
+ * waiting out the deadline — and identically for a device of another tenant, so a request cannot
17
+ * learn that a device id exists somewhere it may not reach.
18
+ */
19
+ export class DeviceOfflineError extends RealtimeError {
20
+ constructor(args: { path: string; deviceId: string; reason: string }) {
21
+ super({
22
+ code: 'X_DEVICE_OFFLINE',
23
+ cause: `deviceSocket("${args.path}") holds no open socket for device ${JSON.stringify(args.deviceId)} under the caller's tenant: ${args.reason}`,
24
+ fix: `x doctor --json # then retry deviceLink.request(ctx, '${idArg(args.deviceId)}', frame) once the device redials; across replicas realtime.transport must be 'nats'`,
25
+ });
26
+ }
27
+ }
28
+
29
+ /** The device took the request frame and sent no `response` carrying its id before the deadline. */
30
+ export class DeviceRequestTimeoutError extends RealtimeError {
31
+ constructor(args: { path: string; deviceId: string; deadlineMs: number }) {
32
+ super({
33
+ code: 'X_DEVICE_REQUEST_TIMEOUT',
34
+ cause: `device ${JSON.stringify(args.deviceId)} on deviceSocket("${args.path}") did not answer within ${args.deadlineMs}ms`,
35
+ fix: `deviceLink.request(ctx, '${idArg(args.deviceId)}', frame, { deadlineMs: ${Math.min(args.deadlineMs * 2, DEVICE_MAX_DEADLINE_MS)} }) # or answer every request with { type: 'response', id, frame } on the device`,
36
+ });
37
+ }
38
+ }
39
+
40
+ /** A frame past `DEVICE_FRAME_MAX_BYTES`, in either direction. */
41
+ export class DeviceFrameTooLargeError extends RealtimeError {
42
+ constructor(args: { path: string; bytes: number; limit: number }) {
43
+ super({
44
+ code: 'X_DEVICE_FRAME_TOO_LARGE',
45
+ cause: `a frame of ${args.bytes} bytes on deviceSocket("${args.path}") is past the framework's ${args.limit}-byte frame limit`,
46
+ fix: `import { DEVICE_FRAME_MAX_BYTES } from '@ultimat3/realtime/server' # then split the payload under it, or store the bytes and send their key`,
47
+ });
48
+ }
49
+ }
50
+
51
+ /** No bearer token, or one the resolver refused or that has expired: 401 before any socket exists. */
52
+ export function deviceUnauthenticated(path: string, reason: string): RealtimeError {
53
+ return new RealtimeError({
54
+ code: 'X_SOCKET_UNAUTHENTICATED',
55
+ cause: `the device upgrade on ${path} was refused: ${reason}`,
56
+ fix: `curl -i -H 'Authorization: Bearer <token>' -H 'Upgrade: websocket' "$APP_URL${renderFixShellArg(path, '/link')}" # a token deviceSocket({ resolveToken }) resolves to a grant whose expiresAt is in the future`,
57
+ });
58
+ }
59
+
60
+ /** The actor already holds `limit` device sockets on this node. */
61
+ export function deviceSocketLimit(path: string, limit: number): RealtimeError {
62
+ return new RealtimeError({
63
+ code: 'X_SOCKET_LIMIT',
64
+ cause: `this actor already holds ${limit} sockets on deviceSocket("${path}") on this node — its maxSocketsPerActor`,
65
+ fix: `deviceSocket({ path: '${path}', maxSocketsPerActor: ${limit * 2}, … }) # or close the actor's other device connections`,
66
+ });
67
+ }
68
+
69
+ /**
70
+ * The upgrade carried an `Origin`, so a BROWSER is dialling — and a device socket is for clients
71
+ * that are not one. Only an origin the declaration lists exactly is admitted.
72
+ */
73
+ export function deviceOriginRefused(path: string, admitted: readonly string[]): RealtimeError {
74
+ const list = admitted.length === 0 ? 'no browser origin at all' : `only ${admitted.join(', ')}`;
75
+ return new RealtimeError({
76
+ code: 'X_SOCKET_ORIGIN_REFUSED',
77
+ cause: `the device upgrade on ${path} carried an Origin header, and this device socket admits ${list}`,
78
+ fix: `deviceSocket({ path: '${path}', allowedOrigins: ['https://www.example.com'], … }) # only when a browser page really is a device; a non-browser client sends no Origin`,
79
+ });
80
+ }
81
+
82
+ /** The resolver raised, or did not decide within the upgrade's deadline: come back, not "signed out". */
83
+ export function deviceAuthUnavailable(detail: string): RealtimeError {
84
+ return new RealtimeError({
85
+ code: 'X_SOCKET_AUTH_UNAVAILABLE',
86
+ cause: `deviceSocket({ resolveToken }) did not decide who a connecting device is: ${detail}`,
87
+ fix: 'x doctor --json',
88
+ });
89
+ }
@@ -0,0 +1,288 @@
1
+ // The device sockets ONE node holds: open, frames in (in a lane, bounded), requests out, the beat
2
+ // and expiry sweep, and the drain. The bus side — where a device is, and how a request from
3
+ // another process reaches this one — is `device-bus.ts`; this file answers what arrives.
4
+
5
+ import type { Clock, Ctx } from '@ultimat3/core';
6
+ import { ctxOf, isUltimateError, reportError, runWithContext } from '@ultimat3/core';
7
+ import { CLOSE } from './close-codes';
8
+ import type { DeviceBus, DeviceBusHandlers } from './device-bus';
9
+ import { DeviceFrameTooLargeError } from './device-errors';
10
+ import type { DeviceWsData } from './device-upgrade';
11
+ import {
12
+ type BusClaim,
13
+ type BusRequest,
14
+ bytesOverLimit,
15
+ DEVICE_FRAME_MAX_BYTES,
16
+ type DeviceOutbound,
17
+ decodeDeviceFrame,
18
+ } from './device-wire';
19
+ import type { JsonValue } from './json';
20
+ import type { PrincipalSockets } from './principal-sockets';
21
+ import { drainPlan, type Rng, reconnectFrame, type Scheduler } from './thundering-herd';
22
+
23
+ /** The socket as much as this file touches it — Bun's `ServerWebSocket<DeviceWsData>` fits. */
24
+ export interface DeviceWs {
25
+ readonly data: DeviceWsData;
26
+ /** Bun's answer: bytes written, `-1` queued under backpressure, `0` dropped. */
27
+ send(data: string): number;
28
+ close(code?: number, reason?: string): void;
29
+ }
30
+
31
+ /** Who sent a frame — what `onFrame` reads alongside it. */
32
+ export interface DeviceInfo {
33
+ readonly deviceId: string;
34
+ readonly socketId: string;
35
+ readonly tenant: string;
36
+ }
37
+
38
+ export type DeviceFrameHandler = (input: {
39
+ readonly frame: JsonValue;
40
+ readonly device: DeviceInfo;
41
+ readonly ctx: Ctx;
42
+ }) => Promise<JsonValue | undefined> | JsonValue | undefined;
43
+
44
+ /** Frames queued behind `onFrame` on one socket before the device is told it is too fast. */
45
+ export const DEVICE_FRAME_QUEUE_MAX = 32;
46
+ /** Requests one device may hold unanswered on this node; past it the next is answered offline. */
47
+ export const DEVICE_REQUESTS_OUTSTANDING_MAX = 256;
48
+ /** A device that is silent for this many beats is gone. */
49
+ const SILENT_BEATS = 2;
50
+ /** The registration outlives a beat this many times over, so one slow write is never a vanish. */
51
+ const REGISTRATION_BEATS = 3;
52
+
53
+ interface Held {
54
+ readonly ws: DeviceWs;
55
+ lastSeen: number;
56
+ lane: Promise<void>;
57
+ queued: number;
58
+ /** Request id → the requesting node and when this node stops waiting for the device. */
59
+ readonly requests: Map<string, { readonly from: string; readonly until: number }>;
60
+ }
61
+
62
+ export interface DeviceHostOptions {
63
+ readonly path: string;
64
+ readonly heartbeatMs: number;
65
+ readonly onFrame: DeviceFrameHandler;
66
+ readonly bus: DeviceBus;
67
+ readonly principals: PrincipalSockets;
68
+ readonly clock: Clock;
69
+ readonly rng: Rng;
70
+ readonly schedule: Scheduler;
71
+ }
72
+
73
+ /** A device's key on this node: tenant and id together, never one without the other. */
74
+ const deviceKey = (tenant: string, deviceId: string): string => JSON.stringify([tenant, deviceId]);
75
+
76
+ export class DeviceHost implements DeviceBusHandlers {
77
+ readonly #options: DeviceHostOptions;
78
+ readonly #held = new Map<string, Held>();
79
+ readonly #byDevice = new Map<string, string>();
80
+
81
+ constructor(options: DeviceHostOptions) {
82
+ this.#options = options;
83
+ }
84
+
85
+ get count(): number {
86
+ return this.#held.size;
87
+ }
88
+
89
+ open(ws: DeviceWs): void {
90
+ const { socketId, tenant, grant } = ws.data;
91
+ const key = deviceKey(tenant, grant.deviceId);
92
+ // One socket per device: the newest wins here and, through the claim, on every other node.
93
+ const previous = this.#byDevice.get(key);
94
+ if (previous !== undefined) this.#held.get(previous)?.ws.close(CLOSE.policy, 'replaced');
95
+ this.#held.set(socketId, {
96
+ ws,
97
+ lastSeen: this.#options.clock.monotonic(),
98
+ lane: Promise.resolve(),
99
+ queued: 0,
100
+ requests: new Map(),
101
+ });
102
+ this.#byDevice.set(key, socketId);
103
+ this.#send(ws, {
104
+ type: 'hello',
105
+ heartbeatMs: this.#options.heartbeatMs,
106
+ maxFrameBytes: DEVICE_FRAME_MAX_BYTES,
107
+ });
108
+ this.#background(
109
+ this.#options.bus.register(tenant, grant.deviceId, socketId, this.#registrationTtl()),
110
+ 'device.register',
111
+ );
112
+ }
113
+
114
+ message(ws: DeviceWs, message: string | Uint8Array): void {
115
+ const held = this.#held.get(ws.data.socketId);
116
+ if (held === undefined) return;
117
+ if (typeof message !== 'string') {
118
+ ws.close(1003, 'text frames only');
119
+ return;
120
+ }
121
+ // Bun's `maxPayloadLength` refuses first; this is the same limit for a host that applied none.
122
+ if (bytesOverLimit(message) !== null) {
123
+ ws.close(1009, 'X_DEVICE_FRAME_TOO_LARGE');
124
+ return;
125
+ }
126
+ held.lastSeen = this.#options.clock.monotonic();
127
+ const frame = decodeDeviceFrame(message);
128
+ if (frame === undefined) {
129
+ ws.close(CLOSE.policy, 'X_PROTOCOL_VERSION');
130
+ return;
131
+ }
132
+ if (frame.type === 'response') {
133
+ const asked = held.requests.get(frame.id);
134
+ if (asked === undefined) return;
135
+ held.requests.delete(frame.id);
136
+ this.#background(
137
+ this.#options.bus.reply(asked.from, { kind: 'response', id: frame.id, frame: frame.frame }),
138
+ 'device.reply',
139
+ );
140
+ } else if (frame.type === 'frame') this.#enqueue(held, frame.frame);
141
+ }
142
+
143
+ close(ws: DeviceWs): void {
144
+ const { socketId, tenant, grant } = ws.data;
145
+ const held = this.#held.get(socketId);
146
+ this.#options.principals.release(socketId);
147
+ if (held === undefined) return;
148
+ this.#held.delete(socketId);
149
+ for (const [id, asked] of held.requests)
150
+ this.#offline(asked.from, id, 'the device disconnected');
151
+ const key = deviceKey(tenant, grant.deviceId);
152
+ if (this.#byDevice.get(key) !== socketId) return;
153
+ this.#byDevice.delete(key);
154
+ this.#background(this.#options.bus.unregister(tenant, grant.deviceId), 'device.unregister');
155
+ }
156
+
157
+ onRequest(request: BusRequest): void {
158
+ const socketId = this.#byDevice.get(deviceKey(request.tenant, request.deviceId));
159
+ const held = socketId === undefined ? undefined : this.#held.get(socketId);
160
+ if (held === undefined) {
161
+ this.#offline(request.from, request.id, 'no socket for it on the node it was registered on');
162
+ return;
163
+ }
164
+ if (held.requests.size >= DEVICE_REQUESTS_OUTSTANDING_MAX) {
165
+ this.#offline(
166
+ request.from,
167
+ request.id,
168
+ `it holds ${DEVICE_REQUESTS_OUTSTANDING_MAX} unanswered requests`,
169
+ );
170
+ return;
171
+ }
172
+ if (!this.#send(held.ws, { type: 'request', id: request.id, frame: request.frame })) {
173
+ this.#offline(request.from, request.id, 'its socket dropped the request frame');
174
+ return;
175
+ }
176
+ const until = this.#options.clock.monotonic() + request.deadlineMs;
177
+ held.requests.set(request.id, { from: request.from, until });
178
+ }
179
+
180
+ onClaim(claim: BusClaim): void {
181
+ const socketId = this.#byDevice.get(deviceKey(claim.tenant, claim.deviceId));
182
+ if (socketId === undefined || socketId === claim.socketId) return;
183
+ this.#held.get(socketId)?.ws.close(CLOSE.policy, 'replaced');
184
+ }
185
+
186
+ /** One pass: silent and expired sockets closed, live ones re-registered, stale requests forgotten. */
187
+ sweep(): void {
188
+ const now = this.#options.clock.monotonic();
189
+ const wall = this.#options.clock.now().getTime();
190
+ const silentAfter = this.#options.heartbeatMs * SILENT_BEATS;
191
+ for (const held of this.#held.values()) {
192
+ const { grant, tenant, socketId } = held.ws.data;
193
+ if (now - held.lastSeen > silentAfter) {
194
+ held.ws.close(CLOSE.idle, 'no beat');
195
+ continue;
196
+ }
197
+ if (grant.expiresAt !== undefined && !(grant.expiresAt > wall)) {
198
+ held.ws.close(CLOSE.policy, 'grant expired');
199
+ continue;
200
+ }
201
+ for (const [id, asked] of held.requests) if (asked.until <= now) held.requests.delete(id);
202
+ this.#background(
203
+ this.#options.bus.refresh(tenant, grant.deviceId, socketId, this.#registrationTtl()),
204
+ 'device.refresh',
205
+ );
206
+ }
207
+ }
208
+
209
+ /** Every socket told when to come back (slots across `spreadMs`), then the grace, then closed. */
210
+ async drain(options: { graceMs: number; spreadMs: number }): Promise<void> {
211
+ const held = [...this.#held.values()];
212
+ const plan = drainPlan(
213
+ held.map((entry) => entry.ws.data.socketId),
214
+ { spreadMs: options.spreadMs, rng: this.#options.rng },
215
+ );
216
+ for (const entry of plan) {
217
+ const socket = this.#held.get(entry.socketId);
218
+ if (socket !== undefined) this.#send(socket.ws, reconnectFrame(entry.afterMs, 'drain'));
219
+ }
220
+ if (options.graceMs > 0 && this.#held.size > 0) {
221
+ await new Promise<void>((resolve) => this.#options.schedule(resolve, options.graceMs));
222
+ }
223
+ // Closed, not removed: each `close` callback releases its slot, its requests and its entry.
224
+ for (const entry of [...this.#held.values()]) entry.ws.close(CLOSE.goingAway, 'drain');
225
+ }
226
+
227
+ #enqueue(held: Held, frame: JsonValue): void {
228
+ if (held.queued >= DEVICE_FRAME_QUEUE_MAX) {
229
+ held.ws.close(CLOSE.policy, 'X_FRAME_RATE_LIMIT');
230
+ return;
231
+ }
232
+ held.queued += 1;
233
+ held.lane = held.lane.then(async () => {
234
+ try {
235
+ await this.#handle(held, frame);
236
+ } finally {
237
+ held.queued -= 1;
238
+ }
239
+ });
240
+ }
241
+
242
+ async #handle(held: Held, frame: JsonValue): Promise<void> {
243
+ const { grant, socketId, tenant } = held.ws.data;
244
+ const device: DeviceInfo = { deviceId: grant.deviceId, socketId, tenant };
245
+ const ctx = ctxOf({ actor: grant.actor });
246
+ try {
247
+ const answer = await runWithContext(ctx, () => this.#options.onFrame({ frame, device, ctx }));
248
+ if (answer === undefined || !this.#held.has(socketId)) return;
249
+ const text = JSON.stringify({ type: 'frame', frame: answer });
250
+ const over = bytesOverLimit(text);
251
+ if (over !== null) {
252
+ throw new DeviceFrameTooLargeError({
253
+ path: this.#options.path,
254
+ bytes: over,
255
+ limit: DEVICE_FRAME_MAX_BYTES,
256
+ });
257
+ }
258
+ held.ws.send(text);
259
+ } catch (error) {
260
+ reportError(error, { source: 'realtime', scope: { operation: 'device.on_frame' } });
261
+ // The code only: the device is a client, and a cause is this node's to read in its log.
262
+ this.#send(held.ws, {
263
+ type: 'error',
264
+ code: isUltimateError(error) ? error.code : 'X_INTERNAL',
265
+ });
266
+ }
267
+ }
268
+
269
+ /** `reconnect` is the sync protocol's own frame, built by its one builder. */
270
+ #send(ws: DeviceWs, frame: DeviceOutbound | ReturnType<typeof reconnectFrame>): boolean {
271
+ return ws.send(JSON.stringify(frame)) !== 0;
272
+ }
273
+
274
+ #offline(to: string, id: string, reason: string): void {
275
+ this.#background(this.#options.bus.reply(to, { kind: 'offline', id, reason }), 'device.reply');
276
+ }
277
+
278
+ #registrationTtl(): number {
279
+ return this.#options.heartbeatMs * REGISTRATION_BEATS;
280
+ }
281
+
282
+ /** A bus write nobody awaits: its failure is reported, never left unhandled. */
283
+ #background(work: Promise<void>, operation: string): void {
284
+ work.catch((error: unknown) => {
285
+ reportError(error, { source: 'realtime', scope: { operation } });
286
+ });
287
+ }
288
+ }