@the-open-engine/zeroshot 6.11.0 → 6.13.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.
Files changed (76) hide show
  1. package/cli/commands/providers.js +13 -13
  2. package/cli/index.js +77 -35
  3. package/cli/lib/first-run.js +12 -11
  4. package/cli/lib/update-checker.js +517 -132
  5. package/lib/agent-cli-provider/adapters/opencode.d.ts.map +1 -1
  6. package/lib/agent-cli-provider/adapters/opencode.js +3 -0
  7. package/lib/agent-cli-provider/adapters/opencode.js.map +1 -1
  8. package/lib/agent-cli-provider/types.d.ts +1 -0
  9. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  10. package/lib/agent-cli-provider/types.js.map +1 -1
  11. package/lib/cluster/client.cjs +146 -0
  12. package/lib/cluster/client.d.ts +44 -0
  13. package/lib/cluster/client.mjs +141 -0
  14. package/lib/cluster/connection.cjs +382 -0
  15. package/lib/cluster/connection.d.ts +41 -0
  16. package/lib/cluster/connection.mjs +378 -0
  17. package/lib/cluster/errors.cjs +44 -0
  18. package/lib/cluster/errors.d.ts +23 -0
  19. package/lib/cluster/errors.mjs +32 -0
  20. package/lib/cluster/frames.cjs +49 -0
  21. package/lib/cluster/frames.d.ts +12 -0
  22. package/lib/cluster/frames.mjs +45 -0
  23. package/lib/cluster/generated/protocol-schema.cjs +5 -0
  24. package/lib/cluster/generated/protocol-schema.d.ts +1 -0
  25. package/lib/cluster/generated/protocol-schema.mjs +2 -0
  26. package/lib/cluster/generated/protocol.cjs +22 -0
  27. package/lib/cluster/generated/protocol.d.ts +781 -0
  28. package/lib/cluster/generated/protocol.mjs +19 -0
  29. package/lib/cluster/index.cjs +40 -0
  30. package/lib/cluster/index.d.ts +10 -0
  31. package/lib/cluster/index.mjs +6 -0
  32. package/lib/cluster/queue.cjs +71 -0
  33. package/lib/cluster/queue.d.ts +15 -0
  34. package/lib/cluster/queue.mjs +67 -0
  35. package/lib/cluster/socket.cjs +15 -0
  36. package/lib/cluster/socket.d.ts +11 -0
  37. package/lib/cluster/socket.mjs +12 -0
  38. package/lib/cluster/subscriptions.cjs +270 -0
  39. package/lib/cluster/subscriptions.d.ts +69 -0
  40. package/lib/cluster/subscriptions.mjs +264 -0
  41. package/lib/cluster/validators.cjs +46 -0
  42. package/lib/cluster/validators.d.ts +3 -0
  43. package/lib/cluster/validators.mjs +42 -0
  44. package/lib/settings.js +195 -23
  45. package/lib/setup-apply.js +45 -32
  46. package/lib/setup-undo.js +25 -16
  47. package/package.json +25 -3
  48. package/scripts/build-cluster.js +43 -0
  49. package/scripts/generate-cluster-types.js +203 -0
  50. package/scripts/release-dry-run.js +6 -6
  51. package/scripts/rust-distribution.js +933 -0
  52. package/src/agent/agent-hook-executor.js +14 -6
  53. package/src/agent/agent-lifecycle.js +25 -8
  54. package/src/agent/agent-task-executor.js +711 -139
  55. package/src/agent/output-reformatter.js +54 -41
  56. package/src/agent/pr-verification.js +11 -3
  57. package/src/agent/task-execution-handle.js +373 -0
  58. package/src/agent-cli-provider/adapters/opencode.ts +3 -0
  59. package/src/agent-cli-provider/types.ts +1 -0
  60. package/src/agent-wrapper.js +5 -2
  61. package/src/cluster/client.ts +199 -0
  62. package/src/cluster/connection.ts +300 -0
  63. package/src/cluster/errors.ts +32 -0
  64. package/src/cluster/frames.ts +44 -0
  65. package/src/cluster/generated/protocol-schema.ts +2 -0
  66. package/src/cluster/generated/protocol.ts +183 -0
  67. package/src/cluster/index.ts +38 -0
  68. package/src/cluster/queue.ts +84 -0
  69. package/src/cluster/socket.ts +31 -0
  70. package/src/cluster/subscriptions.ts +256 -0
  71. package/src/cluster/validators.ts +56 -0
  72. package/src/cluster/ws.d.ts +7 -0
  73. package/src/isolation-manager.js +16 -0
  74. package/src/issue-providers/jira-provider.js +1 -1
  75. package/src/issue-providers/linear-provider.js +4 -4
  76. package/task-lib/runner.js +3 -0
