@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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +24 -0
  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 +101 -0
  13. package/dist/index.js +422 -0
  14. package/dist/ipc.d.ts +42 -0
  15. package/dist/ipc.js +69 -0
  16. package/dist/library-test.d.ts +94 -0
  17. package/dist/library-test.js +37 -0
  18. package/dist/mcp-probe.d.ts +24 -0
  19. package/dist/mcp-probe.js +147 -0
  20. package/dist/portable.d.ts +18 -0
  21. package/dist/portable.js +27 -0
  22. package/dist/prompt.d.ts +32 -0
  23. package/dist/prompt.js +59 -0
  24. package/dist/render.d.ts +6 -0
  25. package/dist/render.js +120 -0
  26. package/dist/rpc/client.d.ts +82 -0
  27. package/dist/rpc/client.js +292 -0
  28. package/dist/rpc/errors.d.ts +42 -0
  29. package/dist/rpc/errors.js +34 -0
  30. package/dist/rpc/framing.d.ts +21 -0
  31. package/dist/rpc/framing.js +57 -0
  32. package/dist/rpc/handshake.d.ts +19 -0
  33. package/dist/rpc/handshake.js +8 -0
  34. package/dist/rpc/reconnect.d.ts +97 -0
  35. package/dist/rpc/reconnect.js +227 -0
  36. package/dist/rpc/timers.d.ts +11 -0
  37. package/dist/rpc/timers.js +11 -0
  38. package/dist/service.d.ts +50 -0
  39. package/dist/service.js +143 -0
  40. package/dist/setup.d.ts +2 -0
  41. package/dist/setup.js +146 -0
  42. package/dist/supervise.d.ts +12 -0
  43. package/dist/supervise.js +81 -0
  44. package/dist/websocket.d.ts +51 -0
  45. package/dist/websocket.js +190 -0
  46. package/package.json +168 -0
  47. package/src/acp-probe.js +46 -0
  48. package/src/antigravity-probe.js +106 -0
  49. package/src/command.js +18 -0
  50. package/src/doctor.d.ts +13 -0
  51. package/src/doctor.js +570 -0
  52. package/src/index.js +457 -0
  53. package/src/ipc.js +92 -0
  54. package/src/library-test.js +41 -0
  55. package/src/mcp-probe.js +118 -0
  56. package/src/portable.js +27 -0
  57. package/src/prompt.js +64 -0
  58. package/src/render.js +136 -0
  59. package/src/rpc/client.js +296 -0
  60. package/src/rpc/errors.js +54 -0
  61. package/src/rpc/framing.js +61 -0
  62. package/src/rpc/handshake.js +16 -0
  63. package/src/rpc/reconnect.js +226 -0
  64. package/src/rpc/timers.js +16 -0
  65. package/src/service.js +154 -0
  66. package/src/setup.js +150 -0
  67. package/src/supervise.d.ts +12 -0
  68. package/src/supervise.js +85 -0
  69. package/src/websocket.js +259 -0
