@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.
Files changed (75) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +28 -2
  4. package/dist/acp-probe.d.ts +44 -0
  5. package/dist/acp-probe.js +37 -0
  6. package/dist/antigravity-probe.d.ts +34 -0
  7. package/dist/antigravity-probe.js +95 -0
  8. package/dist/command.d.ts +5 -0
  9. package/dist/command.js +33 -0
  10. package/dist/doctor.d.ts +13 -0
  11. package/dist/doctor.js +587 -0
  12. package/dist/index.d.ts +151 -0
  13. package/dist/index.js +533 -0
  14. package/dist/integration-lock.d.ts +35 -0
  15. package/dist/integration-lock.js +179 -0
  16. package/dist/ipc.d.ts +42 -0
  17. package/dist/ipc.js +69 -0
  18. package/dist/library-test.d.ts +94 -0
  19. package/dist/library-test.js +37 -0
  20. package/dist/mcp-probe.d.ts +24 -0
  21. package/dist/mcp-probe.js +147 -0
  22. package/dist/portable.d.ts +19 -0
  23. package/dist/portable.js +33 -0
  24. package/dist/prompt.d.ts +32 -0
  25. package/dist/prompt.js +59 -0
  26. package/dist/render.d.ts +6 -0
  27. package/dist/render.js +120 -0
  28. package/dist/rpc/client.d.ts +82 -0
  29. package/dist/rpc/client.js +292 -0
  30. package/dist/rpc/errors.d.ts +42 -0
  31. package/dist/rpc/errors.js +34 -0
  32. package/dist/rpc/framing.d.ts +21 -0
  33. package/dist/rpc/framing.js +57 -0
  34. package/dist/rpc/handshake.d.ts +19 -0
  35. package/dist/rpc/handshake.js +8 -0
  36. package/dist/rpc/reconnect.d.ts +97 -0
  37. package/dist/rpc/reconnect.js +227 -0
  38. package/dist/rpc/registry.d.ts +211 -0
  39. package/dist/rpc/registry.js +145 -0
  40. package/dist/rpc/timers.d.ts +11 -0
  41. package/dist/rpc/timers.js +11 -0
  42. package/dist/service.d.ts +50 -0
  43. package/dist/service.js +143 -0
  44. package/dist/setup.d.ts +2 -0
  45. package/dist/setup.js +146 -0
  46. package/dist/supervise.d.ts +14 -0
  47. package/dist/supervise.js +87 -0
  48. package/dist/websocket.d.ts +51 -0
  49. package/dist/websocket.js +190 -0
  50. package/package.json +174 -4
  51. package/src/acp-probe.js +46 -0
  52. package/src/antigravity-probe.js +106 -0
  53. package/src/command.js +18 -0
  54. package/src/doctor.d.ts +13 -0
  55. package/src/doctor.js +570 -0
  56. package/src/index.js +596 -0
  57. package/src/integration-lock.js +179 -0
  58. package/src/ipc.js +92 -0
  59. package/src/library-test.js +41 -0
  60. package/src/mcp-probe.js +118 -0
  61. package/src/portable.js +28 -0
  62. package/src/prompt.js +64 -0
  63. package/src/render.js +136 -0
  64. package/src/rpc/client.js +296 -0
  65. package/src/rpc/errors.js +54 -0
  66. package/src/rpc/framing.js +61 -0
  67. package/src/rpc/handshake.js +16 -0
  68. package/src/rpc/reconnect.js +226 -0
  69. package/src/rpc/registry.js +161 -0
  70. package/src/rpc/timers.js +16 -0
  71. package/src/service.js +154 -0
  72. package/src/setup.js +150 -0
  73. package/src/supervise.d.ts +14 -0
  74. package/src/supervise.js +92 -0
  75. package/src/websocket.js +259 -0