@@ -0,0 +1,199 @@
1
+ import { PROTOCOL_VERSION } from './generated/protocol.js';
2
+ import type {
3
+ AgentAttachParams, AgentAttachResult, ApplyParams, ApplyResult, DeleteParams, DeleteResult,
4
+ GetParams, GetResult, InitializeParams, InitializeResult, LogsParams, LogsResult,
5
+ PlanParams, PlanResult, ResubmitParams, ResubmitResult, RetryParams, RetryResult,
6
+ StopParams, StopResult, UpdateParams, UpdateResult, WatchParams, WatchResult,
7
+ } from './generated/protocol.js';
8
+ import { Connection } from './connection.js';
9
+ import type { CallOptions } from './connection.js';
10
+ import { addSocketListener } from './socket.js';
11
+ import type { WebSocketLike } from './socket.js';
12
+ import { ClusterConfigError, ClusterProtocolError, ClusterTransportError } from './errors.js';
13
+ import {
14
+ AgentAttachSubscriptionStream,
15
+ LogsSubscriptionStream,
16
+ WatchSubscriptionStream,
17
+ } from './subscriptions.js';
18
+
19
+ export interface ConnectOptions {
20
+ readonly protocols?: string | readonly string[];
21
+ readonly webSocketFactory?: (url: string, protocols?: string | readonly string[]) => WebSocketLike | Promise<WebSocketLike>;
22
+ readonly signal?: AbortSignal;
23
+ readonly initialize?: InitializeParams;
24
+ }
25
+ export interface WatchSubscription { readonly result: WatchResult; readonly stream: WatchSubscriptionStream; }
26
+ export interface LogsSubscription { readonly result: LogsResult; readonly stream: LogsSubscriptionStream; }
27
+ export interface AgentAttachSubscription { readonly result: AgentAttachResult; readonly stream: AgentAttachSubscriptionStream; }
28
+ export interface CoherentWatchSubscription extends WatchSubscription { readonly snapshot: GetResult; }
29
+
30
+ export class ClusterClient {
31
+ constructor(readonly connection: Connection) {}
32
+ async initialize(
33
+ params: InitializeParams = { protocolVersion: PROTOCOL_VERSION },
34
+ options?: CallOptions,
35
+ ): Promise<InitializeResult> {
36
+ const result = await this.connection.call('initialize', params, options);
37
+ if (result.protocolVersion !== params.protocolVersion || result.protocolVersion !== PROTOCOL_VERSION) {
38
+ throw new ClusterProtocolError(
39
+ `protocol version mismatch: requested ${params.protocolVersion}, received ${result.protocolVersion}`,
40
+ 'UNSUPPORTED_PROTOCOL_VERSION',
41
+ );
42
+ }
43
+ return result;
44
+ }
45
+ plan(params: PlanParams, options?: CallOptions): Promise<PlanResult> {
46
+ return this.connection.call('plan', params, options);
47
+ }
48
+ apply(params: ApplyParams, options?: CallOptions): Promise<ApplyResult> {
49
+ return this.connection.call('apply', params, options);
50
+ }
51
+ update(params: UpdateParams, options?: CallOptions): Promise<UpdateResult> {
52
+ return this.connection.call('update', params, options);
53
+ }
54
+ stop(params: StopParams, options?: CallOptions): Promise<StopResult> {
55
+ return this.connection.call('stop', params, options);
56
+ }
57
+ retry(params: RetryParams, options?: CallOptions): Promise<RetryResult> {
58
+ return this.connection.call('retry', params, options);
59
+ }
60
+ resubmit(params: ResubmitParams, options?: CallOptions): Promise<ResubmitResult> {
61
+ return this.connection.call('resubmit', params, options);
62
+ }
63
+ delete(params: DeleteParams, options?: CallOptions): Promise<DeleteResult> {
64
+ return this.connection.call('delete', params, options);
65
+ }
66
+ get(params: GetParams = {}, options?: CallOptions): Promise<GetResult> {
67
+ return this.connection.call('get', params, options);
68
+ }
69
+ async watch(
70
+ params: WatchParams = {},
71
+ options?: CallOptions,
72
+ ): Promise<WatchSubscription> {
73
+ const established = await this.connection.openSubscription('watch', params, options);
74
+ return {
75
+ result: established.result,
76
+ stream: new WatchSubscriptionStream({
77
+ connection: this.connection,
78
+ registration: established.registration,
79
+ result: established.result,
80
+ params,
81
+ }),
82
+ };
83
+ }
84
+ async watchColdStart(
85
+ params: Omit<WatchParams, 'fromCursor'> = {},
86
+ options?: CallOptions,
87
+ ): Promise<CoherentWatchSubscription> {
88
+ const snapshot = await this.get({}, options);
89
+ const watch = await this.watch({
90
+ ...params,
91
+ ...(snapshot.atCursor === undefined ? {} : { fromCursor: snapshot.atCursor }),
92
+ }, options);
93
+ return { snapshot, ...watch };
94
+ }
95
+ async logs(params: LogsParams = {}, options?: CallOptions): Promise<LogsSubscription> {
96
+ const established = await this.connection.openSubscription('logs', params, options);
97
+ return {
98
+ result: established.result,
99
+ stream: new LogsSubscriptionStream(this.connection, established.registration),
100
+ };
101
+ }
102
+ async agentAttach(
103
+ params: AgentAttachParams,
104
+ options?: CallOptions,
105
+ ): Promise<AgentAttachSubscription> {
106
+ const established = await this.connection.openSubscription('agent/attach', params, options);
107
+ return {
108
+ result: established.result,
109
+ stream: new AgentAttachSubscriptionStream(this.connection, established.registration),
110
+ };
111
+ }
112
+ }
113
+
114
+ async function defaultWebSocketFactory(
115
+ url: string,
116
+ protocols?: string | readonly string[],
117
+ ): Promise<WebSocketLike> {
118
+ const globalWebSocket = (globalThis as {
119
+ readonly WebSocket?: new (
120
+ url: string,
121
+ protocols?: string | readonly string[],
122
+ ) => WebSocketLike;
123
+ }).WebSocket;
124
+ if (globalWebSocket) return new globalWebSocket(url, protocols);
125
+ try {
126
+ // Dynamic loading keeps the optional Node runtime off the browser/global-WebSocket path.
127
+ const imported: unknown = await import('ws');
128
+ const candidate = imported !== null && typeof imported === 'object' && 'default' in imported
129
+ ? imported.default
130
+ : imported;
131
+ if (typeof candidate !== 'function') {
132
+ throw new TypeError("The installed 'ws' module does not export a WebSocket constructor");
133
+ }
134
+ const Constructor = candidate as new (
135
+ url: string,
136
+ protocols?: string | readonly string[],
137
+ ) => WebSocketLike;
138
+ return new Constructor(url, protocols);
139
+ } catch (cause) {
140
+ throw new ClusterConfigError(
141
+ "No WebSocket runtime is available; install 'ws' or pass webSocketFactory",
142
+ 'WEBSOCKET_UNAVAILABLE',
143
+ { cause },
144
+ );
145
+ }
146
+ }
147
+
148
+ async function waitForOpen(socket: WebSocketLike, signal?: AbortSignal): Promise<void> {
149
+ if (socket.readyState === 1) return;
150
+ if (socket.readyState !== 0) {
151
+ throw new ClusterTransportError('WebSocket is already closing or closed', 'OPEN_FAILED');
152
+ }
153
+ if (signal?.aborted) throw new DOMException(
154
+ 'connect aborted locally; the server may still have committed this request',
155
+ 'AbortError',
156
+ );
157
+ await new Promise<void>((resolve, reject) => {
158
+ let settled = false;
159
+ const removers: Array<() => void> = [];
160
+ const settle = (fn: () => void) => {
161
+ if (settled) return;
162
+ settled = true;
163
+ for (const remove of removers) remove();
164
+ signal?.removeEventListener('abort', onAbort);
165
+ fn();
166
+ };
167
+ const onAbort = () => settle(() => reject(new DOMException(
168
+ 'connect aborted locally; the server may still have committed this request',
169
+ 'AbortError',
170
+ )));
171
+ removers.push(
172
+ addSocketListener(socket, 'open', () => settle(resolve)),
173
+ addSocketListener(socket, 'error', (error) => settle(() => reject(new ClusterTransportError('WebSocket failed to open', 'OPEN_FAILED', { cause: error })))),
174
+ addSocketListener(socket, 'close', () => settle(() => reject(new ClusterTransportError('WebSocket closed before opening', 'OPEN_FAILED')))),
175
+ );
176
+ signal?.addEventListener('abort', onAbort, { once: true });
177
+ });
178
+ }
179
+
180
+ export async function connect(url: string, options: ConnectOptions = {}): Promise<Connection> {
181
+ const factory = options.webSocketFactory ?? defaultWebSocketFactory;
182
+ let socket: WebSocketLike | undefined;
183
+ try {
184
+ socket = await factory(url, options.protocols);
185
+ await waitForOpen(socket, options.signal);
186
+ const connection = new Connection(socket);
187
+ await new ClusterClient(connection).initialize(
188
+ options.initialize,
189
+ options.signal === undefined ? {} : { signal: options.signal },
190
+ );
191
+ return connection;
192
+ } catch (error) {
193
+ if (socket) {
194
+ try { await socket.close(); }
195
+ catch { /* preserve the construction error */ }
196
+ }
197
+ throw error;
198
+ }
199
+ }
@@ -0,0 +1,300 @@
1
+ import { JSON_RPC_VERSION, MAX_FRAME_BYTES, SUBSCRIPTION_METHODS, UNARY_METHODS } from './generated/protocol.js';
2
+ import type {
3
+ ClusterMethod, ClusterMethodParams, ClusterMethodResults, DomainErrorData,
4
+ SubscriptionMethod, UnaryClusterMethod,
5
+ } from './generated/protocol.js';
6
+ import {
7
+ ClusterConfigError, ClusterInternalError, ClusterProtocolError, ClusterRpcError,
8
+ ClusterStateError, ClusterTimeoutError, ClusterTransportError, requestAbortError,
9
+ } from './errors.js';
10
+ import { boundedFrameText, isRecord } from './frames.js';
11
+ import type { FrameRecord } from './frames.js';
12
+ import { BoundedQueue } from './queue.js';
13
+ import { addSocketListener } from './socket.js';
14
+ import type { WebSocketLike } from './socket.js';
15
+ import { assertDefinition, assertMethodResult } from './validators.js';
16
+ if (!Object.prototype.hasOwnProperty.call(Symbol, 'asyncDispose')) {
17
+ Object.defineProperty(Symbol, 'asyncDispose', {
18
+ configurable: false, enumerable: false,
19
+ value: Symbol.for('Symbol.asyncDispose'), writable: false,
20
+ });
21
+ }
22
+
23
+ export type ConnectionState = 'OPEN' | 'CLOSING' | 'CLOSED';
24
+ export const CONNECTION_TRANSITIONS: Readonly<Record<ConnectionState, readonly ConnectionState[]>> = Object.freeze({
25
+ OPEN: Object.freeze(['CLOSING'] as const),
26
+ CLOSING: Object.freeze(['CLOSED'] as const),
27
+ CLOSED: Object.freeze([] as const),
28
+ });
29
+ export const PROTOCOL_DIAGNOSTIC_CAPACITY = 128;
30
+ export interface CallOptions { readonly signal?: AbortSignal; readonly requestTimeoutMs?: number; }
31
+
32
+ type Deferred<T> = {
33
+ readonly promise: Promise<T>;
34
+ readonly resolve: (value: T) => void;
35
+ readonly reject: (reason: unknown) => void;
36
+ };
37
+ function deferred<T>(): Deferred<T> {
38
+ let resolve!: (value: T) => void; let reject!: (reason: unknown) => void;
39
+ const promise = new Promise<T>((onResolve, onReject) => {
40
+ resolve = onResolve; reject = onReject;
41
+ });
42
+ return { promise, resolve, reject };
43
+ }
44
+ export type SubscriptionKind = 'watch' | 'logs' | 'agent/attach';
45
+ export type SubscriptionRegistration = {
46
+ readonly id: string;
47
+ readonly kind: SubscriptionKind;
48
+ readonly queue: BoundedQueue<FrameRecord>;
49
+ overflowed: boolean;
50
+ cancelSent: boolean;
51
+ abortHandler?: () => void;
52
+ abortSignal?: AbortSignal;
53
+ };
54
+ export type EstablishedSubscription<R> = {
55
+ readonly result: R;
56
+ readonly registration: SubscriptionRegistration;
57
+ };
58
+ type PendingEntry = {
59
+ readonly id: string; readonly method: ClusterMethod; readonly expectedId: string;
60
+ readonly resolve: (value: unknown) => void; readonly reject: (reason: unknown) => void;
61
+ readonly subscriptionKind?: SubscriptionKind; settled: boolean;
62
+ abortHandler?: () => void; signal?: AbortSignal; timeout?: ReturnType<typeof setTimeout>;
63
+ };
64
+
65
+ export class Connection {
66
+ #state: ConnectionState = 'OPEN'; #sequence = 1;
67
+ readonly #pending = new Map<string, PendingEntry>();
68
+ readonly #subscriptions = new Map<string, SubscriptionRegistration>();
69
+ readonly #lateSubscriptionReapers = new Set<string>();
70
+ readonly #removeSocketListeners: Array<() => void> = [];
71
+ readonly #ownedSubscriptions = new WeakSet<SubscriptionRegistration>();
72
+ #closePromise?: Promise<void>;
73
+ readonly closeDiagnostics: unknown[] = [];
74
+ readonly protocolDiagnostics: ClusterProtocolError[] = [];
75
+
76
+ readonly #socket: WebSocketLike;
77
+ constructor(socket: WebSocketLike) {
78
+ if (socket.readyState !== 1) throw new ClusterStateError('Connection requires an already-open WebSocket', 'SOCKET_NOT_OPEN');
79
+ this.#socket = socket;
80
+ this.#removeSocketListeners.push(
81
+ addSocketListener(socket, 'message', (event) => this.#onMessage(event)),
82
+ addSocketListener(socket, 'error', () => { void this.#startClose(false); }),
83
+ addSocketListener(socket, 'close', () => { void this.#startClose(false); }),
84
+ );
85
+ }
86
+ get state(): ConnectionState { return this.#state; }
87
+ get pendingSize(): number { return this.#pending.size; }
88
+ get subscriptionCount(): number { return this.#subscriptions.size; }
89
+ call<M extends UnaryClusterMethod>(method: M, params: ClusterMethodParams[M], options: CallOptions = {}): Promise<ClusterMethodResults[M]> {
90
+ if (!(UNARY_METHODS as readonly string[]).includes(method)) {
91
+ throw new ClusterConfigError(`${method} is a subscription method`, 'INVALID_METHOD');
92
+ }
93
+ return this.#dispatch(method, params, options) as Promise<ClusterMethodResults[M]>;
94
+ }
95
+ cancelSubscription(registration: SubscriptionRegistration): Promise<void> {
96
+ if (!this.#ownedSubscriptions.has(registration)) {
97
+ return Promise.reject(new ClusterStateError('subscription is not owned by this connection', 'UNOWNED_SUBSCRIPTION'));
98
+ }
99
+ if (registration.cancelSent) return Promise.resolve();
100
+ registration.cancelSent = true;
101
+ return this.#sendNotification('subscription/cancel', { subscriptionId: registration.id });
102
+ }
103
+ openSubscription<M extends SubscriptionMethod>(method: M, params: ClusterMethodParams[M], options: CallOptions = {}): Promise<EstablishedSubscription<ClusterMethodResults[M]>> {
104
+ if (!(SUBSCRIPTION_METHODS as readonly string[]).includes(method)) {
105
+ throw new ClusterConfigError(`${method} is not a subscription method`, 'INVALID_METHOD');
106
+ }
107
+ return this.#dispatch(method, params, options, method) as Promise<EstablishedSubscription<ClusterMethodResults[M]>>;
108
+ }
109
+ unregisterSubscription(id: string, registration: SubscriptionRegistration): void {
110
+ if (this.#subscriptions.get(id) !== registration) return;
111
+ this.#subscriptions.delete(id);
112
+ this.#disarmSubscriptionAbort(registration);
113
+ }
114
+ recordDiagnostic(error: unknown): void { this.closeDiagnostics.push(error); }
115
+ close(): Promise<void> { return this.#startClose(true); }
116
+
117
+ #dispatch(method: ClusterMethod, params: unknown, options: CallOptions, subscriptionKind?: SubscriptionKind): Promise<unknown> {
118
+ this.#requireOpen();
119
+ if (options.signal?.aborted) return Promise.reject(requestAbortError(method));
120
+ if (options.requestTimeoutMs !== undefined && options.requestTimeoutMs < 0) return Promise.reject(new ClusterConfigError('requestTimeoutMs must be non-negative', 'INVALID_TIMEOUT'));
121
+ const id = this.#allocateId();
122
+ const result = deferred<unknown>();
123
+ const entry: PendingEntry = { id, method, expectedId: id, resolve: result.resolve, reject: result.reject, settled: false, ...(subscriptionKind === undefined ? {} : { subscriptionKind }) };
124
+ this.#pending.set(id, entry);
125
+ if (options.signal) {
126
+ const onAbort = () => {
127
+ if (!this.#removeExact(id, entry)) return; this.#settleEntry(entry);
128
+ if (entry.subscriptionKind) this.#lateSubscriptionReapers.add(id);
129
+ entry.reject(requestAbortError(method));
130
+ void this.#sendNotification('$/cancelRequest', { id }).catch((error: unknown) => this.recordDiagnostic(error));
131
+ };
132
+ entry.signal = options.signal; entry.abortHandler = onAbort; options.signal.addEventListener('abort', onAbort, { once: true });
133
+ }
134
+ if (options.requestTimeoutMs !== undefined) {
135
+ entry.timeout = setTimeout(() => {
136
+ if (!this.#removeExact(id, entry)) return; this.#settleEntry(entry);
137
+ if (entry.subscriptionKind) this.#lateSubscriptionReapers.add(id);
138
+ entry.reject(new ClusterTimeoutError(`${method} request timed out`, 'REQUEST_TIMEOUT'));
139
+ void this.#sendNotification('$/cancelRequest', { id }).catch((error: unknown) => this.recordDiagnostic(error));
140
+ }, options.requestTimeoutMs);
141
+ }
142
+ const request = { jsonrpc: JSON_RPC_VERSION, id, method, params };
143
+ void this.#sendFrame(request).catch((cause: unknown) => {
144
+ if (!this.#removeExact(id, entry)) return; this.#settleEntry(entry);
145
+ entry.reject(new ClusterTransportError(`failed to send ${method}`, 'SEND_FAILED', { cause }));
146
+ });
147
+ return result.promise;
148
+ }
149
+ #allocateId(): string { return `z${this.#sequence++}`; }
150
+ #removeExact(id: string, entry: PendingEntry): boolean { if (this.#pending.get(id) !== entry) return false; this.#pending.delete(id); return true; }
151
+ #settleEntry(entry: PendingEntry): void {
152
+ if (entry.settled) return; entry.settled = true;
153
+ if (entry.timeout !== undefined) clearTimeout(entry.timeout);
154
+ if (entry.signal && entry.abortHandler) entry.signal.removeEventListener('abort', entry.abortHandler);
155
+ }
156
+ #requireOpen(): void { if (this.#state !== 'OPEN') throw new ClusterStateError(`connection is ${this.#state.toLowerCase()}`, `CONNECTION_${this.#state}`); }
157
+ async #sendFrame(value: unknown, allowClosing = false): Promise<void> {
158
+ if (this.#state === 'CLOSED' || (!allowClosing && this.#state !== 'OPEN')) throw new ClusterStateError(`connection is ${this.#state.toLowerCase()}`, `CONNECTION_${this.#state}`);
159
+ const payload = JSON.stringify(value);
160
+ if (this.#socket.send.length >= 2) {
161
+ await new Promise<void>((resolve, reject) => { try { this.#socket.send(payload, (error?: Error) => error ? reject(error) : resolve()); } catch (error) { reject(error); } });
162
+ return;
163
+ }
164
+ const sent = this.#socket.send(payload); if (sent && typeof (sent as Promise<void>).then === 'function') await sent;
165
+ }
166
+ #sendNotification(method: '$/cancelRequest' | 'subscription/cancel', params: FrameRecord): Promise<void> {
167
+ return this.#sendFrame({ jsonrpc: JSON_RPC_VERSION, method, params }, true);
168
+ }
169
+ #onMessage(event: unknown): void {
170
+ if (this.#state !== 'OPEN') return;
171
+ const inbound = boundedFrameText(event, MAX_FRAME_BYTES);
172
+ if (inbound.kind === 'unsupported') { this.#recordProtocolError('WebSocket message is not text or bytes'); return; }
173
+ if (inbound.kind === 'oversized') { this.#recordProtocolError(`frame exceeds ${MAX_FRAME_BYTES} bytes`); return; }
174
+ const { text, bytes } = inbound;
175
+ let frame: unknown; try { frame = JSON.parse(text); } catch (cause) { this.#recordProtocolError('invalid JSON frame', cause); return; }
176
+ if (!isRecord(frame)) { this.#recordProtocolError('invalid JSON-RPC frame'); return; }
177
+ if ('id' in frame) { this.#routeResponse(frame); return; }
178
+ if (frame.jsonrpc !== JSON_RPC_VERSION) { this.#recordProtocolError('invalid JSON-RPC frame'); return; }
179
+ if (typeof frame.method === 'string' && isRecord(frame.params)) { this.#routeNotification(frame.method, frame.params, frame, bytes); return; }
180
+ this.#recordProtocolError('unrecognized JSON-RPC frame');
181
+ }
182
+ #routeResponse(frame: FrameRecord): void {
183
+ if (typeof frame.id !== 'string') {
184
+ this.#recordProtocolError('response id is not a string'); return;
185
+ }
186
+ const entry = this.#pending.get(frame.id);
187
+ if (!entry) { this.#reapLateSubscription(frame.id, frame.result); return; }
188
+ this.#removeExact(frame.id, entry); this.#settleEntry(entry);
189
+ if (frame.id !== entry.expectedId || frame.jsonrpc !== JSON_RPC_VERSION) {
190
+ entry.reject(new ClusterProtocolError('response identity mismatch', 'INVALID_RESPONSE_IDENTITY')); return;
191
+ }
192
+ if (isRecord(frame.error)) { this.#routeRpcError(entry, frame.error); return; }
193
+ this.#routeSuccess(entry, frame);
194
+ }
195
+ #reapLateSubscription(id: string, result: unknown): void {
196
+ if (!this.#lateSubscriptionReapers.delete(id) || !isRecord(result)) return;
197
+ if (typeof result.subscriptionId !== 'string') return;
198
+ void this.#sendNotification('subscription/cancel', { subscriptionId: result.subscriptionId })
199
+ .catch((error: unknown) => this.recordDiagnostic(error));
200
+ }
201
+ #routeRpcError(entry: PendingEntry, error: FrameRecord): void {
202
+ try { assertDefinition('JsonRpcError', error); }
203
+ catch (cause) { entry.reject(cause); return; }
204
+ const data = isRecord(error.data) ? error.data as DomainErrorData : undefined;
205
+ entry.reject(new ClusterRpcError(error.code as number, error.message as string, data));
206
+ }
207
+ #routeSuccess(entry: PendingEntry, frame: FrameRecord): void {
208
+ if (!('result' in frame)) {
209
+ entry.reject(new ClusterProtocolError('response has neither result nor error', 'INVALID_RESPONSE')); return;
210
+ }
211
+ try { assertMethodResult(entry.method, frame.result); }
212
+ catch (error) {
213
+ if (entry.subscriptionKind && isRecord(frame.result) && typeof frame.result.subscriptionId === 'string') {
214
+ void this.#sendNotification('subscription/cancel', { subscriptionId: frame.result.subscriptionId })
215
+ .catch((cause: unknown) => this.recordDiagnostic(cause));
216
+ }
217
+ entry.reject(error); return;
218
+ }
219
+ if (!entry.subscriptionKind) { entry.resolve(frame.result); return; }
220
+ const result = frame.result as ClusterMethodResults[SubscriptionMethod];
221
+ const registration: SubscriptionRegistration = {
222
+ id: result.subscriptionId, kind: entry.subscriptionKind,
223
+ queue: new BoundedQueue<FrameRecord>(), overflowed: false, cancelSent: false,
224
+ };
225
+ if (this.#subscriptions.has(registration.id)) {
226
+ entry.reject(new ClusterProtocolError('duplicate subscriptionId', 'DUPLICATE_SUBSCRIPTION')); return;
227
+ }
228
+ this.#ownedSubscriptions.add(registration);
229
+ this.#subscriptions.set(registration.id, registration);
230
+ this.#armSubscriptionAbort(registration, entry.signal);
231
+ entry.resolve({ result, registration });
232
+ }
233
+ #armSubscriptionAbort(registration: SubscriptionRegistration, signal: AbortSignal | undefined): void {
234
+ if (!signal) return;
235
+ const onAbort = () => {
236
+ if (this.#subscriptions.get(registration.id) !== registration) return;
237
+ this.unregisterSubscription(registration.id, registration);
238
+ registration.queue.closeAndDiscard();
239
+ void this.cancelSubscription(registration).catch((error: unknown) => this.recordDiagnostic(error));
240
+ };
241
+ registration.abortSignal = signal;
242
+ registration.abortHandler = onAbort;
243
+ signal.addEventListener('abort', onAbort, { once: true });
244
+ if (signal.aborted) onAbort();
245
+ }
246
+ #disarmSubscriptionAbort(registration: SubscriptionRegistration): void {
247
+ if (registration.abortSignal && registration.abortHandler) {
248
+ registration.abortSignal.removeEventListener('abort', registration.abortHandler);
249
+ }
250
+ delete registration.abortSignal;
251
+ delete registration.abortHandler;
252
+ }
253
+ #routeNotification(method: string, params: FrameRecord, frame: FrameRecord, bytes: number): void {
254
+ const subscriptionId = params.subscriptionId;
255
+ if (typeof subscriptionId !== 'string') { this.#recordProtocolError('subscription notification has no subscriptionId'); return; }
256
+ const registration = this.#subscriptions.get(subscriptionId); if (!registration) return;
257
+ const terminal = method === 'subscription/closed'; const outcome = registration.queue.push(frame, bytes);
258
+ if (outcome === 'overflow') {
259
+ registration.overflowed = true; this.unregisterSubscription(subscriptionId, registration); registration.queue.endRetainingBuffer();
260
+ if (!terminal) void this.cancelSubscription(registration).catch((error: unknown) => this.recordDiagnostic(error));
261
+ return;
262
+ }
263
+ if (terminal) { this.unregisterSubscription(subscriptionId, registration); registration.queue.endRetainingBuffer(); }
264
+ }
265
+ #recordProtocolError(message: string, cause?: unknown): void {
266
+ if (this.protocolDiagnostics.length === PROTOCOL_DIAGNOSTIC_CAPACITY) this.protocolDiagnostics.shift();
267
+ this.protocolDiagnostics.push(new ClusterProtocolError(message, 'INVALID_PEER_FRAME', cause === undefined ? undefined : { cause }));
268
+ }
269
+ #startClose(sendCancels: boolean): Promise<void> {
270
+ if (this.#closePromise) return this.#closePromise; if (this.#state === 'CLOSED') return Promise.resolve();
271
+ this.#transition('CLOSING'); this.#closePromise = Promise.resolve().then(() => this.#finishClose(sendCancels)); return this.#closePromise;
272
+ }
273
+ async #finishClose(sendCancels: boolean): Promise<void> {
274
+ const subscriptions = [...this.#subscriptions.values()]; this.#subscriptions.clear();
275
+ for (const subscription of subscriptions) {
276
+ this.#disarmSubscriptionAbort(subscription);
277
+ subscription.queue.closeAndDiscard();
278
+ }
279
+ const pending = [...this.#pending.values()]; this.#pending.clear();
280
+ for (const entry of pending) { this.#settleEntry(entry); entry.reject(new ClusterTransportError('connection closed', 'CONNECTION_CLOSED')); }
281
+ this.#lateSubscriptionReapers.clear();
282
+ if (sendCancels) for (const subscription of subscriptions) {
283
+ try { await this.cancelSubscription(subscription); }
284
+ catch (error) { this.recordDiagnostic(error); }
285
+ }
286
+ try { await this.#socket.close(); } catch (error) { this.recordDiagnostic(error); }
287
+ for (const remove of this.#removeSocketListeners.splice(0)) {
288
+ try { remove(); } catch (error) { this.recordDiagnostic(error); }
289
+ }
290
+ this.#transition('CLOSED');
291
+ }
292
+ #transition(to: ConnectionState): void {
293
+ if (!CONNECTION_TRANSITIONS[this.#state].includes(to)) throw new ClusterInternalError(`illegal connection transition ${this.#state} -> ${to}`, 'ILLEGAL_STATE_TRANSITION');
294
+ this.#state = to;
295
+ }
296
+ }
297
+
298
+ Object.defineProperty(Connection.prototype, Symbol.asyncDispose, {
299
+ configurable: true, value: Connection.prototype.close,
300
+ });
@@ -0,0 +1,32 @@
1
+ import type { DomainErrorData } from './generated/protocol.js';
2
+
3
+ export class ClusterError extends Error {
4
+ constructor(message: string, readonly code: string, options?: ErrorOptions) {
5
+ super(message, options);
6
+ this.name = new.target.name;
7
+ }
8
+ }
9
+
10
+ export class ClusterConfigError extends ClusterError {}
11
+ export class ClusterStateError extends ClusterError {}
12
+ export class ClusterInternalError extends ClusterError {}
13
+ export class ClusterProtocolError extends ClusterError {}
14
+ export class ClusterTransportError extends ClusterError {}
15
+ export class ClusterTimeoutError extends ClusterError {}
16
+
17
+ export function requestAbortError(method: string): DOMException {
18
+ return new DOMException(
19
+ `${method} aborted locally; the server may still have committed this request`,
20
+ 'AbortError',
21
+ );
22
+ }
23
+
24
+ export class ClusterRpcError extends ClusterError {
25
+ constructor(
26
+ readonly rpcCode: number,
27
+ message: string,
28
+ readonly data?: DomainErrorData,
29
+ ) {
30
+ super(message, data?.code ?? String(rpcCode));
31
+ }
32
+ }
@@ -0,0 +1,44 @@
1
+ export type FrameRecord = Readonly<Record<string, unknown>>;
2
+
3
+ export type BoundedFrameText =
4
+ | { readonly kind: 'frame'; readonly text: string; readonly bytes: number }
5
+ | { readonly kind: 'oversized' }
6
+ | { readonly kind: 'unsupported' };
7
+
8
+ function boundedUtf8Length(value: string, maximum: number): number | undefined {
9
+ let bytes = 0;
10
+ for (let index = 0; index < value.length; index += 1) {
11
+ const codePoint = value.codePointAt(index);
12
+ if (codePoint === undefined) break;
13
+ if (codePoint <= 0x7f) bytes += 1;
14
+ else if (codePoint <= 0x7ff) bytes += 2;
15
+ else if (codePoint <= 0xffff) bytes += 3;
16
+ else { bytes += 4; index += 1; }
17
+ if (bytes > maximum) return undefined;
18
+ }
19
+ return bytes;
20
+ }
21
+
22
+ export function boundedFrameText(event: unknown, maximum: number): BoundedFrameText {
23
+ const candidate = event && typeof event === 'object' && 'data' in event
24
+ ? event.data
25
+ : event;
26
+ if (typeof candidate === 'string') {
27
+ const bytes = boundedUtf8Length(candidate, maximum);
28
+ return bytes === undefined ? { kind: 'oversized' } : { kind: 'frame', text: candidate, bytes };
29
+ }
30
+ if (candidate instanceof ArrayBuffer) {
31
+ if (candidate.byteLength > maximum) return { kind: 'oversized' };
32
+ return { kind: 'frame', text: new TextDecoder().decode(candidate), bytes: candidate.byteLength };
33
+ }
34
+ if (ArrayBuffer.isView(candidate)) {
35
+ if (candidate.byteLength > maximum) return { kind: 'oversized' };
36
+ const view = new Uint8Array(candidate.buffer, candidate.byteOffset, candidate.byteLength);
37
+ return { kind: 'frame', text: new TextDecoder().decode(view), bytes: candidate.byteLength };
38
+ }
39
+ return { kind: 'unsupported' };
40
+ }
41
+
42
+ export function isRecord(value: unknown): value is FrameRecord {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
44
+ }