@anhlp/alp-client 0.0.0-stage → 0.0.1-beta.4
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 +202 -0
- package/NOTICE +7 -0
- package/README.md +28 -2
- package/dist/acp-probe.d.ts +44 -0
- package/dist/acp-probe.js +37 -0
- package/dist/antigravity-probe.d.ts +34 -0
- package/dist/antigravity-probe.js +95 -0
- package/dist/command.d.ts +5 -0
- package/dist/command.js +33 -0
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.js +587 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.js +533 -0
- package/dist/integration-lock.d.ts +35 -0
- package/dist/integration-lock.js +179 -0
- package/dist/ipc.d.ts +42 -0
- package/dist/ipc.js +69 -0
- package/dist/library-test.d.ts +94 -0
- package/dist/library-test.js +37 -0
- package/dist/mcp-probe.d.ts +24 -0
- package/dist/mcp-probe.js +147 -0
- package/dist/portable.d.ts +19 -0
- package/dist/portable.js +33 -0
- package/dist/prompt.d.ts +32 -0
- package/dist/prompt.js +59 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.js +120 -0
- package/dist/rpc/client.d.ts +82 -0
- package/dist/rpc/client.js +292 -0
- package/dist/rpc/errors.d.ts +42 -0
- package/dist/rpc/errors.js +34 -0
- package/dist/rpc/framing.d.ts +21 -0
- package/dist/rpc/framing.js +57 -0
- package/dist/rpc/handshake.d.ts +19 -0
- package/dist/rpc/handshake.js +8 -0
- package/dist/rpc/reconnect.d.ts +97 -0
- package/dist/rpc/reconnect.js +227 -0
- package/dist/rpc/registry.d.ts +211 -0
- package/dist/rpc/registry.js +145 -0
- package/dist/rpc/timers.d.ts +11 -0
- package/dist/rpc/timers.js +11 -0
- package/dist/service.d.ts +50 -0
- package/dist/service.js +143 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +146 -0
- package/dist/supervise.d.ts +14 -0
- package/dist/supervise.js +87 -0
- package/dist/websocket.d.ts +51 -0
- package/dist/websocket.js +190 -0
- package/package.json +174 -4
- package/src/acp-probe.js +46 -0
- package/src/antigravity-probe.js +106 -0
- package/src/command.js +18 -0
- package/src/doctor.d.ts +13 -0
- package/src/doctor.js +570 -0
- package/src/index.js +596 -0
- package/src/integration-lock.js +179 -0
- package/src/ipc.js +92 -0
- package/src/library-test.js +41 -0
- package/src/mcp-probe.js +118 -0
- package/src/portable.js +28 -0
- package/src/prompt.js +64 -0
- package/src/render.js +136 -0
- package/src/rpc/client.js +296 -0
- package/src/rpc/errors.js +54 -0
- package/src/rpc/framing.js +61 -0
- package/src/rpc/handshake.js +16 -0
- package/src/rpc/reconnect.js +226 -0
- package/src/rpc/registry.js +161 -0
- package/src/rpc/timers.js +16 -0
- package/src/service.js +154 -0
- package/src/setup.js +150 -0
- package/src/supervise.d.ts +14 -0
- package/src/supervise.js +92 -0
- package/src/websocket.js +259 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { AlpRpcError, MAX_FRAME } from '@anhlp/alp-protocol';
|
|
2
|
+
import { AlpClientError } from './errors.js';
|
|
3
|
+
import { utf8Length } from './framing.js';
|
|
4
|
+
import { timerDelay } from './timers.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The transport-independent JSON-RPC 2.0 engine of an ALP client (plans/reference/ALPD.md §3):
|
|
8
|
+
* request correlation, the daemon's `event` and other notifications, requests the daemon sends
|
|
9
|
+
* the client (extension.register), local timeouts and aborts, and cleanup when the connection
|
|
10
|
+
* ends. It runs unchanged in Node, browsers and React Native; a transport moves whole frames.
|
|
11
|
+
*
|
|
12
|
+
* A transport carries one complete JSON-RPC message per frame, as text. Framing (newlines on a
|
|
13
|
+
* socket, one WebSocket message per frame), byte decoding, connection setup and authentication
|
|
14
|
+
* belong to the transport and whoever creates it, never to this engine.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {'open' | 'closed'} TransportState
|
|
19
|
+
* @typedef {{ error?: Error, code?: number, reason?: string }} TransportClose
|
|
20
|
+
* Why a transport closed: `error` when it failed, `code`/`reason` when the peer said so.
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* readonly state: TransportState,
|
|
23
|
+
* send(frame: string): void,
|
|
24
|
+
* onMessage(listener: (frame: string) => void): () => void,
|
|
25
|
+
* onClose(listener: (close: TransportClose) => void): () => void,
|
|
26
|
+
* close(): void,
|
|
27
|
+
* }} AlpTransport
|
|
28
|
+
* Contract (docs/alp/client-sdk.md): `send` delivers or throws, synchronously, and a throw means
|
|
29
|
+
* the frame did not leave; `onMessage` delivers frames in arrival order; `onClose` fires at most
|
|
30
|
+
* once, also after `close()`; nothing is delivered after it; `close` is idempotent.
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* readonly aborted: boolean,
|
|
33
|
+
* readonly reason?: unknown,
|
|
34
|
+
* addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean }): void,
|
|
35
|
+
* removeEventListener(type: 'abort', listener: () => void): void,
|
|
36
|
+
* }} AbortSignalLike
|
|
37
|
+
* @typedef {{ signal?: AbortSignalLike, timeoutMs?: number }} RequestOptions
|
|
38
|
+
* Both end only this client's wait; neither tells the daemon to stop. A timeout of Infinity, or
|
|
39
|
+
* longer than a timer can wait (2^31-1 ms), means none.
|
|
40
|
+
* @typedef {{
|
|
41
|
+
* maxPending?: number,
|
|
42
|
+
* maxFrame?: number,
|
|
43
|
+
* timeoutMs?: number,
|
|
44
|
+
* onListenerError?: (error: unknown) => void,
|
|
45
|
+
* }} AlpRpcClientOptions
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const CLOSED = 'ALP daemon connection closed';
|
|
49
|
+
/** Requests waiting for an answer at once; beyond it a request fails as `overloaded` and is not sent. */
|
|
50
|
+
export const DEFAULT_MAX_PENDING = 4096;
|
|
51
|
+
|
|
52
|
+
/** A listener's exception must not break dispatch to the others; it surfaces like an uncaught error. @param {unknown} error */
|
|
53
|
+
const rethrow = error => { setTimeout(() => { throw error; }, 0); };
|
|
54
|
+
|
|
55
|
+
/** @param {AlpTransport} transport */
|
|
56
|
+
function assertTransport(transport) {
|
|
57
|
+
for (const name of ['send', 'onMessage', 'onClose', 'close']) {
|
|
58
|
+
if (typeof /** @type {any} */ (transport)?.[name] !== 'function') throw new TypeError(`ALP transport must implement ${name}()`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {AbortSignalLike} signal */
|
|
63
|
+
const abortError = signal => new AlpClientError('ALP request aborted', { reason: 'aborted', cause: signal.reason });
|
|
64
|
+
|
|
65
|
+
/** @param {object} value @param {string} key */
|
|
66
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
67
|
+
|
|
68
|
+
export class AlpRpcClient {
|
|
69
|
+
/** @type {AlpTransport} */ #transport;
|
|
70
|
+
#sequence = 0;
|
|
71
|
+
/** @type {Map<number, { resolve(value: unknown): void, reject(error: unknown): void, cleanup(): void }>} */
|
|
72
|
+
#pending = new Map();
|
|
73
|
+
/** @type {Set<(envelope: any) => void>} */ #events = new Set();
|
|
74
|
+
/** @type {Set<(method: string, params: any) => void>} */ #notifications = new Set();
|
|
75
|
+
/** @type {Set<(error: Error) => void>} */ #closes = new Set();
|
|
76
|
+
/** @type {Set<(method: string, params: any) => unknown>} */ #requestHandlers = new Set();
|
|
77
|
+
/** @type {Array<() => void>} */ #detach = [];
|
|
78
|
+
/** @type {AlpClientError | undefined} */ #closeError;
|
|
79
|
+
#maxPending;
|
|
80
|
+
#maxFrame;
|
|
81
|
+
#timeoutMs;
|
|
82
|
+
#onListenerError;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {AlpTransport} transport an open transport; the client owns it from now on
|
|
86
|
+
* @param {AlpRpcClientOptions} [options]
|
|
87
|
+
*/
|
|
88
|
+
constructor(transport, { maxPending = DEFAULT_MAX_PENDING, maxFrame = MAX_FRAME, timeoutMs, onListenerError = rethrow } = {}) {
|
|
89
|
+
assertTransport(transport);
|
|
90
|
+
this.#transport = transport;
|
|
91
|
+
this.#maxPending = maxPending;
|
|
92
|
+
this.#maxFrame = maxFrame;
|
|
93
|
+
timerDelay(timeoutMs);
|
|
94
|
+
this.#timeoutMs = timeoutMs;
|
|
95
|
+
this.#onListenerError = onListenerError;
|
|
96
|
+
this.#detach.push(transport.onMessage(frame => this.#receive(frame)));
|
|
97
|
+
this.#detach.push(transport.onClose(close => this.#finish(this.#disconnected(close))));
|
|
98
|
+
if (transport.state === 'closed') this.#finish(new AlpClientError(CLOSED, { reason: 'disconnected' }));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** True once the connection is gone, for whatever reason. */
|
|
102
|
+
get closed() { return this.#closeError !== undefined; }
|
|
103
|
+
/** Requests sent and still waiting for an answer. */
|
|
104
|
+
get pendingCount() { return this.#pending.size; }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Sends one request and resolves with its result, or rejects with AlpRpcError (the daemon
|
|
108
|
+
* answered with an error) or AlpClientError (the client gave up: see CLIENT_ERROR). Requests are
|
|
109
|
+
* never resent, also not after a reconnect.
|
|
110
|
+
* @template [T=any]
|
|
111
|
+
* @param {string} method
|
|
112
|
+
* @param {unknown} [params]
|
|
113
|
+
* @param {RequestOptions} [options]
|
|
114
|
+
* @returns {Promise<T>}
|
|
115
|
+
*/
|
|
116
|
+
request(method, params = {}, { signal, timeoutMs = this.#timeoutMs } = {}) {
|
|
117
|
+
if (this.#closeError) return Promise.reject(new AlpClientError(CLOSED, { reason: this.#closeError.reason === 'closed' ? 'closed' : 'disconnected', cause: this.#closeError }));
|
|
118
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
119
|
+
if (this.#pending.size >= this.#maxPending) return Promise.reject(new AlpClientError(`Too many ALP requests waiting for an answer (${this.#maxPending})`, { reason: 'overloaded' }));
|
|
120
|
+
/** @type {number | undefined} */
|
|
121
|
+
let delay;
|
|
122
|
+
try { delay = timerDelay(timeoutMs); } catch (error) { return Promise.reject(error); }
|
|
123
|
+
const id = ++this.#sequence;
|
|
124
|
+
let frame;
|
|
125
|
+
try { frame = JSON.stringify({ jsonrpc: '2.0', id, method, params }); } catch (error) { return Promise.reject(error); }
|
|
126
|
+
// The limit counts the newline a stream transport adds, as alpd counts it.
|
|
127
|
+
if (utf8Length(frame) >= this.#maxFrame) return Promise.reject(new AlpClientError(`ALP request ${method} exceeds the ${this.#maxFrame}-byte frame limit`, { reason: 'send_failed' }));
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
130
|
+
let timer;
|
|
131
|
+
const onAbort = () => { if (this.#pending.delete(id)) { cleanup(); reject(abortError(/** @type {AbortSignalLike} */ (signal))); } };
|
|
132
|
+
const cleanup = () => {
|
|
133
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
134
|
+
signal?.removeEventListener('abort', onAbort);
|
|
135
|
+
};
|
|
136
|
+
this.#pending.set(id, { resolve, reject, cleanup });
|
|
137
|
+
if (delay !== undefined) {
|
|
138
|
+
timer = setTimeout(() => {
|
|
139
|
+
if (!this.#pending.delete(id)) return;
|
|
140
|
+
cleanup();
|
|
141
|
+
reject(new AlpClientError(`ALP request ${method} timed out after ${timeoutMs} ms`, { reason: 'timeout' }));
|
|
142
|
+
}, delay);
|
|
143
|
+
}
|
|
144
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
145
|
+
try {
|
|
146
|
+
this.#transport.send(frame);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// A transport that is already gone (a socket half-closed by alpd, a WebSocket closing) ends
|
|
149
|
+
// the connection now, so `closed` is true when this request rejects, as it was before the
|
|
150
|
+
// transport reported its close. A transport still open refused only this frame.
|
|
151
|
+
if (this.#transport.state === 'closed') this.#finish(new AlpClientError(CLOSED, { reason: 'disconnected', cause: error }));
|
|
152
|
+
else if (this.#pending.delete(id)) {
|
|
153
|
+
cleanup();
|
|
154
|
+
reject(new AlpClientError(`Could not send ALP request ${method}: ${error instanceof Error ? error.message : String(error)}`, { reason: 'send_failed', cause: error }));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every `event` notification: an envelope of a session the connection attached to. Returns a remover. @param {(envelope: any) => void} listener */
|
|
161
|
+
onEvent(listener) { return this.#add(this.#events, listener); }
|
|
162
|
+
|
|
163
|
+
/** Every notification from the daemon, `event` included; for methods this client does not know yet. @param {(method: string, params: any) => void} listener */
|
|
164
|
+
onNotification(listener) { return this.#add(this.#notifications, listener); }
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Answers requests the daemon sends this client (the extension RPC of ALPD §61). The first
|
|
168
|
+
* handler answers; its result, or its error's numeric `code`, `message` and `data`, goes back.
|
|
169
|
+
* @param {(method: string, params: any) => unknown} handler
|
|
170
|
+
*/
|
|
171
|
+
onRequest(handler) { return this.#add(this.#requestHandlers, handler); }
|
|
172
|
+
|
|
173
|
+
/** Called once when the connection ends, with the error pending requests were rejected with. @param {(error: Error) => void} listener */
|
|
174
|
+
onClose(listener) { return this.#add(this.#closes, listener); }
|
|
175
|
+
|
|
176
|
+
/** Ends the connection; waiting requests reject with reason `closed`. Idempotent. */
|
|
177
|
+
close() {
|
|
178
|
+
if (this.#closeError) return;
|
|
179
|
+
this.#finish(new AlpClientError(CLOSED, { reason: 'closed' }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @template F @param {Set<F>} set @param {F} listener */
|
|
183
|
+
#add(set, listener) {
|
|
184
|
+
if (typeof listener !== 'function') throw new TypeError('ALP client listener must be a function');
|
|
185
|
+
if (this.#closeError) return () => false;
|
|
186
|
+
set.add(listener);
|
|
187
|
+
return () => set.delete(listener);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {TransportClose} [close] */
|
|
191
|
+
#disconnected(close = {}) {
|
|
192
|
+
if (close.error instanceof AlpClientError) return close.error;
|
|
193
|
+
const data = close.code !== undefined || close.reason ? { code: close.code, reason: close.reason } : undefined;
|
|
194
|
+
return new AlpClientError(close.error?.message ?? CLOSED, { reason: 'disconnected', cause: close.error, data });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {string} message */
|
|
198
|
+
#violation(message) {
|
|
199
|
+
this.#finish(new AlpClientError(message, { reason: 'protocol' }));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {Function} listener @param {unknown[]} args */
|
|
203
|
+
#call(listener, ...args) {
|
|
204
|
+
try { listener(...args); } catch (error) { this.#onListenerError(error); }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** @param {string} frame */
|
|
208
|
+
#receive(frame) {
|
|
209
|
+
if (this.#closeError) return;
|
|
210
|
+
if (typeof frame !== 'string') return this.#violation('Invalid frame from ALP daemon');
|
|
211
|
+
let message;
|
|
212
|
+
try { message = JSON.parse(frame); } catch { return this.#violation('Invalid frame from ALP daemon'); }
|
|
213
|
+
if (typeof message !== 'object' || message === null || Array.isArray(message)) return this.#violation('Invalid frame from ALP daemon');
|
|
214
|
+
const hasId = message.id !== undefined && message.id !== null;
|
|
215
|
+
if (message.method === 'event') {
|
|
216
|
+
for (const listener of [...this.#events]) this.#call(listener, message.params);
|
|
217
|
+
for (const listener of [...this.#notifications]) this.#call(listener, message.method, message.params);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (typeof message.method === 'string' && hasId && !own(message, 'result') && !own(message, 'error')) return this.#answer(message.id, message.method, message.params ?? {});
|
|
221
|
+
if (typeof message.method === 'string' && !hasId) {
|
|
222
|
+
for (const listener of [...this.#notifications]) this.#call(listener, message.method, message.params);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// A response to a request this client no longer waits for (timed out, aborted) or never sent,
|
|
226
|
+
// and an error the daemon could not tie to a request (id null), have nobody to go to.
|
|
227
|
+
const waiter = this.#pending.get(message.id);
|
|
228
|
+
if (!waiter) return;
|
|
229
|
+
this.#pending.delete(message.id);
|
|
230
|
+
waiter.cleanup();
|
|
231
|
+
if (own(message, 'error') && typeof message.error === 'object' && message.error !== null) waiter.reject(new AlpRpcError(message.error));
|
|
232
|
+
else if (own(message, 'result') && !own(message, 'error')) waiter.resolve(message.result);
|
|
233
|
+
else waiter.reject(new AlpClientError('Malformed response from ALP daemon', { reason: 'protocol', data: { id: message.id } }));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Answers a request from alpd. Every request gets exactly one answer: a result that cannot be
|
|
238
|
+
* serialized or does not fit a frame becomes an error, and an error whose data cannot go either
|
|
239
|
+
* goes without it, so alpd's caller never waits for a reply that was silently dropped.
|
|
240
|
+
* @param {unknown} id @param {string} method @param {unknown} params
|
|
241
|
+
*/
|
|
242
|
+
#answer(id, method, params) {
|
|
243
|
+
/** The frame for `body`, or undefined if it cannot be serialized or is over the limit. @param {object} body */
|
|
244
|
+
const frameOf = body => {
|
|
245
|
+
try {
|
|
246
|
+
const frame = JSON.stringify({ jsonrpc: '2.0', id, ...body });
|
|
247
|
+
return utf8Length(frame) < this.#maxFrame ? frame : undefined;
|
|
248
|
+
} catch { return undefined; }
|
|
249
|
+
};
|
|
250
|
+
/** @param {string | undefined} frame */
|
|
251
|
+
const send = frame => {
|
|
252
|
+
if (this.#closeError) return;
|
|
253
|
+
if (frame === undefined) return this.#finish(new AlpClientError('ALP reply exceeds the frame limit', { reason: 'protocol' }));
|
|
254
|
+
try { this.#transport.send(frame); } catch (cause) {
|
|
255
|
+
this.#finish(new AlpClientError('Could not send ALP reply', { reason: 'send_failed', cause }));
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
/** @param {unknown} error */
|
|
259
|
+
const failure = error => {
|
|
260
|
+
const code = (error && typeof (/** @type {any} */ (error)).code === 'number') ? /** @type {any} */ (error).code : 1000;
|
|
261
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
262
|
+
const message = text.length > 2000 ? `${text.slice(0, 2000)}...` : text;
|
|
263
|
+
const data = (error && /** @type {any} */ (error).data !== undefined) ? { data: /** @type {any} */ (error).data } : {};
|
|
264
|
+
return frameOf({ error: { code, message, ...data } }) ?? frameOf({ error: { code, message } })
|
|
265
|
+
?? frameOf({ error: { code: -32603, message: 'The reply could not be sent' } });
|
|
266
|
+
};
|
|
267
|
+
const [handler] = this.#requestHandlers;
|
|
268
|
+
if (!handler) return send(failure(Object.assign(new Error(`Unknown method '${method}'`), { code: -32601 })));
|
|
269
|
+
Promise.resolve().then(async () => {
|
|
270
|
+
try {
|
|
271
|
+
const result = await handler(method, params);
|
|
272
|
+
const frame = frameOf({ result: result === undefined ? {} : result });
|
|
273
|
+
send(frame ?? failure(new Error(`The result of ${method} cannot be serialized or exceeds the ${this.#maxFrame}-byte frame limit`)));
|
|
274
|
+
} catch (error) {
|
|
275
|
+
send(failure(error));
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** @param {AlpClientError} error */
|
|
281
|
+
#finish(error) {
|
|
282
|
+
if (this.#closeError) return;
|
|
283
|
+
this.#closeError = error;
|
|
284
|
+
for (const detach of this.#detach.splice(0)) { try { detach(); } catch {} }
|
|
285
|
+
try { this.#transport.close(); } catch {}
|
|
286
|
+
const pending = [...this.#pending.values()];
|
|
287
|
+
this.#pending.clear();
|
|
288
|
+
for (const waiter of pending) { waiter.cleanup(); waiter.reject(error); }
|
|
289
|
+
const closes = [...this.#closes];
|
|
290
|
+
this.#events.clear();
|
|
291
|
+
this.#notifications.clear();
|
|
292
|
+
this.#requestHandlers.clear();
|
|
293
|
+
this.#closes.clear();
|
|
294
|
+
for (const listener of closes) this.#call(listener, error);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors the client raises itself, as opposed to AlpRpcError, which carries an error alpd returned.
|
|
3
|
+
* Portable: no Node built-ins, process or Buffer (plans/reference/ALPD.md §3, docs/alp/client-sdk.md).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const BRAND = Symbol.for('@anhlp/alp-client/AlpClientError');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Why a request or a connection ended on the client side.
|
|
10
|
+
* - `closed`: this client was closed locally with close().
|
|
11
|
+
* - `disconnected`: the transport closed or failed. A request that was already sent may or may not
|
|
12
|
+
* have run on the daemon; the client never resends it.
|
|
13
|
+
* - `protocol`: the peer sent something that is not ALP JSON-RPC, so the client closed the connection.
|
|
14
|
+
* - `send_failed`: the transport refused the frame; the request never left this client.
|
|
15
|
+
* - `timeout` / `aborted`: the caller stopped waiting. Only the local wait ends; the daemon is not told.
|
|
16
|
+
* - `overloaded`: too many requests are waiting for an answer already; nothing was sent.
|
|
17
|
+
*/
|
|
18
|
+
export const CLIENT_ERROR = Object.freeze({
|
|
19
|
+
closed: 'closed',
|
|
20
|
+
disconnected: 'disconnected',
|
|
21
|
+
protocol: 'protocol',
|
|
22
|
+
sendFailed: 'send_failed',
|
|
23
|
+
timeout: 'timeout',
|
|
24
|
+
aborted: 'aborted',
|
|
25
|
+
overloaded: 'overloaded',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export class AlpClientError extends Error {
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} message
|
|
31
|
+
* @param {{ reason: 'closed' | 'disconnected' | 'protocol' | 'send_failed' | 'timeout' | 'aborted' | 'overloaded', cause?: unknown, data?: unknown }} details
|
|
32
|
+
*/
|
|
33
|
+
constructor(message, { reason, cause, data }) {
|
|
34
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
35
|
+
this.name = 'AlpClientError';
|
|
36
|
+
/** @type {'closed' | 'disconnected' | 'protocol' | 'send_failed' | 'timeout' | 'aborted' | 'overloaded'} */
|
|
37
|
+
this.reason = reason;
|
|
38
|
+
/** @type {unknown} */
|
|
39
|
+
this.data = data;
|
|
40
|
+
Object.defineProperty(this, BRAND, { value: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True for an AlpClientError, also one thrown by another copy of this module (a bundle may carry
|
|
46
|
+
* two); `reason` narrows the check.
|
|
47
|
+
* @param {unknown} error
|
|
48
|
+
* @param {string} [reason]
|
|
49
|
+
* @returns {error is AlpClientError}
|
|
50
|
+
*/
|
|
51
|
+
export function isAlpClientError(error, reason) {
|
|
52
|
+
return typeof error === 'object' && error !== null && /** @type {any} */ (error)[BRAND] === true
|
|
53
|
+
&& (reason === undefined || /** @type {any} */ (error).reason === reason);
|
|
54
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newline-delimited JSON framing for stream transports (a Unix socket, a named pipe, a TCP
|
|
3
|
+
* stream). Portable: strings in, strings out; the stream owner decodes bytes to text first.
|
|
4
|
+
* Message transports such as a WebSocket carry whole frames and need none of this.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** UTF-8 length of `text` without Buffer or TextEncoder; an unpaired surrogate counts as U+FFFD (3 bytes). @param {string} text */
|
|
8
|
+
export function utf8Length(text) {
|
|
9
|
+
let bytes = 0;
|
|
10
|
+
for (let index = 0; index < text.length; index++) {
|
|
11
|
+
const code = text.charCodeAt(index);
|
|
12
|
+
if (code < 0x80) bytes += 1;
|
|
13
|
+
else if (code < 0x800) bytes += 2;
|
|
14
|
+
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length && (text.charCodeAt(index + 1) & 0xfc00) === 0xdc00) { bytes += 4; index++; }
|
|
15
|
+
else bytes += 3;
|
|
16
|
+
}
|
|
17
|
+
return bytes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Splits decoded text chunks into frames. `push` returns the complete frames in order, blank
|
|
22
|
+
* lines skipped, and throws once a frame (complete or still arriving) exceeds `maxFrame` bytes;
|
|
23
|
+
* the decoder is unusable after that. Each character is measured once, so a frame that arrives
|
|
24
|
+
* in many chunks costs linear time.
|
|
25
|
+
* @param {{ maxFrame: number }} options
|
|
26
|
+
* @returns {{ push(chunk: string): string[], readonly buffered: number }}
|
|
27
|
+
*/
|
|
28
|
+
export function createLineDecoder({ maxFrame }) {
|
|
29
|
+
/** @type {string[]} */
|
|
30
|
+
let parts = [];
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
let failed = false;
|
|
33
|
+
const overflow = () => { failed = true; parts = []; bytes = 0; return new RangeError(`Frame exceeds ${maxFrame} bytes`); };
|
|
34
|
+
return {
|
|
35
|
+
push(chunk) {
|
|
36
|
+
if (failed) throw new RangeError('Line decoder already failed');
|
|
37
|
+
/** @type {string[]} */
|
|
38
|
+
const frames = [];
|
|
39
|
+
let start = 0;
|
|
40
|
+
for (let index = chunk.indexOf('\n'); index >= 0; index = chunk.indexOf('\n', start)) {
|
|
41
|
+
const piece = chunk.slice(start, index);
|
|
42
|
+
bytes += utf8Length(piece);
|
|
43
|
+
if (bytes > maxFrame) throw overflow();
|
|
44
|
+
parts.push(piece);
|
|
45
|
+
const line = parts.length === 1 ? parts[0] : parts.join('');
|
|
46
|
+
parts = [];
|
|
47
|
+
bytes = 0;
|
|
48
|
+
start = index + 1;
|
|
49
|
+
if (line.trim()) frames.push(line);
|
|
50
|
+
}
|
|
51
|
+
if (start < chunk.length) {
|
|
52
|
+
const rest = chunk.slice(start);
|
|
53
|
+
bytes += utf8Length(rest);
|
|
54
|
+
if (bytes > maxFrame) throw overflow();
|
|
55
|
+
parts.push(rest);
|
|
56
|
+
}
|
|
57
|
+
return frames;
|
|
58
|
+
},
|
|
59
|
+
get buffered() { return bytes; },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from '@anhlp/alp-protocol';
|
|
2
|
+
|
|
3
|
+
/** @typedef {{ name: string, version: string }} ClientInfo */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The version handshake every ALP connection starts with (plans/reference/ALPD.md §3): alpd
|
|
7
|
+
* answers nothing else before it. Shared by the Node `connect` and portable clients, so a change
|
|
8
|
+
* to the handshake (Task 06) lands in one place.
|
|
9
|
+
* @param {{ request(method: string, params?: unknown, options?: import('./client.js').RequestOptions): Promise<any> }} client
|
|
10
|
+
* @param {ClientInfo} [info]
|
|
11
|
+
* @param {import('./client.js').RequestOptions} [options]
|
|
12
|
+
* @returns {Promise<{ protocolVersion: number } & Record<string, unknown>>}
|
|
13
|
+
*/
|
|
14
|
+
export function hello(client, info = { name: 'alp', version: '0' }, options) {
|
|
15
|
+
return client.request('daemon.hello', { protocolVersion: PROTOCOL_VERSION, client: info }, options);
|
|
16
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { AlpRpcError, ERROR } from '@anhlp/alp-protocol';
|
|
2
|
+
import { AlpRpcClient } from './client.js';
|
|
3
|
+
import { AlpClientError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Keeps an ALP connection up: connects, runs the caller's `onConnect` (handshake, reattach and
|
|
7
|
+
* replay as the protocol defines them), and after a disconnect connects again with exponential
|
|
8
|
+
* backoff until stopped. A connection counts as up only once it has stayed open `stableMs`; one
|
|
9
|
+
* that drops sooner (a gateway closing right after the handshake) counts as a failed attempt, so
|
|
10
|
+
* backoff and `maxAttempts` still apply. It never resends a request: requests in flight when the connection drops
|
|
11
|
+
* reject as `disconnected`, and requests made while it is down reject at once. Whether and how to
|
|
12
|
+
* retry belongs to the caller, who knows which methods are safe to repeat.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {import('./client.js').AlpTransport} AlpTransport
|
|
17
|
+
* @typedef {import('./client.js').AlpRpcClientOptions} AlpRpcClientOptions
|
|
18
|
+
* @typedef {import('./client.js').RequestOptions} RequestOptions
|
|
19
|
+
* @typedef {import('./client.js').AbortSignalLike} AbortSignalLike
|
|
20
|
+
* @typedef {'connecting' | 'open' | 'waiting' | 'stopped'} ReconnectState
|
|
21
|
+
* @typedef {{ initialMs?: number, maxMs?: number, factor?: number, jitter?: number }} Backoff
|
|
22
|
+
* @typedef {{
|
|
23
|
+
* connect(context: { attempt: number, signal: AbortSignalLike }): Promise<AlpTransport>,
|
|
24
|
+
* onConnect?(client: AlpRpcClient, context: { attempt: number, reconnect: boolean, signal: AbortSignalLike }): unknown,
|
|
25
|
+
* onDisconnect?(error: Error, context: { reconnect: boolean }): void,
|
|
26
|
+
* backoff?: Backoff,
|
|
27
|
+
* maxAttempts?: number,
|
|
28
|
+
* stableMs?: number,
|
|
29
|
+
* shouldRetry?(error: Error): boolean,
|
|
30
|
+
* client?: AlpRpcClientOptions,
|
|
31
|
+
* random?: () => number,
|
|
32
|
+
* }} ReconnectOptions
|
|
33
|
+
* @typedef {{
|
|
34
|
+
* readonly state: ReconnectState,
|
|
35
|
+
* readonly client: AlpRpcClient | undefined,
|
|
36
|
+
* readonly attempt: number,
|
|
37
|
+
* ready(options?: { signal?: AbortSignalLike }): Promise<AlpRpcClient>,
|
|
38
|
+
* request<T = any>(method: string, params?: unknown, options?: RequestOptions): Promise<T>,
|
|
39
|
+
* onEvent(listener: (envelope: any) => void): () => boolean,
|
|
40
|
+
* onNotification(listener: (method: string, params: any) => void): () => boolean,
|
|
41
|
+
* onState(listener: (state: ReconnectState, error?: Error) => void): () => boolean,
|
|
42
|
+
* stop(): void,
|
|
43
|
+
* }} ReconnectingClient
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** The delay before attempt `attempt` (1-based), with ±jitter. @param {number} attempt @param {Backoff} backoff @param {() => number} random */
|
|
47
|
+
export function backoffDelay(attempt, { initialMs = 250, maxMs = 10_000, factor = 2, jitter = 0.2 } = {}, random = Math.random) {
|
|
48
|
+
const base = Math.min(maxMs, initialMs * factor ** Math.max(0, attempt - 1));
|
|
49
|
+
return Math.max(0, Math.round(base * (1 + jitter * (2 * random() - 1))));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a failed connect or onConnect is worth another attempt: everything but alpd refusing the
|
|
54
|
+
* handshake (ERROR.protocol, an unsupported protocol version), which retrying cannot fix.
|
|
55
|
+
* @param {Error} error
|
|
56
|
+
*/
|
|
57
|
+
export function retryable(error) {
|
|
58
|
+
return !(error instanceof AlpRpcError && error.code === ERROR.protocol);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The platform's AbortController, whose signal fetch and AbortSignal.any accept; a minimal one where there is none. */
|
|
62
|
+
function controller() {
|
|
63
|
+
if (typeof globalThis.AbortController === 'function') {
|
|
64
|
+
const native = new globalThis.AbortController();
|
|
65
|
+
return { signal: /** @type {AbortSignalLike} */ (native.signal), /** @param {unknown} reason */ abort: reason => native.abort(reason) };
|
|
66
|
+
}
|
|
67
|
+
/** @type {Set<() => void>} */
|
|
68
|
+
const listeners = new Set();
|
|
69
|
+
const signal = {
|
|
70
|
+
aborted: false,
|
|
71
|
+
/** @type {unknown} */ reason: undefined,
|
|
72
|
+
/** @param {'abort'} _type @param {() => void} listener */
|
|
73
|
+
addEventListener(_type, listener) { listeners.add(listener); },
|
|
74
|
+
/** @param {'abort'} _type @param {() => void} listener */
|
|
75
|
+
removeEventListener(_type, listener) { listeners.delete(listener); },
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
signal,
|
|
79
|
+
/** @param {unknown} reason */
|
|
80
|
+
abort(reason) {
|
|
81
|
+
if (signal.aborted) return;
|
|
82
|
+
signal.aborted = true;
|
|
83
|
+
signal.reason = reason;
|
|
84
|
+
for (const listener of [...listeners]) listener();
|
|
85
|
+
listeners.clear();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {ReconnectOptions} options
|
|
92
|
+
* @returns {ReconnectingClient}
|
|
93
|
+
*/
|
|
94
|
+
export function createReconnectingClient({ connect, onConnect, onDisconnect, backoff = {}, maxAttempts = Infinity, stableMs = 5_000, shouldRetry = retryable, client: clientOptions, random = Math.random }) {
|
|
95
|
+
if (typeof connect !== 'function') throw new TypeError('createReconnectingClient needs connect()');
|
|
96
|
+
/** @type {ReconnectState} */
|
|
97
|
+
let state = 'connecting';
|
|
98
|
+
/** @type {AlpRpcClient | undefined} */
|
|
99
|
+
let current;
|
|
100
|
+
let attempt = 0;
|
|
101
|
+
let connectedBefore = false;
|
|
102
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
103
|
+
let timer;
|
|
104
|
+
// One run for the client's whole life: stop() is final and aborts it.
|
|
105
|
+
const run = controller();
|
|
106
|
+
/** @type {Set<(envelope: any) => void>} */
|
|
107
|
+
const events = new Set();
|
|
108
|
+
/** @type {Set<(method: string, params: any) => void>} */
|
|
109
|
+
const notifications = new Set();
|
|
110
|
+
/** @type {Set<(state: ReconnectState, error?: Error) => void>} */
|
|
111
|
+
const states = new Set();
|
|
112
|
+
/** @type {Set<{ resolve(client: AlpRpcClient): void, reject(error: Error): void }>} */
|
|
113
|
+
const waiters = new Set();
|
|
114
|
+
|
|
115
|
+
const onListenerError = clientOptions?.onListenerError ?? (error => { setTimeout(() => { throw error; }, 0); });
|
|
116
|
+
/** @param {Function} listener @param {unknown[]} args */
|
|
117
|
+
const dispatch = (listener, ...args) => { try { listener(...args); } catch (error) { onListenerError(error); } };
|
|
118
|
+
|
|
119
|
+
/** @param {ReconnectState} next @param {Error} [error] */
|
|
120
|
+
const enter = (next, error) => {
|
|
121
|
+
state = next;
|
|
122
|
+
for (const listener of [...states]) {
|
|
123
|
+
dispatch(listener, next, error);
|
|
124
|
+
// A listener that called stop() has moved the state on; later ones must not hear a stale one.
|
|
125
|
+
if (state !== next) return;
|
|
126
|
+
}
|
|
127
|
+
if (next === 'open' && current) for (const waiter of [...waiters]) { waiters.delete(waiter); waiter.resolve(current); }
|
|
128
|
+
if (next === 'stopped') for (const waiter of [...waiters]) { waiters.delete(waiter); waiter.reject(error ?? new AlpClientError('ALP connection stopped', { reason: 'closed' })); }
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** @param {Error} error */
|
|
132
|
+
const retry = error => {
|
|
133
|
+
if (state === 'stopped') return;
|
|
134
|
+
if (attempt >= maxAttempts || !shouldRetry(error)) return enter('stopped', error);
|
|
135
|
+
enter('waiting', error);
|
|
136
|
+
if (state !== 'waiting') return;
|
|
137
|
+
timer = setTimeout(() => { timer = undefined; void open(); }, backoffDelay(attempt, backoff, random));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const open = async () => {
|
|
141
|
+
if (state === 'stopped') return;
|
|
142
|
+
attempt++;
|
|
143
|
+
enter('connecting');
|
|
144
|
+
const { signal } = run;
|
|
145
|
+
if (state !== 'connecting' || signal.aborted) return;
|
|
146
|
+
/** @type {AlpRpcClient | undefined} */
|
|
147
|
+
let candidate;
|
|
148
|
+
// stop() during the handshake closes the connection being set up: its pending requests reject
|
|
149
|
+
// and nothing more is sent on it.
|
|
150
|
+
const closeCandidate = () => candidate?.close();
|
|
151
|
+
signal.addEventListener('abort', closeCandidate, { once: true });
|
|
152
|
+
try {
|
|
153
|
+
const transport = await connect({ attempt, signal });
|
|
154
|
+
if (signal.aborted) { try { transport.close(); } catch {} return; }
|
|
155
|
+
candidate = new AlpRpcClient(transport, clientOptions);
|
|
156
|
+
// Listeners go on before onConnect so a replay it requests reaches them.
|
|
157
|
+
// Each caller listener runs on its own, as on AlpRpcClient: one that throws does not stop the rest.
|
|
158
|
+
candidate.onEvent(envelope => { for (const listener of [...events]) dispatch(listener, envelope); });
|
|
159
|
+
candidate.onNotification((method, params) => { for (const listener of [...notifications]) dispatch(listener, method, params); });
|
|
160
|
+
if (onConnect) await onConnect(candidate, { attempt, reconnect: connectedBefore, signal });
|
|
161
|
+
if (signal.aborted) { candidate.close(); return; }
|
|
162
|
+
if (candidate.closed) throw new AlpClientError('ALP connection closed during onConnect', { reason: 'disconnected' });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
candidate?.close();
|
|
165
|
+
if (signal.aborted) return;
|
|
166
|
+
return retry(error instanceof Error ? error : new Error(String(error)));
|
|
167
|
+
} finally {
|
|
168
|
+
signal.removeEventListener('abort', closeCandidate);
|
|
169
|
+
}
|
|
170
|
+
const connected = /** @type {AlpRpcClient} */ (candidate);
|
|
171
|
+
current = connected;
|
|
172
|
+
const reconnect = connectedBefore;
|
|
173
|
+
connectedBefore = true;
|
|
174
|
+
const openedAt = Date.now();
|
|
175
|
+
connected.onClose(error => {
|
|
176
|
+
if (current !== connected) return;
|
|
177
|
+
current = undefined;
|
|
178
|
+
// Only a connection that stayed up starts the next one from a fresh backoff.
|
|
179
|
+
if (Date.now() - openedAt >= stableMs) attempt = 0;
|
|
180
|
+
dispatch(() => onDisconnect?.(error, { reconnect }));
|
|
181
|
+
retry(error);
|
|
182
|
+
});
|
|
183
|
+
enter('open');
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
void open();
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
get state() { return state; },
|
|
190
|
+
get client() { return current; },
|
|
191
|
+
get attempt() { return attempt; },
|
|
192
|
+
ready({ signal } = {}) {
|
|
193
|
+
if (state === 'open' && current) return Promise.resolve(current);
|
|
194
|
+
if (state === 'stopped') return Promise.reject(new AlpClientError('ALP connection stopped', { reason: 'closed' }));
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const waiter = {
|
|
197
|
+
/** @param {AlpRpcClient} client */ resolve: client => { signal?.removeEventListener('abort', onAbort); resolve(client); },
|
|
198
|
+
/** @param {Error} error */ reject: error => { signal?.removeEventListener('abort', onAbort); reject(error); },
|
|
199
|
+
};
|
|
200
|
+
const onAbort = () => { waiters.delete(waiter); reject(new AlpClientError('Waiting for the ALP connection was aborted', { reason: 'aborted', cause: signal?.reason })); };
|
|
201
|
+
if (signal?.aborted) return onAbort();
|
|
202
|
+
waiters.add(waiter);
|
|
203
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
request(method, params, options) {
|
|
207
|
+
if (state !== 'open' || !current) return Promise.reject(new AlpClientError('ALP daemon connection closed', { reason: state === 'stopped' ? 'closed' : 'disconnected' }));
|
|
208
|
+
return current.request(method, params, options);
|
|
209
|
+
},
|
|
210
|
+
onEvent(listener) { events.add(listener); return () => events.delete(listener); },
|
|
211
|
+
onNotification(listener) { notifications.add(listener); return () => notifications.delete(listener); },
|
|
212
|
+
onState(listener) { states.add(listener); return () => states.delete(listener); },
|
|
213
|
+
stop() {
|
|
214
|
+
if (state === 'stopped') return;
|
|
215
|
+
if (timer !== undefined) { clearTimeout(timer); timer = undefined; }
|
|
216
|
+
run.abort(new AlpClientError('ALP connection stopped', { reason: 'closed' }));
|
|
217
|
+
const last = current;
|
|
218
|
+
current = undefined;
|
|
219
|
+
enter('stopped');
|
|
220
|
+
last?.close();
|
|
221
|
+
events.clear();
|
|
222
|
+
notifications.clear();
|
|
223
|
+
states.clear();
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|