@@ -0,0 +1,259 @@
1
+ import { MAX_FRAME } from '@anhlp/alp-protocol';
2
+ import { AlpClientError } from './rpc/errors.js';
3
+ import { utf8Length } from './rpc/framing.js';
4
+ import { timerDelay } from './rpc/timers.js';
5
+
6
+ /**
7
+ * A WebSocket transport for the portable ALP client: the browser's WebSocket, React Native's, or
8
+ * any implementation the caller injects (`ws` in Node, a test double). One JSON-RPC message per
9
+ * text message; a message holding several newline-separated messages is accepted too. Binary
10
+ * messages are refused: the socket is closed with 4003, a message over `maxFrame` with 4009
11
+ * (application codes, since browsers refuse to send 1003 and 1009 themselves).
12
+ *
13
+ * How the connection is authenticated is the caller's business and is not designed here (Task 08):
14
+ * `url` and `protocols` are passed through untouched, and `createSocket` builds the socket however
15
+ * the deployment needs (for example Node `ws` with headers). Browsers cannot set arbitrary request
16
+ * headers on a WebSocket, so nothing here relies on them.
17
+ */
18
+
19
+ /**
20
+ * @typedef {import('./rpc/client.js').AlpTransport} AlpTransport
21
+ * @typedef {import('./rpc/client.js').TransportClose} TransportClose
22
+ * @typedef {import('./rpc/client.js').AbortSignalLike} AbortSignalLike
23
+ * @typedef {{
24
+ * readonly readyState: number,
25
+ * readonly bufferedAmount?: number,
26
+ * binaryType?: string,
27
+ * send(data: string): void,
28
+ * close(code?: number, reason?: string): void,
29
+ * addEventListener?(type: string, listener: (event: any) => void): void,
30
+ * removeEventListener?(type: string, listener: (event: any) => void): void,
31
+ * onopen?: ((event: any) => void) | null,
32
+ * onmessage?: ((event: any) => void) | null,
33
+ * onerror?: ((event: any) => void) | null,
34
+ * onclose?: ((event: any) => void) | null,
35
+ * }} WebSocketLike
36
+ * @typedef {new (url: string, protocols?: string | string[]) => WebSocketLike} WebSocketConstructor
37
+ * @typedef {{ maxFrame?: number, maxBufferedBytes?: number }} WebSocketTransportOptions
38
+ * @typedef {WebSocketTransportOptions & {
39
+ * WebSocket?: WebSocketConstructor,
40
+ * protocols?: string | string[],
41
+ * createSocket?: (url: string, protocols?: string | string[]) => WebSocketLike,
42
+ * signal?: AbortSignalLike,
43
+ * timeoutMs?: number,
44
+ * }} ConnectWebSocketOptions
45
+ */
46
+
47
+ const CONNECTING = 0;
48
+ const OPEN = 1;
49
+
50
+ /** Close codes this transport sends when the peer breaks the protocol. */
51
+ export const CLOSE_CODE = Object.freeze({ unsupportedData: 4003, tooLarge: 4009 });
52
+ /** How long an error waits for the close event that carries its code before ending the transport. */
53
+ const CLOSE_AFTER_ERROR_MS = 250;
54
+ /** A close reason may hold 123 UTF-8 bytes. */
55
+ const MAX_CLOSE_REASON = 123;
56
+
57
+ /** A URL fit for an error message: no credentials, query or fragment, which may carry a token. @param {string} url */
58
+ export function redactUrl(url) {
59
+ // The userinfo runs to the last '@' before the path, so a password holding '@' goes with it.
60
+ const match = /^([a-z][a-z0-9+.-]*:\/\/)(?:[^/?#\\]*@)?([^/?#\\@]*)([^?#]*)/i.exec(String(url));
61
+ return match ? `${match[1]}${match[2]}${match[3]}` : '<websocket>';
62
+ }
63
+
64
+ /**
65
+ * An error safe to show or log: the original's name and message with every URL redacted, and none
66
+ * of its other properties (an implementation may keep the URL in `input`, `target` or `cause`).
67
+ * @param {unknown} error @param {string} url
68
+ */
69
+ function scrubbed(error, url) {
70
+ const text = error instanceof Error ? error.message : String(error ?? '');
71
+ const message = (text.split(String(url)).join(redactUrl(url)) || 'WebSocket error')
72
+ .replace(/[a-z][a-z0-9+.-]*:\/\/[^\s'"<>]+/gi, found => redactUrl(found));
73
+ const clean = new Error(message);
74
+ if (error instanceof Error && error.name) clean.name = error.name;
75
+ return clean;
76
+ }
77
+
78
+ /** `reason` cut to what a close frame may carry. @param {string} reason */
79
+ function closeReason(reason) {
80
+ let cut = reason;
81
+ while (utf8Length(cut) > MAX_CLOSE_REASON) cut = cut.slice(0, -1);
82
+ return cut;
83
+ }
84
+
85
+ /**
86
+ * Closes a socket that is not closed yet, with `code` where the implementation accepts it and
87
+ * plainly where it does not, so the socket is always released.
88
+ * @param {WebSocketLike} socket @param {number} [code] @param {string} [reason]
89
+ */
90
+ function release(socket, code, reason) {
91
+ if (socket.readyState !== CONNECTING && socket.readyState !== OPEN) return;
92
+ try { if (code === undefined) socket.close(); else socket.close(code, closeReason(reason ?? '')); return; } catch {}
93
+ try { socket.close(); } catch {}
94
+ }
95
+
96
+ /**
97
+ * Keeps an 'error' listener on the socket for good: Node's `ws` throws an 'error' event that
98
+ * nobody listens to as an uncaught exception, and one can follow a close or an abandoned connect.
99
+ * @param {WebSocketLike} socket
100
+ */
101
+ function absorbErrors(socket) {
102
+ if (typeof socket.addEventListener === 'function') socket.addEventListener('error', () => {});
103
+ }
104
+
105
+ /** Subscribes with addEventListener where the socket has it, else through its on<type> property. @param {WebSocketLike} socket @param {string} type @param {(event: any) => void} listener */
106
+ function listen(socket, type, listener) {
107
+ if (typeof socket.addEventListener === 'function') {
108
+ socket.addEventListener(type, listener);
109
+ return () => socket.removeEventListener?.(type, listener);
110
+ }
111
+ const key = /** @type {'onopen' | 'onmessage' | 'onerror' | 'onclose'} */ (`on${type}`);
112
+ const previous = socket[key];
113
+ socket[key] = event => { previous?.(event); listener(event); };
114
+ return () => { socket[key] = previous ?? null; };
115
+ }
116
+
117
+ /** The error an error event carries, never with an empty message. @param {any} event */
118
+ const errorOf = event => {
119
+ const message = (event?.error instanceof Error && event.error.message) || (typeof event?.message === 'string' && event.message) || 'WebSocket error';
120
+ return event?.error instanceof Error && event.error.message ? event.error : new Error(message);
121
+ };
122
+
123
+ /**
124
+ * Wraps an open WebSocket in an ALP transport. The transport owns the socket from then on.
125
+ * @param {WebSocketLike} socket
126
+ * @param {WebSocketTransportOptions} [options]
127
+ * @returns {AlpTransport}
128
+ */
129
+ export function createWebSocketTransport(socket, { maxFrame = MAX_FRAME, maxBufferedBytes = 4 * MAX_FRAME } = {}) {
130
+ if (socket.readyState !== OPEN) throw new AlpClientError('WebSocket is not open', { reason: 'disconnected' });
131
+ absorbErrors(socket);
132
+ try { socket.binaryType = 'arraybuffer'; } catch {}
133
+ /** @type {Set<(frame: string) => void>} */
134
+ const messages = new Set();
135
+ /** @type {Set<(close: TransportClose) => void>} */
136
+ const closes = new Set();
137
+ /** @type {TransportClose | undefined} */
138
+ let ended;
139
+ /** @type {Array<() => void>} */
140
+ const detach = [];
141
+ /** An error event waiting for the close event that follows it. @type {Error | undefined} */
142
+ let failed;
143
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
144
+ let failTimer;
145
+ /** @param {TransportClose} close */
146
+ const end = close => {
147
+ if (ended) return;
148
+ ended = close;
149
+ if (failTimer !== undefined) clearTimeout(failTimer);
150
+ for (const remove of detach.splice(0)) remove();
151
+ messages.clear();
152
+ const listeners = [...closes];
153
+ closes.clear();
154
+ for (const listener of listeners) listener(close);
155
+ };
156
+ /** @param {string} message @param {number} code */
157
+ const violate = (message, code) => {
158
+ const error = new AlpClientError(message, { reason: 'protocol' });
159
+ release(socket, code, message);
160
+ end({ error });
161
+ };
162
+ detach.push(listen(socket, 'message', event => {
163
+ if (ended) return;
164
+ const data = event?.data;
165
+ if (typeof data !== 'string') return violate('Binary WebSocket messages are not ALP frames', CLOSE_CODE.unsupportedData);
166
+ if (utf8Length(data) > maxFrame) return violate(`ALP frame exceeds ${maxFrame} bytes`, CLOSE_CODE.tooLarge);
167
+ for (const line of data.includes('\n') ? data.split('\n') : [data]) {
168
+ if (ended) return;
169
+ if (line.trim()) for (const listener of [...messages]) listener(line);
170
+ }
171
+ }));
172
+ detach.push(listen(socket, 'close', event => end({ error: failed, code: typeof event?.code === 'number' ? event.code : undefined, reason: typeof event?.reason === 'string' && event.reason ? event.reason : undefined })));
173
+ // An error comes before its close event, which carries the code (1006 for a dropped
174
+ // connection); the transport ends with both. Not every implementation sends the close, so it
175
+ // ends without it shortly after.
176
+ detach.push(listen(socket, 'error', event => {
177
+ if (failed) return;
178
+ failed = errorOf(event);
179
+ failTimer = setTimeout(() => end({ error: failed }), CLOSE_AFTER_ERROR_MS);
180
+ release(socket);
181
+ }));
182
+ return {
183
+ get state() { return ended || socket.readyState !== OPEN ? 'closed' : 'open'; },
184
+ send(frame) {
185
+ if (ended || socket.readyState !== OPEN) throw new AlpClientError('WebSocket is not open', { reason: 'send_failed' });
186
+ if ((socket.bufferedAmount ?? 0) + utf8Length(frame) > maxBufferedBytes) throw new AlpClientError(`WebSocket send buffer is over ${maxBufferedBytes} bytes`, { reason: 'send_failed' });
187
+ socket.send(frame);
188
+ },
189
+ onMessage(listener) {
190
+ if (ended) return () => false;
191
+ messages.add(listener);
192
+ return () => messages.delete(listener);
193
+ },
194
+ onClose(listener) {
195
+ if (ended) return () => false;
196
+ closes.add(listener);
197
+ return () => closes.delete(listener);
198
+ },
199
+ close() {
200
+ release(socket, 1000, 'client closed');
201
+ end({ code: 1000, reason: 'client closed' });
202
+ },
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Opens a WebSocket to `url` and resolves with its transport once it is open. The socket comes
208
+ * from `createSocket`, else `new WebSocket(url, protocols)` with the injected or global WebSocket.
209
+ * Errors name the URL without its credentials, query or fragment.
210
+ * @param {string} url
211
+ * @param {ConnectWebSocketOptions} [options]
212
+ * @returns {Promise<AlpTransport>}
213
+ */
214
+ export function connectWebSocket(url, { WebSocket: Implementation, protocols, createSocket, signal, timeoutMs = 15_000, ...options } = {}) {
215
+ const where = redactUrl(url);
216
+ /** @type {number | undefined} */
217
+ let delay;
218
+ try { delay = timerDelay(timeoutMs); } catch (error) { return Promise.reject(error); }
219
+ if (signal?.aborted) return Promise.reject(new AlpClientError(`Connecting to ${where} was aborted`, { reason: 'aborted', cause: signal.reason }));
220
+ const Constructor = Implementation ?? /** @type {any} */ (globalThis).WebSocket;
221
+ if (!createSocket && typeof Constructor !== 'function') return Promise.reject(new TypeError('No WebSocket implementation: pass options.WebSocket or options.createSocket'));
222
+ /** @type {WebSocketLike} */
223
+ let socket;
224
+ try {
225
+ socket = createSocket ? createSocket(url, protocols) : protocols === undefined ? new Constructor(url) : new Constructor(url, protocols);
226
+ } catch (error) {
227
+ // The raw error may hold the whole URL (a TypeError's `input`, for one); only a scrubbed copy goes on.
228
+ return Promise.reject(new AlpClientError(`Could not connect to ${where}`, { reason: 'disconnected', cause: scrubbed(error, url) }));
229
+ }
230
+ absorbErrors(socket);
231
+ return new Promise((resolve, reject) => {
232
+ /** @type {Array<() => void>} */
233
+ const detach = [];
234
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
235
+ let timer;
236
+ const settle = () => {
237
+ for (const remove of detach.splice(0)) remove();
238
+ if (timer !== undefined) clearTimeout(timer);
239
+ signal?.removeEventListener('abort', onAbort);
240
+ };
241
+ /** @param {AlpClientError} error */
242
+ const fail = error => {
243
+ settle();
244
+ release(socket);
245
+ reject(error);
246
+ };
247
+ const onAbort = () => fail(new AlpClientError(`Connecting to ${where} was aborted`, { reason: 'aborted', cause: signal?.reason }));
248
+ const open = () => {
249
+ settle();
250
+ try { resolve(createWebSocketTransport(socket, options)); } catch (error) { reject(error); }
251
+ };
252
+ if (socket.readyState === OPEN) return open();
253
+ detach.push(listen(socket, 'open', open));
254
+ detach.push(listen(socket, 'error', event => fail(new AlpClientError(`Could not connect to ${where}`, { reason: 'disconnected', cause: scrubbed(errorOf(event), url) }))));
255
+ detach.push(listen(socket, 'close', event => fail(new AlpClientError(`Could not connect to ${where}`, { reason: 'disconnected', data: { code: event?.code, reason: event?.reason || undefined } }))));
256
+ signal?.addEventListener('abort', onAbort, { once: true });
257
+ if (delay !== undefined) timer = setTimeout(() => fail(new AlpClientError(`Connecting to ${where} timed out after ${timeoutMs} ms`, { reason: 'timeout' })), delay);
258
+ });
259
+ }