@anhlp/alp-client 0.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +24 -0
- 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 +101 -0
- package/dist/index.js +422 -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 +18 -0
- package/dist/portable.js +27 -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/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 +12 -0
- package/dist/supervise.js +81 -0
- package/dist/websocket.d.ts +51 -0
- package/dist/websocket.js +190 -0
- package/package.json +168 -0
- 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 +457 -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 +27 -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/timers.js +16 -0
- package/src/service.js +154 -0
- package/src/setup.js +150 -0
- package/src/supervise.d.ts +12 -0
- package/src/supervise.js +85 -0
- package/src/websocket.js +259 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// packages/client/src/rpc/client.js
|
|
2
|
+
import { AlpRpcError, MAX_FRAME } from "@anhlp/alp-protocol";
|
|
3
|
+
import { AlpClientError } from "./errors.js";
|
|
4
|
+
import { utf8Length } from "./framing.js";
|
|
5
|
+
import { timerDelay } from "./timers.js";
|
|
6
|
+
var CLOSED = "ALP daemon connection closed";
|
|
7
|
+
var DEFAULT_MAX_PENDING = 4096;
|
|
8
|
+
var rethrow = (error) => {
|
|
9
|
+
setTimeout(() => {
|
|
10
|
+
throw error;
|
|
11
|
+
}, 0);
|
|
12
|
+
};
|
|
13
|
+
function assertTransport(transport) {
|
|
14
|
+
for (const name of ["send", "onMessage", "onClose", "close"]) {
|
|
15
|
+
if (typeof /** @type {any} */
|
|
16
|
+
transport?.[name] !== "function") throw new TypeError(`ALP transport must implement ${name}()`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
var abortError = (signal) => new AlpClientError("ALP request aborted", { reason: "aborted", cause: signal.reason });
|
|
20
|
+
var own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
21
|
+
var AlpRpcClient = class {
|
|
22
|
+
/** @type {AlpTransport} */
|
|
23
|
+
#transport;
|
|
24
|
+
#sequence = 0;
|
|
25
|
+
/** @type {Map<number, { resolve(value: unknown): void, reject(error: unknown): void, cleanup(): void }>} */
|
|
26
|
+
#pending = /* @__PURE__ */ new Map();
|
|
27
|
+
/** @type {Set<(envelope: any) => void>} */
|
|
28
|
+
#events = /* @__PURE__ */ new Set();
|
|
29
|
+
/** @type {Set<(method: string, params: any) => void>} */
|
|
30
|
+
#notifications = /* @__PURE__ */ new Set();
|
|
31
|
+
/** @type {Set<(error: Error) => void>} */
|
|
32
|
+
#closes = /* @__PURE__ */ new Set();
|
|
33
|
+
/** @type {Set<(method: string, params: any) => unknown>} */
|
|
34
|
+
#requestHandlers = /* @__PURE__ */ new Set();
|
|
35
|
+
/** @type {Array<() => void>} */
|
|
36
|
+
#detach = [];
|
|
37
|
+
/** @type {AlpClientError | undefined} */
|
|
38
|
+
#closeError;
|
|
39
|
+
#maxPending;
|
|
40
|
+
#maxFrame;
|
|
41
|
+
#timeoutMs;
|
|
42
|
+
#onListenerError;
|
|
43
|
+
/**
|
|
44
|
+
* @param {AlpTransport} transport an open transport; the client owns it from now on
|
|
45
|
+
* @param {AlpRpcClientOptions} [options]
|
|
46
|
+
*/
|
|
47
|
+
constructor(transport, { maxPending = DEFAULT_MAX_PENDING, maxFrame = MAX_FRAME, timeoutMs, onListenerError = rethrow } = {}) {
|
|
48
|
+
assertTransport(transport);
|
|
49
|
+
this.#transport = transport;
|
|
50
|
+
this.#maxPending = maxPending;
|
|
51
|
+
this.#maxFrame = maxFrame;
|
|
52
|
+
timerDelay(timeoutMs);
|
|
53
|
+
this.#timeoutMs = timeoutMs;
|
|
54
|
+
this.#onListenerError = onListenerError;
|
|
55
|
+
this.#detach.push(transport.onMessage((frame) => this.#receive(frame)));
|
|
56
|
+
this.#detach.push(transport.onClose((close) => this.#finish(this.#disconnected(close))));
|
|
57
|
+
if (transport.state === "closed") this.#finish(new AlpClientError(CLOSED, { reason: "disconnected" }));
|
|
58
|
+
}
|
|
59
|
+
/** True once the connection is gone, for whatever reason. */
|
|
60
|
+
get closed() {
|
|
61
|
+
return this.#closeError !== void 0;
|
|
62
|
+
}
|
|
63
|
+
/** Requests sent and still waiting for an answer. */
|
|
64
|
+
get pendingCount() {
|
|
65
|
+
return this.#pending.size;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Sends one request and resolves with its result, or rejects with AlpRpcError (the daemon
|
|
69
|
+
* answered with an error) or AlpClientError (the client gave up: see CLIENT_ERROR). Requests are
|
|
70
|
+
* never resent, also not after a reconnect.
|
|
71
|
+
* @template [T=any]
|
|
72
|
+
* @param {string} method
|
|
73
|
+
* @param {unknown} [params]
|
|
74
|
+
* @param {RequestOptions} [options]
|
|
75
|
+
* @returns {Promise<T>}
|
|
76
|
+
*/
|
|
77
|
+
request(method, params = {}, { signal, timeoutMs = this.#timeoutMs } = {}) {
|
|
78
|
+
if (this.#closeError) return Promise.reject(new AlpClientError(CLOSED, { reason: this.#closeError.reason === "closed" ? "closed" : "disconnected", cause: this.#closeError }));
|
|
79
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
80
|
+
if (this.#pending.size >= this.#maxPending) return Promise.reject(new AlpClientError(`Too many ALP requests waiting for an answer (${this.#maxPending})`, { reason: "overloaded" }));
|
|
81
|
+
let delay;
|
|
82
|
+
try {
|
|
83
|
+
delay = timerDelay(timeoutMs);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return Promise.reject(error);
|
|
86
|
+
}
|
|
87
|
+
const id = ++this.#sequence;
|
|
88
|
+
let frame;
|
|
89
|
+
try {
|
|
90
|
+
frame = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return Promise.reject(error);
|
|
93
|
+
}
|
|
94
|
+
if (utf8Length(frame) >= this.#maxFrame) return Promise.reject(new AlpClientError(`ALP request ${method} exceeds the ${this.#maxFrame}-byte frame limit`, { reason: "send_failed" }));
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
let timer;
|
|
97
|
+
const onAbort = () => {
|
|
98
|
+
if (this.#pending.delete(id)) {
|
|
99
|
+
cleanup();
|
|
100
|
+
reject(abortError(
|
|
101
|
+
/** @type {AbortSignalLike} */
|
|
102
|
+
signal
|
|
103
|
+
));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const cleanup = () => {
|
|
107
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
108
|
+
signal?.removeEventListener("abort", onAbort);
|
|
109
|
+
};
|
|
110
|
+
this.#pending.set(id, { resolve, reject, cleanup });
|
|
111
|
+
if (delay !== void 0) {
|
|
112
|
+
timer = setTimeout(() => {
|
|
113
|
+
if (!this.#pending.delete(id)) return;
|
|
114
|
+
cleanup();
|
|
115
|
+
reject(new AlpClientError(`ALP request ${method} timed out after ${timeoutMs} ms`, { reason: "timeout" }));
|
|
116
|
+
}, delay);
|
|
117
|
+
}
|
|
118
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
119
|
+
try {
|
|
120
|
+
this.#transport.send(frame);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (this.#transport.state === "closed") this.#finish(new AlpClientError(CLOSED, { reason: "disconnected", cause: error }));
|
|
123
|
+
else if (this.#pending.delete(id)) {
|
|
124
|
+
cleanup();
|
|
125
|
+
reject(new AlpClientError(`Could not send ALP request ${method}: ${error instanceof Error ? error.message : String(error)}`, { reason: "send_failed", cause: error }));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** Every `event` notification: an envelope of a session the connection attached to. Returns a remover. @param {(envelope: any) => void} listener */
|
|
131
|
+
onEvent(listener) {
|
|
132
|
+
return this.#add(this.#events, listener);
|
|
133
|
+
}
|
|
134
|
+
/** Every notification from the daemon, `event` included; for methods this client does not know yet. @param {(method: string, params: any) => void} listener */
|
|
135
|
+
onNotification(listener) {
|
|
136
|
+
return this.#add(this.#notifications, listener);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Answers requests the daemon sends this client (the extension RPC of ALPD §61). The first
|
|
140
|
+
* handler answers; its result, or its error's numeric `code`, `message` and `data`, goes back.
|
|
141
|
+
* @param {(method: string, params: any) => unknown} handler
|
|
142
|
+
*/
|
|
143
|
+
onRequest(handler) {
|
|
144
|
+
return this.#add(this.#requestHandlers, handler);
|
|
145
|
+
}
|
|
146
|
+
/** Called once when the connection ends, with the error pending requests were rejected with. @param {(error: Error) => void} listener */
|
|
147
|
+
onClose(listener) {
|
|
148
|
+
return this.#add(this.#closes, listener);
|
|
149
|
+
}
|
|
150
|
+
/** Ends the connection; waiting requests reject with reason `closed`. Idempotent. */
|
|
151
|
+
close() {
|
|
152
|
+
if (this.#closeError) return;
|
|
153
|
+
this.#finish(new AlpClientError(CLOSED, { reason: "closed" }));
|
|
154
|
+
}
|
|
155
|
+
/** @template F @param {Set<F>} set @param {F} listener */
|
|
156
|
+
#add(set, listener) {
|
|
157
|
+
if (typeof listener !== "function") throw new TypeError("ALP client listener must be a function");
|
|
158
|
+
if (this.#closeError) return () => false;
|
|
159
|
+
set.add(listener);
|
|
160
|
+
return () => set.delete(listener);
|
|
161
|
+
}
|
|
162
|
+
/** @param {TransportClose} [close] */
|
|
163
|
+
#disconnected(close = {}) {
|
|
164
|
+
if (close.error instanceof AlpClientError) return close.error;
|
|
165
|
+
const data = close.code !== void 0 || close.reason ? { code: close.code, reason: close.reason } : void 0;
|
|
166
|
+
return new AlpClientError(close.error?.message ?? CLOSED, { reason: "disconnected", cause: close.error, data });
|
|
167
|
+
}
|
|
168
|
+
/** @param {string} message */
|
|
169
|
+
#violation(message) {
|
|
170
|
+
this.#finish(new AlpClientError(message, { reason: "protocol" }));
|
|
171
|
+
}
|
|
172
|
+
/** @param {Function} listener @param {unknown[]} args */
|
|
173
|
+
#call(listener, ...args) {
|
|
174
|
+
try {
|
|
175
|
+
listener(...args);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
this.#onListenerError(error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** @param {string} frame */
|
|
181
|
+
#receive(frame) {
|
|
182
|
+
if (this.#closeError) return;
|
|
183
|
+
if (typeof frame !== "string") return this.#violation("Invalid frame from ALP daemon");
|
|
184
|
+
let message;
|
|
185
|
+
try {
|
|
186
|
+
message = JSON.parse(frame);
|
|
187
|
+
} catch {
|
|
188
|
+
return this.#violation("Invalid frame from ALP daemon");
|
|
189
|
+
}
|
|
190
|
+
if (typeof message !== "object" || message === null || Array.isArray(message)) return this.#violation("Invalid frame from ALP daemon");
|
|
191
|
+
const hasId = message.id !== void 0 && message.id !== null;
|
|
192
|
+
if (message.method === "event") {
|
|
193
|
+
for (const listener of [...this.#events]) this.#call(listener, message.params);
|
|
194
|
+
for (const listener of [...this.#notifications]) this.#call(listener, message.method, message.params);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (typeof message.method === "string" && hasId && !own(message, "result") && !own(message, "error")) return this.#answer(message.id, message.method, message.params ?? {});
|
|
198
|
+
if (typeof message.method === "string" && !hasId) {
|
|
199
|
+
for (const listener of [...this.#notifications]) this.#call(listener, message.method, message.params);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const waiter = this.#pending.get(message.id);
|
|
203
|
+
if (!waiter) return;
|
|
204
|
+
this.#pending.delete(message.id);
|
|
205
|
+
waiter.cleanup();
|
|
206
|
+
if (own(message, "error") && typeof message.error === "object" && message.error !== null) waiter.reject(new AlpRpcError(message.error));
|
|
207
|
+
else if (own(message, "result") && !own(message, "error")) waiter.resolve(message.result);
|
|
208
|
+
else waiter.reject(new AlpClientError("Malformed response from ALP daemon", { reason: "protocol", data: { id: message.id } }));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Answers a request from alpd. Every request gets exactly one answer: a result that cannot be
|
|
212
|
+
* serialized or does not fit a frame becomes an error, and an error whose data cannot go either
|
|
213
|
+
* goes without it, so alpd's caller never waits for a reply that was silently dropped.
|
|
214
|
+
* @param {unknown} id @param {string} method @param {unknown} params
|
|
215
|
+
*/
|
|
216
|
+
#answer(id, method, params) {
|
|
217
|
+
const frameOf = (body) => {
|
|
218
|
+
try {
|
|
219
|
+
const frame = JSON.stringify({ jsonrpc: "2.0", id, ...body });
|
|
220
|
+
return utf8Length(frame) < this.#maxFrame ? frame : void 0;
|
|
221
|
+
} catch {
|
|
222
|
+
return void 0;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const send = (frame) => {
|
|
226
|
+
if (this.#closeError) return;
|
|
227
|
+
if (frame === void 0) return this.#finish(new AlpClientError("ALP reply exceeds the frame limit", { reason: "protocol" }));
|
|
228
|
+
try {
|
|
229
|
+
this.#transport.send(frame);
|
|
230
|
+
} catch (cause) {
|
|
231
|
+
this.#finish(new AlpClientError("Could not send ALP reply", { reason: "send_failed", cause }));
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const failure = (error) => {
|
|
235
|
+
const code = error && typeof /** @type {any} */
|
|
236
|
+
error.code === "number" ? (
|
|
237
|
+
/** @type {any} */
|
|
238
|
+
error.code
|
|
239
|
+
) : 1e3;
|
|
240
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
241
|
+
const message = text.length > 2e3 ? `${text.slice(0, 2e3)}...` : text;
|
|
242
|
+
const data = error && /** @type {any} */
|
|
243
|
+
error.data !== void 0 ? { data: (
|
|
244
|
+
/** @type {any} */
|
|
245
|
+
error.data
|
|
246
|
+
) } : {};
|
|
247
|
+
return frameOf({ error: { code, message, ...data } }) ?? frameOf({ error: { code, message } }) ?? frameOf({ error: { code: -32603, message: "The reply could not be sent" } });
|
|
248
|
+
};
|
|
249
|
+
const [handler] = this.#requestHandlers;
|
|
250
|
+
if (!handler) return send(failure(Object.assign(new Error(`Unknown method '${method}'`), { code: -32601 })));
|
|
251
|
+
Promise.resolve().then(async () => {
|
|
252
|
+
try {
|
|
253
|
+
const result = await handler(method, params);
|
|
254
|
+
const frame = frameOf({ result: result === void 0 ? {} : result });
|
|
255
|
+
send(frame ?? failure(new Error(`The result of ${method} cannot be serialized or exceeds the ${this.#maxFrame}-byte frame limit`)));
|
|
256
|
+
} catch (error) {
|
|
257
|
+
send(failure(error));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/** @param {AlpClientError} error */
|
|
262
|
+
#finish(error) {
|
|
263
|
+
if (this.#closeError) return;
|
|
264
|
+
this.#closeError = error;
|
|
265
|
+
for (const detach of this.#detach.splice(0)) {
|
|
266
|
+
try {
|
|
267
|
+
detach();
|
|
268
|
+
} catch {
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
this.#transport.close();
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
const pending = [...this.#pending.values()];
|
|
276
|
+
this.#pending.clear();
|
|
277
|
+
for (const waiter of pending) {
|
|
278
|
+
waiter.cleanup();
|
|
279
|
+
waiter.reject(error);
|
|
280
|
+
}
|
|
281
|
+
const closes = [...this.#closes];
|
|
282
|
+
this.#events.clear();
|
|
283
|
+
this.#notifications.clear();
|
|
284
|
+
this.#requestHandlers.clear();
|
|
285
|
+
this.#closes.clear();
|
|
286
|
+
for (const listener of closes) this.#call(listener, error);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
export {
|
|
290
|
+
AlpRpcClient,
|
|
291
|
+
DEFAULT_MAX_PENDING
|
|
292
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True for an AlpClientError, also one thrown by another copy of this module (a bundle may carry
|
|
3
|
+
* two); `reason` narrows the check.
|
|
4
|
+
* @param {unknown} error
|
|
5
|
+
* @param {string} [reason]
|
|
6
|
+
* @returns {error is AlpClientError}
|
|
7
|
+
*/
|
|
8
|
+
export function isAlpClientError(error: unknown, reason?: string): error is AlpClientError;
|
|
9
|
+
/**
|
|
10
|
+
* Why a request or a connection ended on the client side.
|
|
11
|
+
* - `closed`: this client was closed locally with close().
|
|
12
|
+
* - `disconnected`: the transport closed or failed. A request that was already sent may or may not
|
|
13
|
+
* have run on the daemon; the client never resends it.
|
|
14
|
+
* - `protocol`: the peer sent something that is not ALP JSON-RPC, so the client closed the connection.
|
|
15
|
+
* - `send_failed`: the transport refused the frame; the request never left this client.
|
|
16
|
+
* - `timeout` / `aborted`: the caller stopped waiting. Only the local wait ends; the daemon is not told.
|
|
17
|
+
* - `overloaded`: too many requests are waiting for an answer already; nothing was sent.
|
|
18
|
+
*/
|
|
19
|
+
export const CLIENT_ERROR: Readonly<{
|
|
20
|
+
closed: "closed";
|
|
21
|
+
disconnected: "disconnected";
|
|
22
|
+
protocol: "protocol";
|
|
23
|
+
sendFailed: "send_failed";
|
|
24
|
+
timeout: "timeout";
|
|
25
|
+
aborted: "aborted";
|
|
26
|
+
overloaded: "overloaded";
|
|
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: string, { reason, cause, data }: {
|
|
34
|
+
reason: "closed" | "disconnected" | "protocol" | "send_failed" | "timeout" | "aborted" | "overloaded";
|
|
35
|
+
cause?: unknown;
|
|
36
|
+
data?: unknown;
|
|
37
|
+
});
|
|
38
|
+
/** @type {'closed' | 'disconnected' | 'protocol' | 'send_failed' | 'timeout' | 'aborted' | 'overloaded'} */
|
|
39
|
+
reason: "closed" | "disconnected" | "protocol" | "send_failed" | "timeout" | "aborted" | "overloaded";
|
|
40
|
+
/** @type {unknown} */
|
|
41
|
+
data: unknown;
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// packages/client/src/rpc/errors.js
|
|
2
|
+
var BRAND = Symbol.for("@anhlp/alp-client/AlpClientError");
|
|
3
|
+
var CLIENT_ERROR = Object.freeze({
|
|
4
|
+
closed: "closed",
|
|
5
|
+
disconnected: "disconnected",
|
|
6
|
+
protocol: "protocol",
|
|
7
|
+
sendFailed: "send_failed",
|
|
8
|
+
timeout: "timeout",
|
|
9
|
+
aborted: "aborted",
|
|
10
|
+
overloaded: "overloaded"
|
|
11
|
+
});
|
|
12
|
+
var AlpClientError = class extends Error {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} message
|
|
15
|
+
* @param {{ reason: 'closed' | 'disconnected' | 'protocol' | 'send_failed' | 'timeout' | 'aborted' | 'overloaded', cause?: unknown, data?: unknown }} details
|
|
16
|
+
*/
|
|
17
|
+
constructor(message, { reason, cause, data }) {
|
|
18
|
+
super(message, cause === void 0 ? void 0 : { cause });
|
|
19
|
+
this.name = "AlpClientError";
|
|
20
|
+
this.reason = reason;
|
|
21
|
+
this.data = data;
|
|
22
|
+
Object.defineProperty(this, BRAND, { value: true });
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
function isAlpClientError(error, reason) {
|
|
26
|
+
return typeof error === "object" && error !== null && /** @type {any} */
|
|
27
|
+
error[BRAND] === true && (reason === void 0 || /** @type {any} */
|
|
28
|
+
error.reason === reason);
|
|
29
|
+
}
|
|
30
|
+
export {
|
|
31
|
+
AlpClientError,
|
|
32
|
+
CLIENT_ERROR,
|
|
33
|
+
isAlpClientError
|
|
34
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
/** UTF-8 length of `text` without Buffer or TextEncoder; an unpaired surrogate counts as U+FFFD (3 bytes). @param {string} text */
|
|
7
|
+
export function utf8Length(text: string): number;
|
|
8
|
+
/**
|
|
9
|
+
* Splits decoded text chunks into frames. `push` returns the complete frames in order, blank
|
|
10
|
+
* lines skipped, and throws once a frame (complete or still arriving) exceeds `maxFrame` bytes;
|
|
11
|
+
* the decoder is unusable after that. Each character is measured once, so a frame that arrives
|
|
12
|
+
* in many chunks costs linear time.
|
|
13
|
+
* @param {{ maxFrame: number }} options
|
|
14
|
+
* @returns {{ push(chunk: string): string[], readonly buffered: number }}
|
|
15
|
+
*/
|
|
16
|
+
export function createLineDecoder({ maxFrame }: {
|
|
17
|
+
maxFrame: number;
|
|
18
|
+
}): {
|
|
19
|
+
push(chunk: string): string[];
|
|
20
|
+
readonly buffered: number;
|
|
21
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// packages/client/src/rpc/framing.js
|
|
2
|
+
function utf8Length(text) {
|
|
3
|
+
let bytes = 0;
|
|
4
|
+
for (let index = 0; index < text.length; index++) {
|
|
5
|
+
const code = text.charCodeAt(index);
|
|
6
|
+
if (code < 128) bytes += 1;
|
|
7
|
+
else if (code < 2048) bytes += 2;
|
|
8
|
+
else if (code >= 55296 && code <= 56319 && index + 1 < text.length && (text.charCodeAt(index + 1) & 64512) === 56320) {
|
|
9
|
+
bytes += 4;
|
|
10
|
+
index++;
|
|
11
|
+
} else bytes += 3;
|
|
12
|
+
}
|
|
13
|
+
return bytes;
|
|
14
|
+
}
|
|
15
|
+
function createLineDecoder({ maxFrame }) {
|
|
16
|
+
let parts = [];
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
let failed = false;
|
|
19
|
+
const overflow = () => {
|
|
20
|
+
failed = true;
|
|
21
|
+
parts = [];
|
|
22
|
+
bytes = 0;
|
|
23
|
+
return new RangeError(`Frame exceeds ${maxFrame} bytes`);
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
push(chunk) {
|
|
27
|
+
if (failed) throw new RangeError("Line decoder already failed");
|
|
28
|
+
const frames = [];
|
|
29
|
+
let start = 0;
|
|
30
|
+
for (let index = chunk.indexOf("\n"); index >= 0; index = chunk.indexOf("\n", start)) {
|
|
31
|
+
const piece = chunk.slice(start, index);
|
|
32
|
+
bytes += utf8Length(piece);
|
|
33
|
+
if (bytes > maxFrame) throw overflow();
|
|
34
|
+
parts.push(piece);
|
|
35
|
+
const line = parts.length === 1 ? parts[0] : parts.join("");
|
|
36
|
+
parts = [];
|
|
37
|
+
bytes = 0;
|
|
38
|
+
start = index + 1;
|
|
39
|
+
if (line.trim()) frames.push(line);
|
|
40
|
+
}
|
|
41
|
+
if (start < chunk.length) {
|
|
42
|
+
const rest = chunk.slice(start);
|
|
43
|
+
bytes += utf8Length(rest);
|
|
44
|
+
if (bytes > maxFrame) throw overflow();
|
|
45
|
+
parts.push(rest);
|
|
46
|
+
}
|
|
47
|
+
return frames;
|
|
48
|
+
},
|
|
49
|
+
get buffered() {
|
|
50
|
+
return bytes;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
createLineDecoder,
|
|
56
|
+
utf8Length
|
|
57
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** @typedef {{ name: string, version: string }} ClientInfo */
|
|
2
|
+
/**
|
|
3
|
+
* The version handshake every ALP connection starts with (plans/reference/ALPD.md §3): alpd
|
|
4
|
+
* answers nothing else before it. Shared by the Node `connect` and portable clients, so a change
|
|
5
|
+
* to the handshake (Task 06) lands in one place.
|
|
6
|
+
* @param {{ request(method: string, params?: unknown, options?: import('./client.js').RequestOptions): Promise<any> }} client
|
|
7
|
+
* @param {ClientInfo} [info]
|
|
8
|
+
* @param {import('./client.js').RequestOptions} [options]
|
|
9
|
+
* @returns {Promise<{ protocolVersion: number } & Record<string, unknown>>}
|
|
10
|
+
*/
|
|
11
|
+
export function hello(client: {
|
|
12
|
+
request(method: string, params?: unknown, options?: import("./client.js").RequestOptions): Promise<any>;
|
|
13
|
+
}, info?: ClientInfo, options?: import("./client.js").RequestOptions): Promise<{
|
|
14
|
+
protocolVersion: number;
|
|
15
|
+
} & Record<string, unknown>>;
|
|
16
|
+
export type ClientInfo = {
|
|
17
|
+
name: string;
|
|
18
|
+
version: string;
|
|
19
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// packages/client/src/rpc/handshake.js
|
|
2
|
+
import { PROTOCOL_VERSION } from "@anhlp/alp-protocol";
|
|
3
|
+
function hello(client, info = { name: "alp", version: "0" }, options) {
|
|
4
|
+
return client.request("daemon.hello", { protocolVersion: PROTOCOL_VERSION, client: info }, options);
|
|
5
|
+
}
|
|
6
|
+
export {
|
|
7
|
+
hello
|
|
8
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps an ALP connection up: connects, runs the caller's `onConnect` (handshake, reattach and
|
|
3
|
+
* replay as the protocol defines them), and after a disconnect connects again with exponential
|
|
4
|
+
* backoff until stopped. A connection counts as up only once it has stayed open `stableMs`; one
|
|
5
|
+
* that drops sooner (a gateway closing right after the handshake) counts as a failed attempt, so
|
|
6
|
+
* backoff and `maxAttempts` still apply. It never resends a request: requests in flight when the connection drops
|
|
7
|
+
* reject as `disconnected`, and requests made while it is down reject at once. Whether and how to
|
|
8
|
+
* retry belongs to the caller, who knows which methods are safe to repeat.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {import('./client.js').AlpTransport} AlpTransport
|
|
12
|
+
* @typedef {import('./client.js').AlpRpcClientOptions} AlpRpcClientOptions
|
|
13
|
+
* @typedef {import('./client.js').RequestOptions} RequestOptions
|
|
14
|
+
* @typedef {import('./client.js').AbortSignalLike} AbortSignalLike
|
|
15
|
+
* @typedef {'connecting' | 'open' | 'waiting' | 'stopped'} ReconnectState
|
|
16
|
+
* @typedef {{ initialMs?: number, maxMs?: number, factor?: number, jitter?: number }} Backoff
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* connect(context: { attempt: number, signal: AbortSignalLike }): Promise<AlpTransport>,
|
|
19
|
+
* onConnect?(client: AlpRpcClient, context: { attempt: number, reconnect: boolean, signal: AbortSignalLike }): unknown,
|
|
20
|
+
* onDisconnect?(error: Error, context: { reconnect: boolean }): void,
|
|
21
|
+
* backoff?: Backoff,
|
|
22
|
+
* maxAttempts?: number,
|
|
23
|
+
* stableMs?: number,
|
|
24
|
+
* shouldRetry?(error: Error): boolean,
|
|
25
|
+
* client?: AlpRpcClientOptions,
|
|
26
|
+
* random?: () => number,
|
|
27
|
+
* }} ReconnectOptions
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* readonly state: ReconnectState,
|
|
30
|
+
* readonly client: AlpRpcClient | undefined,
|
|
31
|
+
* readonly attempt: number,
|
|
32
|
+
* ready(options?: { signal?: AbortSignalLike }): Promise<AlpRpcClient>,
|
|
33
|
+
* request<T = any>(method: string, params?: unknown, options?: RequestOptions): Promise<T>,
|
|
34
|
+
* onEvent(listener: (envelope: any) => void): () => boolean,
|
|
35
|
+
* onNotification(listener: (method: string, params: any) => void): () => boolean,
|
|
36
|
+
* onState(listener: (state: ReconnectState, error?: Error) => void): () => boolean,
|
|
37
|
+
* stop(): void,
|
|
38
|
+
* }} ReconnectingClient
|
|
39
|
+
*/
|
|
40
|
+
/** The delay before attempt `attempt` (1-based), with ±jitter. @param {number} attempt @param {Backoff} backoff @param {() => number} random */
|
|
41
|
+
export function backoffDelay(attempt: number, { initialMs, maxMs, factor, jitter }?: Backoff, random?: () => number): number;
|
|
42
|
+
/**
|
|
43
|
+
* Whether a failed connect or onConnect is worth another attempt: everything but alpd refusing the
|
|
44
|
+
* handshake (ERROR.protocol, an unsupported protocol version), which retrying cannot fix.
|
|
45
|
+
* @param {Error} error
|
|
46
|
+
*/
|
|
47
|
+
export function retryable(error: Error): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* @param {ReconnectOptions} options
|
|
50
|
+
* @returns {ReconnectingClient}
|
|
51
|
+
*/
|
|
52
|
+
export function createReconnectingClient({ connect, onConnect, onDisconnect, backoff, maxAttempts, stableMs, shouldRetry, client: clientOptions, random }: ReconnectOptions): ReconnectingClient;
|
|
53
|
+
export type AlpTransport = import("./client.js").AlpTransport;
|
|
54
|
+
export type AlpRpcClientOptions = import("./client.js").AlpRpcClientOptions;
|
|
55
|
+
export type RequestOptions = import("./client.js").RequestOptions;
|
|
56
|
+
export type AbortSignalLike = import("./client.js").AbortSignalLike;
|
|
57
|
+
export type ReconnectState = "connecting" | "open" | "waiting" | "stopped";
|
|
58
|
+
export type Backoff = {
|
|
59
|
+
initialMs?: number;
|
|
60
|
+
maxMs?: number;
|
|
61
|
+
factor?: number;
|
|
62
|
+
jitter?: number;
|
|
63
|
+
};
|
|
64
|
+
export type ReconnectOptions = {
|
|
65
|
+
connect(context: {
|
|
66
|
+
attempt: number;
|
|
67
|
+
signal: AbortSignalLike;
|
|
68
|
+
}): Promise<AlpTransport>;
|
|
69
|
+
onConnect?(client: AlpRpcClient, context: {
|
|
70
|
+
attempt: number;
|
|
71
|
+
reconnect: boolean;
|
|
72
|
+
signal: AbortSignalLike;
|
|
73
|
+
}): unknown;
|
|
74
|
+
onDisconnect?(error: Error, context: {
|
|
75
|
+
reconnect: boolean;
|
|
76
|
+
}): void;
|
|
77
|
+
backoff?: Backoff;
|
|
78
|
+
maxAttempts?: number;
|
|
79
|
+
stableMs?: number;
|
|
80
|
+
shouldRetry?(error: Error): boolean;
|
|
81
|
+
client?: AlpRpcClientOptions;
|
|
82
|
+
random?: () => number;
|
|
83
|
+
};
|
|
84
|
+
export type ReconnectingClient = {
|
|
85
|
+
readonly state: ReconnectState;
|
|
86
|
+
readonly client: AlpRpcClient | undefined;
|
|
87
|
+
readonly attempt: number;
|
|
88
|
+
ready(options?: {
|
|
89
|
+
signal?: AbortSignalLike;
|
|
90
|
+
}): Promise<AlpRpcClient>;
|
|
91
|
+
request<T = any>(method: string, params?: unknown, options?: RequestOptions): Promise<T>;
|
|
92
|
+
onEvent(listener: (envelope: any) => void): () => boolean;
|
|
93
|
+
onNotification(listener: (method: string, params: any) => void): () => boolean;
|
|
94
|
+
onState(listener: (state: ReconnectState, error?: Error) => void): () => boolean;
|
|
95
|
+
stop(): void;
|
|
96
|
+
};
|
|
97
|
+
import { AlpRpcClient } from './client.js';
|