package/src/setup.js ADDED
@@ -0,0 +1,150 @@
1
+ import { copyFile, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { initProject } from '@anhlp/alp-core/init';
5
+ import { MODELS } from '@anhlp/alp-core/models';
6
+ import { fitEffort, modelEfforts } from '@anhlp/alp-core/profiles';
7
+ import { resolveTeam } from '@anhlp/alp-core/teams';
8
+ import { validateSettings } from '@anhlp/alp-core/validation';
9
+ import { Cancelled, select, text } from './prompt.js';
10
+
11
+ const PROVIDERS = ['claude', 'codex'];
12
+ const PROVIDER_LABEL = { claude: 'Claude', codex: 'Codex' };
13
+ /** Each seat's model and effort per provider. Main runs on the preferred provider's only; peers may run on any model of an enabled provider. */
14
+ const DEFAULTS = {
15
+ main: { claude: ['claude-opus-5-5', 'high'], codex: ['gpt-6.1-sol', 'high'] },
16
+ worker: { claude: ['claude-opus-5-5', 'high'], codex: ['gpt-6.1-sol', 'high'] },
17
+ supervisor: { claude: ['claude-sonnet-5', 'medium'], codex: ['gpt-6-luna', 'medium'] },
18
+ oracle: { claude: ['claude-fable-5-1', 'high'], codex: ['gpt-6-astra', 'high'] },
19
+ };
20
+ const FLAGS = { main: 'model', worker: 'model', supervisor: 'supervisor', oracle: 'oracle' };
21
+ const SEAT_LABEL = { main: 'main', worker: 'lead, reviewer', supervisor: 'supervisor', oracle: 'oracle' };
22
+ const ANY_EFFORT = ['low', 'medium', 'high', 'xhigh', 'max'];
23
+
24
+ /** Write Phở and Cafe's agent profiles into a project's .alp/settings.json, without installing software or calling models. */
25
+ export async function setup(args, home) {
26
+ const { values, positionals } = parseArgs({ args, allowPositionals: true, options: Object.fromEntries(
27
+ ['runtime', 'prefer', ...PROVIDERS.flatMap(provider => Object.values(FLAGS).map(flag => `${provider}-${flag}`))].map(name => [name, { type: 'string' }]),
28
+ ) });
29
+ if (positionals.length > 1) throw new Error('setup takes at most one directory');
30
+ const root = path.resolve(positionals[0] ?? process.cwd());
31
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
32
+ try {
33
+ const runtime = values.runtime ?? (interactive ? await select({ message: 'Runtime', initial: 'mix', choices: [
34
+ { label: 'Mix', value: 'mix', hint: 'Claude and Codex: main runs on one, the others can run on either' },
35
+ { label: 'Codex only', value: 'codex' },
36
+ { label: 'Claude only', value: 'claude' },
37
+ ] }) : undefined);
38
+ if (runtime === undefined) throw new Error('Pass --runtime codex, claude or mix when setup is not running in a terminal');
39
+ if (!['codex', 'claude', 'mix'].includes(runtime)) throw new Error('--runtime must be codex, claude or mix');
40
+ const prefer = values.prefer ?? (runtime !== 'mix' ? runtime : interactive ? await select({ message: 'Main runs on', initial: 'claude', choices: [
41
+ { label: 'Claude', value: 'claude', hint: 'Claude first for the others too' },
42
+ { label: 'Codex', value: 'codex', hint: 'Codex first for the others too' },
43
+ ] }) : 'claude');
44
+ if (!PROVIDERS.includes(prefer) || (runtime !== 'mix' && prefer !== runtime)) throw new Error('--prefer must be an enabled runtime');
45
+ // The preferred provider comes first: it is each agent's default, the other one a choice.
46
+ const enabled = runtime === 'mix' ? [prefer, ...PROVIDERS.filter(provider => provider !== prefer)] : [runtime];
47
+ for (const provider of PROVIDERS) {
48
+ for (const flag of Object.values(FLAGS)) {
49
+ if (!enabled.includes(provider) && values[`${provider}-${flag}`]) throw new Error(`--${provider}-${flag} is unavailable in ${runtime}-only mode`);
50
+ }
51
+ }
52
+ /** @type {Record<string, Array<{ provider: string, model: string, effort?: string }>>} */
53
+ const seats = {};
54
+ for (const [seat, flag] of Object.entries(FLAGS)) {
55
+ seats[seat] = (seat === 'main' ? [prefer] : enabled).map(provider => {
56
+ const option = `${provider}-${flag}`;
57
+ const [fallback, effort] = DEFAULTS[seat][provider];
58
+ const model = nativeModel(values[option] ?? fallback, provider, `--${option}`);
59
+ return { provider, model, effort: fitEffort(`${provider}:${model}`, effort) };
60
+ });
61
+ }
62
+ const peers = enabled.map(provider => `${provider}:*`);
63
+
64
+ const file = path.join(root, '.alp/settings.json');
65
+ const settings = await readFile(file, 'utf8').then(content => {
66
+ try { return JSON.parse(content); } catch (error) { throw new Error(`${file} is not valid JSON: ${error.message}`); }
67
+ }, error => {
68
+ if (error.code === 'ENOENT') return undefined;
69
+ throw new Error(`${file}: ${error.message}`);
70
+ });
71
+ const show = () => {
72
+ for (const [seat, choices] of Object.entries(seats)) console.log(` ${SEAT_LABEL[seat]}: ${choices.map(describe).join(', ')}`);
73
+ console.log(` peer: ${peers.join(', ')} (any model: whoever assigns a peer picks its model and effort for the task)`);
74
+ };
75
+ console.log(`Profiles for Phở and Cafe in ${file}:`);
76
+ show();
77
+ const replaced = ['pho', 'cafe'].filter(id => (settings?.team ?? settings?.profile)?.[id]);
78
+ if (replaced.length) console.log(`These replace the ${replaced.join(' and ')} profiles already in the file; a backup is kept in ${file}.bak.`);
79
+ if (interactive) {
80
+ for (;;) {
81
+ const next = await select({ message: 'Save these profiles?', initial: 'save', choices: [
82
+ { label: 'Save', value: 'save' },
83
+ { label: 'Change a model', value: 'change' },
84
+ { label: 'Cancel', value: 'cancel' },
85
+ ] });
86
+ if (next === 'cancel') throw new Cancelled();
87
+ if (next === 'save') break;
88
+ await change(seats);
89
+ show();
90
+ }
91
+ }
92
+ const profile = await profiles(root, home, seats, peers, settings);
93
+ validateSettings({ ...(settings ?? {}), team: { ...(settings?.profile ?? {}), ...(settings?.team ?? {}), ...profile } }, file);
94
+ if (!settings) await initProject(root, { library: home });
95
+ else if (replaced.length) await copyFile(file, `${file}.bak`);
96
+ const written = settings ?? JSON.parse(await readFile(file, 'utf8'));
97
+ const next = { ...written, team: { ...(written.profile ?? {}), ...(written.team ?? {}), ...profile } };
98
+ delete next.profile;
99
+ await writeFile(file, JSON.stringify(next, null, 2) + '\n');
100
+ console.log('Setup saved. New sessions in this project use these profiles; edit the file to change one agent. Run alp doctor to check that each CLI is installed and logged in.');
101
+ } catch (error) {
102
+ if (!(error instanceof Cancelled)) throw error;
103
+ console.log(error.message);
104
+ }
105
+ }
106
+
107
+ const describe = choice => `${choice.provider}:${choice.model}${choice.effort ? `:${choice.effort}` : ''}`;
108
+
109
+ /** Pick one seat and provider, then its model and effort. */
110
+ async function change(seats) {
111
+ const [seat, at] = await select({ message: 'Which model?', choices: Object.entries(seats).flatMap(([seat, choices]) =>
112
+ choices.map((choice, at) => ({ label: `${SEAT_LABEL[seat]} · ${PROVIDER_LABEL[choice.provider]}`, value: [seat, at], hint: describe(choice) }))) });
113
+ const current = seats[seat][at];
114
+ const offered = MODELS.filter(model => model.runtime === current.provider);
115
+ const picked = await select({ message: `${SEAT_LABEL[seat]} on ${PROVIDER_LABEL[current.provider]}`, initial: current.model, choices: [
116
+ ...offered.map(model => ({ label: model.label, value: model.id, hint: `${model.id} · ${model.description}` })),
117
+ { label: 'Other…', value: undefined, hint: 'type a model ID' },
118
+ ] });
119
+ const model = picked ?? nativeModel(await text(`${PROVIDER_LABEL[current.provider]} model ID:`), current.provider, 'The model ID');
120
+ const efforts = modelEfforts(`${current.provider}:${model}`) ?? ANY_EFFORT;
121
+ const effort = efforts.length > 1 ? await select({ message: 'Effort', initial: efforts.includes(current.effort) ? current.effort : DEFAULTS[seat][current.provider][1],
122
+ choices: efforts.map(effort => ({ label: effort, value: effort })) }) : efforts[0];
123
+ seats[seat][at] = { provider: current.provider, model, ...(effort ? { effort } : {}) };
124
+ }
125
+
126
+ /** Every Phở and Cafe agent's profiles, keeping each agent's own compaction setting. */
127
+ async function profiles(root, home, seats, peers, settings) {
128
+ const profile = {};
129
+ for (const id of ['pho', 'cafe']) {
130
+ const team = await resolveTeam(root, id, { library: home });
131
+ const agents = {};
132
+ const seat = (name, choices) => {
133
+ agents[name] = { profiles: choices.map(choice => typeof choice === 'string' ? choice : describe(choice)), autocompact: (settings?.team ?? settings?.profile)?.[id]?.[name]?.autocompact ?? 'auto' };
134
+ };
135
+ for (const [name, member] of Object.entries(team.members)) {
136
+ if (name !== 'oracle') seat(name, name === team.main ? seats.main : member.role === 'peer' ? peers : seats.worker);
137
+ }
138
+ if (team.supervisor) seat(team.supervisor.agent, seats.supervisor);
139
+ if (team.members.oracle) seat('oracle', seats.oracle);
140
+ profile[id] = agents;
141
+ }
142
+ return profile;
143
+ }
144
+
145
+ /** A model for one provider: a native ID, or one prefixed with that provider. */
146
+ function nativeModel(id, provider, where) {
147
+ const model = id.startsWith(`${provider}:`) ? id.slice(provider.length + 1) : id;
148
+ if (!model || /\s/.test(model) || /^(codex|claude):/.test(model) || (model.includes(':') && !id.startsWith(`${provider}:`))) throw new Error(`${where} expects a ${provider} model, got '${id}'`);
149
+ return model;
150
+ }
@@ -0,0 +1,12 @@
1
+ export function holdDaemon(home: string, by?: string): Promise<void>;
2
+ export function releaseHold(home: string): Promise<void>;
3
+ export function daemonHeld(home: string): Promise<boolean>;
4
+ export function startDaemon(options: { home: string; entry?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<string>;
5
+ export function superviseDaemon(options: {
6
+ home: string;
7
+ entry?: string | (() => string | undefined);
8
+ env?: NodeJS.ProcessEnv;
9
+ intervalMs?: number;
10
+ misses?: number;
11
+ log?: (message: string) => void;
12
+ }): (() => Promise<void>) & { started: Promise<void> };
@@ -0,0 +1,85 @@
1
+ import { mkdir, unlink, writeFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ensureDaemon, lockAlive, readLock } from './index.js';
4
+ import { installedProgram, serviceFor, startService } from './service.js';
5
+
6
+ /**
7
+ * Keeping alpd up (ALPD §40). Whoever starts alpd goes through its login service when
8
+ * one is installed, so the service keeps managing it; otherwise alpd starts detached.
9
+ * A deliberate `alp daemon stop` leaves a hold, which watchers respect: they start
10
+ * alpd again only when someone asks for it.
11
+ */
12
+
13
+ const holdFile = home => path.join(home, 'state', 'alpd.held');
14
+
15
+ /** Records that the user stopped alpd on purpose. */
16
+ export async function holdDaemon(home, by = 'alp daemon stop') {
17
+ await mkdir(path.dirname(holdFile(home)), { recursive: true, mode: 0o700 });
18
+ await writeFile(holdFile(home), JSON.stringify({ by, at: new Date().toISOString() }) + '\n', { mode: 0o600 });
19
+ }
20
+
21
+ export async function releaseHold(home) {
22
+ await unlink(holdFile(home)).catch(() => {});
23
+ }
24
+
25
+ export async function daemonHeld(home) {
26
+ return stat(holdFile(home)).then(() => true, () => false);
27
+ }
28
+
29
+ /** Polls until an alpd for `home` is ready; returns its socket. */
30
+ async function whenReady(home, timeoutMs) {
31
+ const deadline = Date.now() + timeoutMs;
32
+ while (Date.now() < deadline) {
33
+ const lock = await readLock(home);
34
+ if (lockAlive(lock) && lock.ready) return lock.socket;
35
+ await new Promise(resolve => setTimeout(resolve, 100));
36
+ }
37
+ throw new Error(`alpd did not become ready within ${timeoutMs / 1000} s; see ${path.join(home, 'logs')}`);
38
+ }
39
+
40
+ /**
41
+ * Starts alpd for `home` unless it runs, and lifts a hold: someone asked for it.
42
+ * Returns its socket.
43
+ */
44
+ export async function startDaemon({ home, entry, env = process.env, timeoutMs = 15_000 }) {
45
+ await releaseHold(home);
46
+ const lock = await readLock(home);
47
+ if (lockAlive(lock)) return lock.ready ? lock.socket : whenReady(home, timeoutMs);
48
+ const service = serviceFor({ home, env });
49
+ if (service && await installedProgram(service)) {
50
+ await startService(service);
51
+ return whenReady(home, timeoutMs);
52
+ }
53
+ return ensureDaemon({ home, entry, env, timeoutMs });
54
+ }
55
+
56
+ /**
57
+ * Starts alpd now and starts it again whenever it is found down on `misses` checks in a
58
+ * row, `intervalMs` apart, unless the user holds it. Two misses let `alp daemon restart`
59
+ * and `install` finish their own stop and start first. Returns a function that stops
60
+ * watching, and resolves once a start it began has settled; alpd keeps running.
61
+ */
62
+ export function superviseDaemon({ home, entry, env = process.env, intervalMs = 5_000, misses = 2, log = () => {} }) {
63
+ let stopped = false;
64
+ let missed = 0;
65
+ let starting;
66
+ const start = reason => {
67
+ starting ??= startDaemon({ home, entry: typeof entry === 'function' ? entry() : entry, env })
68
+ .then(() => log(`alpd ${reason}`), error => log(`could not start alpd: ${error?.message ?? error}`))
69
+ .finally(() => { starting = undefined; missed = 0; });
70
+ return starting;
71
+ };
72
+ const check = async () => {
73
+ if (stopped || starting) return;
74
+ if (lockAlive(await readLock(home))) { missed = 0; return; }
75
+ if (await daemonHeld(home)) { missed = 0; return; }
76
+ // Stopped while this check looked: start nothing.
77
+ if (++missed >= misses && !stopped) await start('was down; started it again');
78
+ };
79
+ const initial = start('started with Paseo');
80
+ const timer = setInterval(() => { void check(); }, intervalMs);
81
+ timer.unref?.();
82
+ const stop = () => { stopped = true; clearInterval(timer); return starting ?? Promise.resolve(); };
83
+ stop.started = initial;
84
+ return stop;
85
+ }
@@ -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
+ }