@aexol/spectral 0.0.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/CHANGELOG.md +106 -0
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/cli.js +206 -0
- package/dist/commands/bind.js +96 -0
- package/dist/commands/login.js +109 -0
- package/dist/commands/logout.js +24 -0
- package/dist/commands/serve.js +374 -0
- package/dist/commands/unbind.js +36 -0
- package/dist/config.js +92 -0
- package/dist/extensions/aexol-mcp.js +117 -0
- package/dist/mcp-client.js +116 -0
- package/dist/preflight.js +36 -0
- package/dist/relay/client.js +240 -0
- package/dist/relay/dispatcher.js +504 -0
- package/dist/relay/machine-store.js +116 -0
- package/dist/relay/models-fetch.js +108 -0
- package/dist/relay/registration.js +135 -0
- package/dist/server/handlers/errors.js +34 -0
- package/dist/server/handlers/projects.js +86 -0
- package/dist/server/handlers/sessions.js +42 -0
- package/dist/server/paths.js +78 -0
- package/dist/server/pi-bridge.js +572 -0
- package/dist/server/session-stream.js +579 -0
- package/dist/server/shutdown.js +180 -0
- package/dist/server/storage.js +491 -0
- package/dist/server/title-generator.js +196 -0
- package/dist/server/wire.js +12 -0
- package/dist/studio-binding.js +97 -0
- package/package.json +67 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aexol MCP pi extension.
|
|
3
|
+
*
|
|
4
|
+
* Loaded by the spectral wrapper via `--extension <abs-path>`. On startup it:
|
|
5
|
+
* 1. Reads ~/.spectral/config.json (writen by `spectral login`).
|
|
6
|
+
* 2. Calls tools/list against the MCP backend once.
|
|
7
|
+
* 3. Registers every returned tool through pi.registerTool() so the model
|
|
8
|
+
* can call them like built-in tools.
|
|
9
|
+
*
|
|
10
|
+
* Each registered tool is just a thin proxy: when pi's model invokes it, we
|
|
11
|
+
* translate the call into a JSON-RPC tools/call POST and unwrap the response
|
|
12
|
+
* into the `{ content: [{ type: "text", text }], details }` shape pi expects.
|
|
13
|
+
*
|
|
14
|
+
* Failure modes are intentionally non-fatal: if config is missing or the
|
|
15
|
+
* backend is unreachable we log a warning and return, leaving pi to start
|
|
16
|
+
* without Aexol tools rather than crashing the whole agent.
|
|
17
|
+
*/
|
|
18
|
+
import { getApiUrl, readConfig } from "../config.js";
|
|
19
|
+
import { AexolMcpClient, AexolMcpError } from "../mcp-client.js";
|
|
20
|
+
/**
|
|
21
|
+
* Render a backend tool result into a single string for pi.
|
|
22
|
+
*
|
|
23
|
+
* The Aexol backend wraps results as `{ content: [{ type: "json", json: ... }] }`
|
|
24
|
+
* but may also send `{ type: "text", text: ... }`. We pick the first content
|
|
25
|
+
* item, prefer JSON when present (pretty-print so the model can read it),
|
|
26
|
+
* fall back to text, and last-resort stringify the whole content array.
|
|
27
|
+
*/
|
|
28
|
+
function renderContentToString(content) {
|
|
29
|
+
if (content.length === 0)
|
|
30
|
+
return "";
|
|
31
|
+
const first = content[0];
|
|
32
|
+
if (first.json !== undefined) {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.stringify(first.json, null, 2);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return String(first.json);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (typeof first.text === "string")
|
|
41
|
+
return first.text;
|
|
42
|
+
try {
|
|
43
|
+
return JSON.stringify(content, null, 2);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return String(content);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Build a one-line human label from a tool name like "remote_cloud_search". */
|
|
50
|
+
function toLabel(name) {
|
|
51
|
+
return name
|
|
52
|
+
.split(/[_\s-]+/)
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
55
|
+
.join(" ") || name;
|
|
56
|
+
}
|
|
57
|
+
export default async function aexolMcpExtension(pi) {
|
|
58
|
+
const cfg = await readConfig();
|
|
59
|
+
if (!cfg) {
|
|
60
|
+
// Pre-flight in cli.ts should have caught this. Logging is enough.
|
|
61
|
+
process.stderr.write("[aexol-mcp] No ~/.spectral/config.json found; Aexol tools disabled. Run `spectral login`.\n");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const apiUrl = getApiUrl(cfg.apiUrl);
|
|
65
|
+
const client = new AexolMcpClient(apiUrl, cfg.teamApiKey);
|
|
66
|
+
let tools;
|
|
67
|
+
try {
|
|
68
|
+
tools = await client.listTools();
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const msg = err instanceof AexolMcpError || err instanceof Error ? err.message : String(err);
|
|
72
|
+
process.stderr.write(`[aexol-mcp] Failed to fetch tool catalog from ${apiUrl}: ${msg}\n`);
|
|
73
|
+
process.stderr.write("[aexol-mcp] Continuing without Aexol tools.\n");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let registered = 0;
|
|
77
|
+
for (const tool of tools) {
|
|
78
|
+
// Defensive defaults: an empty-object schema is valid JSON Schema and
|
|
79
|
+
// will pass the agent's validator if the model sends `{}`.
|
|
80
|
+
const parameters = (tool.inputSchema ?? { type: "object", properties: {} });
|
|
81
|
+
const definition = {
|
|
82
|
+
name: tool.name,
|
|
83
|
+
label: toLabel(tool.name),
|
|
84
|
+
description: tool.description ?? "",
|
|
85
|
+
parameters,
|
|
86
|
+
async execute(_toolCallId, params) {
|
|
87
|
+
const res = await client.callTool(tool.name, params);
|
|
88
|
+
const text = renderContentToString(res.content);
|
|
89
|
+
if (res.isError === true) {
|
|
90
|
+
// Surface backend tool errors as a textual result, not a thrown
|
|
91
|
+
// exception. Pi treats this as the tool's natural reply; the model
|
|
92
|
+
// sees the `[Aexol MCP error] ...` marker and can recover. Throwing
|
|
93
|
+
// here would force pi to wrap the message and risk crashing
|
|
94
|
+
// callers that don't expect agent-tool exceptions.
|
|
95
|
+
const errorText = `[Aexol MCP error] ${text || "Tool reported an error with no message."}`;
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: "text", text: errorText }],
|
|
98
|
+
details: { isError: true },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text", text }],
|
|
103
|
+
details: {},
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
pi.registerTool(definition);
|
|
109
|
+
registered++;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
113
|
+
process.stderr.write(`[aexol-mcp] Failed to register tool "${tool.name}": ${msg}\n`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
process.stderr.write(`[aexol-mcp] Registered ${registered} Aexol MCP tool(s) from ${apiUrl}.\n`);
|
|
117
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal hand-rolled JSON-RPC 2.0 client for the Aexol MCP backend.
|
|
3
|
+
*
|
|
4
|
+
* Why hand-rolled, not @modelcontextprotocol/sdk?
|
|
5
|
+
* - The Aexol backend speaks a non-standard MCP dialect (custom content types,
|
|
6
|
+
* protocol version 0.1.0 negotiation, no SSE). The official SDK adds a lot
|
|
7
|
+
* of negotiation overhead we don't need and would actively break against
|
|
8
|
+
* this server.
|
|
9
|
+
* - All we want is: POST a JSON-RPC envelope with a Bearer token, get a
|
|
10
|
+
* JSON-RPC reply, surface errors. fetch() is built into Node 20+.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Errors thrown by AexolMcpClient carry an optional HTTP `status` so callers
|
|
14
|
+
* can produce friendlier messages (e.g. 401 → "invalid token").
|
|
15
|
+
*/
|
|
16
|
+
export class AexolMcpError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
code;
|
|
19
|
+
constructor(message, opts = {}) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "AexolMcpError";
|
|
22
|
+
this.status = opts.status;
|
|
23
|
+
this.code = opts.code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class AexolMcpClient {
|
|
27
|
+
apiUrl;
|
|
28
|
+
apiKey;
|
|
29
|
+
timeoutMs;
|
|
30
|
+
constructor(apiUrl, apiKey, timeoutMs = 30_000) {
|
|
31
|
+
this.apiUrl = apiUrl;
|
|
32
|
+
this.apiKey = apiKey;
|
|
33
|
+
this.timeoutMs = timeoutMs;
|
|
34
|
+
}
|
|
35
|
+
/** Low-level: send a JSON-RPC call and return its `result` field. */
|
|
36
|
+
async call(method, params) {
|
|
37
|
+
const body = JSON.stringify({
|
|
38
|
+
jsonrpc: "2.0",
|
|
39
|
+
// crypto.randomUUID is overkill; a counter would do, but this keeps the
|
|
40
|
+
// module stateless. JSON-RPC ids only need to be unique per outstanding call.
|
|
41
|
+
id: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
|
|
42
|
+
method,
|
|
43
|
+
params,
|
|
44
|
+
});
|
|
45
|
+
let res;
|
|
46
|
+
try {
|
|
47
|
+
res = await fetch(this.apiUrl, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
Accept: "application/json",
|
|
52
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
53
|
+
},
|
|
54
|
+
body,
|
|
55
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
60
|
+
throw new AexolMcpError(`Network error talking to ${this.apiUrl}: ${msg}`);
|
|
61
|
+
}
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
// Try to read a body for context, but don't fail if we can't.
|
|
64
|
+
let detail = "";
|
|
65
|
+
try {
|
|
66
|
+
detail = (await res.text()).slice(0, 500);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* ignore */
|
|
70
|
+
}
|
|
71
|
+
throw new AexolMcpError(`HTTP ${res.status} ${res.statusText} from ${this.apiUrl}${detail ? `: ${detail}` : ""}`, { status: res.status });
|
|
72
|
+
}
|
|
73
|
+
let payload;
|
|
74
|
+
try {
|
|
75
|
+
payload = (await res.json());
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
79
|
+
throw new AexolMcpError(`Invalid JSON response from ${this.apiUrl}: ${msg}`);
|
|
80
|
+
}
|
|
81
|
+
if ("error" in payload && payload.error) {
|
|
82
|
+
throw new AexolMcpError(`JSON-RPC error ${payload.error.code}: ${payload.error.message}`, { code: payload.error.code });
|
|
83
|
+
}
|
|
84
|
+
if (!("result" in payload)) {
|
|
85
|
+
throw new AexolMcpError(`JSON-RPC response missing 'result' field`);
|
|
86
|
+
}
|
|
87
|
+
return payload.result;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Fetch the tool catalog. The Aexol backend returns `{ tools: [...] }`
|
|
91
|
+
* per JSON-RPC convention, but we accept the raw array shape too in case
|
|
92
|
+
* the server format drifts.
|
|
93
|
+
*/
|
|
94
|
+
async listTools() {
|
|
95
|
+
const result = await this.call("tools/list");
|
|
96
|
+
if (Array.isArray(result))
|
|
97
|
+
return result;
|
|
98
|
+
if (result && Array.isArray(result.tools))
|
|
99
|
+
return result.tools;
|
|
100
|
+
throw new AexolMcpError("tools/list returned an unexpected payload shape");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Invoke a single tool. Returns the raw `{ content, isError? }` envelope —
|
|
104
|
+
* the caller (the pi extension) decides how to surface it back to the model.
|
|
105
|
+
*/
|
|
106
|
+
async callTool(name, args) {
|
|
107
|
+
const result = await this.call("tools/call", {
|
|
108
|
+
name,
|
|
109
|
+
arguments: args ?? {},
|
|
110
|
+
});
|
|
111
|
+
if (!result || typeof result !== "object" || !Array.isArray(result.content)) {
|
|
112
|
+
throw new AexolMcpError(`tools/call returned an unexpected payload for "${name}"`);
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared pre-flight check: every authenticated `spectral` subcommand (and the
|
|
3
|
+
* fall-through to pi) must verify that credentials exist in
|
|
4
|
+
* `~/.spectral/config.json` before proceeding.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `cli.ts` so `serve` (and any future subcommand) reuses the
|
|
7
|
+
* exact same UX — message wording, exit code, stderr formatting.
|
|
8
|
+
*
|
|
9
|
+
* `requireLogin()` writes to stderr and calls `process.exit(1)` on failure;
|
|
10
|
+
* `checkLogin()` is the testable variant that just reports the result so we
|
|
11
|
+
* can assert on it without killing the test runner.
|
|
12
|
+
*/
|
|
13
|
+
import { getConfigFile, readConfig } from "./config.js";
|
|
14
|
+
// Plain ANSI red. Mirrors the helper in cli.ts; we intentionally keep this
|
|
15
|
+
// dependency-light so pre-flight stays cheap on cold start.
|
|
16
|
+
function red(s) {
|
|
17
|
+
return process.stdout.isTTY || process.stderr.isTTY ? `\x1b[31m${s}\x1b[0m` : s;
|
|
18
|
+
}
|
|
19
|
+
/** Inspect login state without side effects. Safe to call from tests. */
|
|
20
|
+
export async function checkLogin() {
|
|
21
|
+
const cfg = await readConfig();
|
|
22
|
+
return { ok: cfg !== null, config: cfg, configFile: getConfigFile() };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Enforce login: returns the config on success, prints a friendly error and
|
|
26
|
+
* exits 1 on failure. Matches the wording used by the main spawn path.
|
|
27
|
+
*/
|
|
28
|
+
export async function requireLogin() {
|
|
29
|
+
const result = await checkLogin();
|
|
30
|
+
if (!result.ok || !result.config) {
|
|
31
|
+
process.stderr.write(red(`✗ Not logged in. Run: spectral login\n`));
|
|
32
|
+
process.stderr.write(` (expected credentials at ${result.configFile})\n`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
return result.config;
|
|
36
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RelayClient — long-lived WebSocket connection to the Aexol backend's
|
|
3
|
+
* `/agent-connection` endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities (Batch 2):
|
|
6
|
+
* - Open and maintain a single WS to the relay, authenticated with the
|
|
7
|
+
* machine JWT via `Authorization: Bearer <jwt>`.
|
|
8
|
+
* - Reply `{kind:"pong"}` to backend `{kind:"ping"}`. Backend closes
|
|
9
|
+
* `4408 heartbeat-timeout` if it doesn't hear from us within 90s; we
|
|
10
|
+
* rely on the backend pings rather than emitting our own (single source
|
|
11
|
+
* of liveness, no jitter on our side).
|
|
12
|
+
* - On unexpected close, reconnect forever with exponential backoff +
|
|
13
|
+
* ±20% jitter, capped at 30s. There is no "give up" state — if the
|
|
14
|
+
* machine is offline for hours, we just keep trying. Operators can
|
|
15
|
+
* `Ctrl-C` to stop.
|
|
16
|
+
* - Buffer outbound frames in a small queue while the socket is closed.
|
|
17
|
+
* Capped at 100 frames; oldest is dropped on overflow. Until Batch 3
|
|
18
|
+
* introduces relay envelopes there's nothing meaningful to send anyway,
|
|
19
|
+
* so this is mostly future-proofing.
|
|
20
|
+
*
|
|
21
|
+
* Out of scope for Batch 2 (Batch 3 work):
|
|
22
|
+
* - Envelope routing (`rest_request` / `subscribe` / `ws_event`). The
|
|
23
|
+
* client emits `frame` for every non-pong frame; the dispatcher in
|
|
24
|
+
* `serve.ts` (currently just an ack/echo) will own translation.
|
|
25
|
+
*
|
|
26
|
+
* Events (typed via `RelayClientEvents`):
|
|
27
|
+
* - `open` - connected and welcomed
|
|
28
|
+
* - `welcome` - backend `{kind:"welcome"}` frame
|
|
29
|
+
* - `frame` - any non-pong, non-welcome frame
|
|
30
|
+
* - `close` - socket closed (we may reconnect after)
|
|
31
|
+
* - `error` - non-fatal error (parse failure, send failure)
|
|
32
|
+
* - `reconnect-scheduled` - emitted with the delay in ms before each retry
|
|
33
|
+
*
|
|
34
|
+
* Threading model: `node:events` is single-threaded; all callbacks fire on
|
|
35
|
+
* the same event loop turn. Listeners must be cheap.
|
|
36
|
+
*/
|
|
37
|
+
import { EventEmitter } from "node:events";
|
|
38
|
+
import WebSocket from "ws";
|
|
39
|
+
/** Maximum outbound queue size while disconnected. */
|
|
40
|
+
const SEND_QUEUE_CAP = 100;
|
|
41
|
+
/** Backoff schedule (ms). The last value is the cap and is reused indefinitely. */
|
|
42
|
+
const RECONNECT_SCHEDULE = [1000, 2000, 5000, 15000, 30000];
|
|
43
|
+
/** ±20% jitter on each scheduled delay. */
|
|
44
|
+
const JITTER_RATIO = 0.2;
|
|
45
|
+
export class RelayClient extends EventEmitter {
|
|
46
|
+
relayUrl;
|
|
47
|
+
machineJwt;
|
|
48
|
+
WS;
|
|
49
|
+
logger;
|
|
50
|
+
exit;
|
|
51
|
+
ws = null;
|
|
52
|
+
disposed = false;
|
|
53
|
+
reconnectAttempt = 0;
|
|
54
|
+
reconnectTimer = null;
|
|
55
|
+
sendQueue = [];
|
|
56
|
+
constructor(opts) {
|
|
57
|
+
super();
|
|
58
|
+
this.relayUrl = opts.relayUrl;
|
|
59
|
+
this.machineJwt = opts.machineJwt;
|
|
60
|
+
this.WS = opts.webSocketImpl ?? WebSocket;
|
|
61
|
+
this.logger = opts.logger ?? console;
|
|
62
|
+
this.exit = opts.exit ?? ((code) => process.exit(code));
|
|
63
|
+
}
|
|
64
|
+
/** Open the connection. Idempotent — calling twice is a no-op. */
|
|
65
|
+
connect() {
|
|
66
|
+
if (this.disposed)
|
|
67
|
+
return;
|
|
68
|
+
if (this.ws)
|
|
69
|
+
return;
|
|
70
|
+
this.openSocket();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Send a frame. If the socket is open it goes immediately; otherwise
|
|
74
|
+
* it's queued (capped at 100 — oldest dropped) and flushed on the next
|
|
75
|
+
* successful `open`.
|
|
76
|
+
*
|
|
77
|
+
* Returns `true` if the frame was sent or queued, `false` if it was
|
|
78
|
+
* dropped due to dispose.
|
|
79
|
+
*/
|
|
80
|
+
send(frame) {
|
|
81
|
+
if (this.disposed)
|
|
82
|
+
return false;
|
|
83
|
+
const wire = typeof frame === "string" ? frame : JSON.stringify(frame);
|
|
84
|
+
const ws = this.ws;
|
|
85
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
86
|
+
try {
|
|
87
|
+
ws.send(wire);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
this.emit("error", err);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Queue it.
|
|
96
|
+
if (this.sendQueue.length >= SEND_QUEUE_CAP) {
|
|
97
|
+
this.sendQueue.shift();
|
|
98
|
+
}
|
|
99
|
+
this.sendQueue.push(wire);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Close the connection and stop reconnecting. After dispose, this client
|
|
104
|
+
* is dead — create a new one to reconnect.
|
|
105
|
+
*/
|
|
106
|
+
dispose() {
|
|
107
|
+
if (this.disposed)
|
|
108
|
+
return;
|
|
109
|
+
this.disposed = true;
|
|
110
|
+
if (this.reconnectTimer) {
|
|
111
|
+
clearTimeout(this.reconnectTimer);
|
|
112
|
+
this.reconnectTimer = null;
|
|
113
|
+
}
|
|
114
|
+
const ws = this.ws;
|
|
115
|
+
this.ws = null;
|
|
116
|
+
if (ws) {
|
|
117
|
+
try {
|
|
118
|
+
ws.close(1000, "client disposed");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// ignore — best-effort
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
this.sendQueue = [];
|
|
125
|
+
}
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
// Internals
|
|
128
|
+
// -------------------------------------------------------------------------
|
|
129
|
+
openSocket() {
|
|
130
|
+
const ws = new this.WS(this.relayUrl, {
|
|
131
|
+
headers: { Authorization: `Bearer ${this.machineJwt}` },
|
|
132
|
+
});
|
|
133
|
+
this.ws = ws;
|
|
134
|
+
ws.on("open", () => {
|
|
135
|
+
this.reconnectAttempt = 0;
|
|
136
|
+
// Flush any queued frames.
|
|
137
|
+
const queued = this.sendQueue;
|
|
138
|
+
this.sendQueue = [];
|
|
139
|
+
for (const wire of queued) {
|
|
140
|
+
try {
|
|
141
|
+
ws.send(wire);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
// Re-queue on failure and stop draining; the next open will retry.
|
|
145
|
+
this.sendQueue = queued.slice(queued.indexOf(wire));
|
|
146
|
+
this.emit("error", err);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this.emit("open");
|
|
151
|
+
});
|
|
152
|
+
ws.on("message", (data) => {
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(data.toString());
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
this.emit("error", err);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Heartbeat: reply pong inline, do NOT propagate.
|
|
162
|
+
if (parsed && parsed.kind === "ping") {
|
|
163
|
+
try {
|
|
164
|
+
ws.send(JSON.stringify({ kind: "pong" }));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
this.emit("error", err);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (parsed && parsed.kind === "welcome") {
|
|
172
|
+
this.emit("welcome", parsed);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
this.emit("frame", parsed);
|
|
176
|
+
});
|
|
177
|
+
ws.on("error", (err) => {
|
|
178
|
+
// `ws` emits 'error' before 'close'; we don't reconnect here — the
|
|
179
|
+
// 'close' handler is the single reconnect entry point so we don't
|
|
180
|
+
// double-schedule.
|
|
181
|
+
this.emit("error", err);
|
|
182
|
+
});
|
|
183
|
+
ws.on("close", (code, reason) => {
|
|
184
|
+
this.ws = null;
|
|
185
|
+
const reasonStr = reason?.toString() ?? "";
|
|
186
|
+
this.emit("close", { code, reason: reasonStr });
|
|
187
|
+
if (this.disposed)
|
|
188
|
+
return;
|
|
189
|
+
// Backend evicts older WS sessions when a newer registration arrives
|
|
190
|
+
// for the same machineId (see backend `registry.ts: register()`).
|
|
191
|
+
// If we reconnect here we'll just kick out the new instance, which
|
|
192
|
+
// will then reconnect and kick us out — an infinite ping-pong.
|
|
193
|
+
// Exit instead so the operator (or process supervisor) notices.
|
|
194
|
+
if (code === 1000 && reasonStr === "replaced-by-newer-registration") {
|
|
195
|
+
this.logger.error("\n✗ Another `spectral serve` instance has registered for this machine.");
|
|
196
|
+
this.logger.error(" This instance is exiting to avoid a reconnect loop.");
|
|
197
|
+
this.logger.error(" If you want to run multiple instances, use distinct `--machine-name` values.\n");
|
|
198
|
+
this.dispose();
|
|
199
|
+
this.exit(1);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
this.scheduleReconnect();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
scheduleReconnect() {
|
|
206
|
+
if (this.disposed)
|
|
207
|
+
return;
|
|
208
|
+
if (this.reconnectTimer)
|
|
209
|
+
return;
|
|
210
|
+
const idx = Math.min(this.reconnectAttempt, RECONNECT_SCHEDULE.length - 1);
|
|
211
|
+
const base = RECONNECT_SCHEDULE[idx];
|
|
212
|
+
const jitter = base * JITTER_RATIO * (Math.random() * 2 - 1);
|
|
213
|
+
const delay = Math.max(0, Math.round(base + jitter));
|
|
214
|
+
this.reconnectAttempt++;
|
|
215
|
+
this.emit("reconnect-scheduled", { delayMs: delay, attempt: this.reconnectAttempt });
|
|
216
|
+
// IMPORTANT: do NOT `unref()` this timer. `spectral serve` is a long-
|
|
217
|
+
// lived daemon and the reconnect timer is frequently the ONLY thing
|
|
218
|
+
// keeping the event loop alive between a WS close and the next open
|
|
219
|
+
// (the SessionStreamManager holds nothing while idle). An unref'd
|
|
220
|
+
// timer lets Node decide the loop is empty and exit the process,
|
|
221
|
+
// which manifests as the daemon silently dying after a network blip.
|
|
222
|
+
// Tests dispose the client explicitly via `dispose()`, which clears
|
|
223
|
+
// this timer, so they still terminate cleanly.
|
|
224
|
+
this.reconnectTimer = setTimeout(() => {
|
|
225
|
+
this.reconnectTimer = null;
|
|
226
|
+
if (this.disposed)
|
|
227
|
+
return;
|
|
228
|
+
try {
|
|
229
|
+
this.openSocket();
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
// A synchronous throw from the WebSocket constructor (DNS, bad
|
|
233
|
+
// URL, etc.) must not kill the daemon — log it and re-schedule
|
|
234
|
+
// so the backoff continues forever.
|
|
235
|
+
this.emit("error", err);
|
|
236
|
+
this.scheduleReconnect();
|
|
237
|
+
}
|
|
238
|
+
}, delay);
|
|
239
|
+
}
|
|
240
|
+
}
|