mtech-ai-local-runner 0.1.48 → 0.1.50

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.
@@ -12,6 +12,51 @@ export class GatewayToolError extends Error {
12
12
  this.code = code;
13
13
  }
14
14
  }
15
+ const PUSH_CHANNEL = /^private-ai-gateway\.[a-z0-9_-]{1,32}\.runner\.[a-z0-9:_-]{1,128}$/;
16
+ const PUSH_APP_KEY = /^[A-Za-z0-9_-]{1,128}$/;
17
+ const CHANNEL_AUTH = /^[^:\s]{1,128}:[a-f0-9]{64}$/;
18
+ /*
19
+ * A sync result without the ask keys ends every stored delivery (the e1c08b33 kill switch), so a result that is not
20
+ * clearly a sync answer is refused instead of being read as "asks off": the transport retries it with backoff.
21
+ */
22
+ export function parseRunnerSync(result, runnerId) {
23
+ const body = result && typeof result === "object" && !Array.isArray(result) ? result : undefined;
24
+ const malformed = (key) => body !== undefined && body[key] !== undefined && !Array.isArray(body[key]);
25
+ if (body === undefined || !Array.isArray(body.opens) || malformed("ask_deliveries") || malformed("closed_delivery_ids")) {
26
+ throw new GatewayToolError("Gateway returned an invalid runner sync result", "local_agent_runner_sync", "GATEWAY_INVALID_TOOL_RESULT");
27
+ }
28
+ return {
29
+ opens: body.opens,
30
+ ...(Array.isArray(body.ask_deliveries) ? { askDeliveries: body.ask_deliveries } : {}),
31
+ ...(Array.isArray(body.closed_delivery_ids) ? { closedDeliveryIds: body.closed_delivery_ids } : {}),
32
+ jobAvailable: body.job_available === true,
33
+ push: parsePush(body.push, runnerId),
34
+ syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
35
+ fallbackSyncSeconds: clampSeconds(body.fallback_sync_seconds, 5, 30, 8),
36
+ };
37
+ }
38
+ function clampSeconds(value, min, max, fallback) {
39
+ return typeof value === "number" && Number.isInteger(value) ? Math.min(max, Math.max(min, value)) : fallback;
40
+ }
41
+ function parsePush(value, runnerId) {
42
+ if (!value || typeof value !== "object")
43
+ return null;
44
+ const { url, app_key: appKey, channel, event } = value;
45
+ if (typeof url !== "string" || typeof appKey !== "string" || typeof channel !== "string" || event !== "runner.sync")
46
+ return null;
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(url);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ if (parsed.protocol !== "wss:" || !PUSH_APP_KEY.test(appKey) || !PUSH_CHANNEL.test(channel))
55
+ return null;
56
+ if (!channel.endsWith(`.runner.${runnerId.toLowerCase()}`))
57
+ return null;
58
+ return { url, appKey, channel, event };
59
+ }
15
60
  function nested(value, path) {
16
61
  return path.split(".").reduce((current, key) => {
17
62
  if (!current || typeof current !== "object")
@@ -479,6 +524,27 @@ export class McpGatewayClient {
479
524
  ...(input.failureCode ? { failure_code: input.failureCode } : {}),
480
525
  }, signal);
481
526
  }
527
+ /** acknowledgedClosureIds: closures handled since the last sync; the Gateway lists each again until acknowledged. */
528
+ async syncRunner(input, signal) {
529
+ const acknowledged = (input.acknowledgedClosureIds ?? []).slice(0, 50);
530
+ const result = await this.callTool("local_agent_runner_sync", {
531
+ runner_id: this.runnerId,
532
+ adapter_versions: input.adapterVersions,
533
+ reason: input.reason,
534
+ ...(acknowledged.length > 0 ? { acknowledged_closure_ids: acknowledged } : {}),
535
+ }, signal);
536
+ return parseRunnerSync(result, this.runnerId);
537
+ }
538
+ async authorizeRunnerChannel(socketId, signal) {
539
+ const result = await this.callTool("local_agent_runner_channel_auth", {
540
+ runner_id: this.runnerId,
541
+ socket_id: socketId,
542
+ }, signal);
543
+ if (typeof result?.channel !== "string" || typeof result.auth !== "string" || !CHANNEL_AUTH.test(result.auth)) {
544
+ throw new GatewayToolError("Gateway returned an invalid channel authorization", "local_agent_runner_channel_auth", "GATEWAY_INVALID_TOOL_RESULT");
545
+ }
546
+ return { channel: result.channel, auth: result.auth };
547
+ }
482
548
  async localRunnerStatus() {
483
549
  return this.callTool("local_agent_runner_status", { runner_id: this.runnerId });
484
550
  }
@@ -6,7 +6,7 @@ import { evaluateCommand } from './command-policy.js';
6
6
  import { runSandboxed, runSandboxedMeasured } from './subprocess-sandbox.js';
7
7
  import { LOCAL_TOOL_WORKER } from './local-tool-worker.js';
8
8
  import { verifyCommandIntegrity } from './registered-command-integrity.js';
9
- export const LOCAL_TOOL_NAMES = ['local_read', 'local_search', 'local_edit', 'local_test'];
9
+ export const LOCAL_TOOL_NAMES = ['local_read', 'local_search', 'local_edit', 'local_replace', 'local_test'];
10
10
  // Private state/control basenames only: Auth.php, auth/ and mcp.ts are ordinary source.
11
11
  const sensitive = (segment) => /^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(segment);
12
12
  const contains = (root, file) => file === root || file.startsWith(root + path.sep);
@@ -40,11 +40,19 @@ export class LocalToolPolicy {
40
40
  if (!args || typeof args !== 'object' || Array.isArray(args))
41
41
  throw new Error('DENIED_ARGUMENTS');
42
42
  const input = args;
43
- if (tool === 'local_edit' && this.options.access !== 'write')
43
+ if ((tool === 'local_edit' || tool === 'local_replace') && this.options.access !== 'write')
44
44
  throw new Error('DENIED_READ_ONLY');
45
45
  if (tool === 'local_test')
46
46
  return this.test(input);
47
47
  const file = await this.authorizedFile(input.path, tool === 'local_edit');
48
+ if (tool === 'local_replace') {
49
+ if (typeof input.old !== 'string' || !input.old || Buffer.byteLength(input.old) > 1024 * 1024)
50
+ throw new Error('DENIED_REPLACE_OLD');
51
+ if (typeof input.new !== 'string' || Buffer.byteLength(input.new) > 1024 * 1024)
52
+ throw new Error('DENIED_CONTENT');
53
+ if (isPatchContent(input.new))
54
+ throw new Error('DENIED_PATCH_CONTENT');
55
+ }
48
56
  if (tool === 'local_edit' && (typeof input.content !== 'string' || Buffer.byteLength(input.content) > 1024 * 1024))
49
57
  throw new Error('DENIED_CONTENT');
50
58
  if (tool === 'local_edit' && isPatchContent(input.content))
@@ -52,10 +60,13 @@ export class LocalToolPolicy {
52
60
  if (tool === 'local_search' && (typeof input.query !== 'string' || input.query.length > 4096))
53
61
  throw new Error('DENIED_QUERY');
54
62
  const temp = file + '.runner-edit-' + randomBytes(16).toString('hex');
55
- const output = await runSandboxed({ executable: process.execPath, argv: ['--no-addons', '--no-global-search-paths', '--eval', LOCAL_TOOL_WORKER, JSON.stringify({ tool, path: file, temp, content: input.content, query: input.query })], readFiles: tool === 'local_edit' ? [file, temp] : [file], writeFiles: tool === 'local_edit' ? [file, temp] : [], timeoutMs: 10000, signal: this.abort.signal });
63
+ const output = await runSandboxed({ executable: process.execPath, argv: ['--no-addons', '--no-global-search-paths', '--eval', LOCAL_TOOL_WORKER, JSON.stringify({ tool, path: file, temp, content: input.content, query: input.query, old: input.old, new: input.new })], readFiles: (tool === 'local_edit' || tool === 'local_replace') ? [file, temp] : [file], writeFiles: (tool === 'local_edit' || tool === 'local_replace') ? [file, temp] : [], timeoutMs: 10000, signal: this.abort.signal });
56
64
  if (this.abort.signal.aborted)
57
65
  throw new Error('DENIED_REVOKED');
58
- return JSON.parse(output);
66
+ const result = JSON.parse(output);
67
+ if (tool === 'local_replace' && ['DENIED_REPLACE_NOT_FOUND', 'DENIED_REPLACE_AMBIGUOUS', 'DENIED_SIZE'].includes(result.denied))
68
+ throw new Error(result.denied);
69
+ return result;
59
70
  }
60
71
  async authorizedFile(value, allowMissing = false) {
61
72
  if (typeof value !== 'string' || !value || value.includes('\0') || path.isAbsolute(value) || value.split(/[\\/]/).some(p => p === '..' || sensitive(p)))
@@ -24,11 +24,12 @@ const schemas = {
24
24
  local_read: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false },
25
25
  local_search: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' } }, required: ['path', 'query'], additionalProperties: false },
26
26
  local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string', description: 'The complete new contents of the entire file. Do not send a diff, patch, hunk, or edit instructions.' } }, required: ['path', 'content'], additionalProperties: false },
27
+ local_replace: { type: 'object', properties: { path: { type: 'string' }, old: { type: 'string', minLength: 1 }, new: { type: 'string' } }, required: ['path', 'old', 'new'], additionalProperties: false },
27
28
  local_test: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'], additionalProperties: false },
28
29
  };
29
30
  /** Same immutable-by-copy inventory used by native admission and MCP discovery. */
30
31
  export function localToolInventory() {
31
- return LOCAL_TOOL_NAMES.map(name => ({ name, description: name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
32
+ return LOCAL_TOOL_NAMES.map(name => ({ name, description: name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : name === 'local_replace' ? 'Replace exactly one occurrence of old with new in the file at path. Read the file first; old must match exactly, including whitespace, and appear once. Prefer this for small changes; use local_edit only to write a whole file.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
32
33
  }
33
34
  /** Job-only JSON MCP endpoint; intentionally has no Gateway transport or discovery. */
34
35
  export class LocalToolServer {
@@ -5,6 +5,12 @@ const fs = require('node:fs');
5
5
  const input = JSON.parse(process.argv[1]);
6
6
  const isPatchContent = ${isPatchContent.toString()};
7
7
  if (input.tool === 'local_edit' && isPatchContent(input.content)) throw Error('DENIED_PATCH_CONTENT');
8
+ if (input.tool === 'local_replace') {
9
+ if (typeof input.old !== 'string' || !input.old || Buffer.byteLength(input.old) > 1048576) throw Error('DENIED_REPLACE_OLD');
10
+ if (typeof input.new !== 'string' || Buffer.byteLength(input.new) > 1048576) throw Error('DENIED_CONTENT');
11
+ if (isPatchContent(input.new)) throw Error('DENIED_PATCH_CONTENT');
12
+ }
13
+ try {
8
14
  let current = '';
9
15
  for (const part of input.path.split('/').filter(Boolean)) {
10
16
  current += '/' + part;
@@ -27,8 +33,20 @@ try {
27
33
  if (actual.ino !== before.ino || actual.dev !== before.dev || actual.ino !== final.ino || actual.dev !== final.dev || actual.nlink !== 1) throw Error('DENIED_RACE');
28
34
  }
29
35
  let result;
30
- if (input.tool === 'local_edit') {
31
- const data = Buffer.from(input.content, 'utf8');
36
+ if (input.tool === 'local_edit' || input.tool === 'local_replace') {
37
+ let content = input.content;
38
+ if (input.tool === 'local_replace') {
39
+ const buffer = Buffer.alloc(1048577);
40
+ const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
41
+ if (count > 1048576) throw Error('DENIED_SIZE');
42
+ const original = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(buffer.subarray(0, count));
43
+ const first = original.indexOf(input.old);
44
+ if (first < 0) throw Error('DENIED_REPLACE_NOT_FOUND');
45
+ if (original.indexOf(input.old, first + 1) >= 0) throw Error('DENIED_REPLACE_AMBIGUOUS');
46
+ content = original.slice(0, first) + input.new + original.slice(first + input.old.length);
47
+ }
48
+ const data = Buffer.from(content, 'utf8');
49
+ if (data.length > 1048576) throw Error('DENIED_SIZE');
32
50
  const temp = fs.openSync(input.temp, fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, before ? before.mode & 0o777 : 0o600);
33
51
  try {
34
52
  fs.writeFileSync(temp, data);
@@ -37,6 +55,7 @@ try {
37
55
  if (tempStat.nlink !== 1 || tempStat.ino !== tempFinal.ino || tempStat.dev !== tempFinal.dev) throw Error('DENIED_RACE');
38
56
  if (actual) {
39
57
  const still = fs.lstatSync(input.path);
58
+ if (input.tool === 'local_replace' && (still.size !== actual.size || still.mtimeMs !== actual.mtimeMs || still.ctimeMs !== actual.ctimeMs)) throw Error('DENIED_RACE');
40
59
  if (still.isSymbolicLink() || still.ino !== actual.ino || still.dev !== actual.dev || still.nlink !== 1) throw Error('DENIED_RACE');
41
60
  fs.renameSync(input.temp, input.path);
42
61
  } else {
@@ -57,4 +76,8 @@ try {
57
76
  }
58
77
  process.stdout.write(JSON.stringify(result));
59
78
  } finally { if (fd !== null) fs.closeSync(fd); }
79
+ } catch (error) {
80
+ if (input.tool !== 'local_replace' || !['DENIED_REPLACE_NOT_FOUND','DENIED_REPLACE_AMBIGUOUS','DENIED_SIZE'].includes(error.message)) throw error;
81
+ process.stdout.write(JSON.stringify({denied: error.message}));
82
+ }
60
83
  `;
@@ -1,4 +1,4 @@
1
- export const CLAUDE_LOCAL_MCP_TOOLS = Object.freeze(["local_read", "local_search", "local_edit", "local_test"].map(name => `mcp__ai_local__${name}`));
1
+ export const CLAUDE_LOCAL_MCP_TOOLS = Object.freeze(["local_read", "local_search", "local_edit", "local_replace", "local_test"].map(name => `mcp__ai_local__${name}`));
2
2
  function allow(reasonCode) {
3
3
  return {
4
4
  hookSpecificOutput: {
@@ -0,0 +1,12 @@
1
+ /** Resolves after the delay or at once on abort, and never leaves a listener on a long-lived signal. */
2
+ export function abortableDelay(milliseconds, signal) {
3
+ return new Promise(resolve => {
4
+ if (signal.aborted) {
5
+ resolve();
6
+ return;
7
+ }
8
+ const onAbort = () => { clearTimeout(timer); resolve(); };
9
+ const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, milliseconds);
10
+ signal.addEventListener("abort", onAbort, { once: true });
11
+ });
12
+ }
@@ -0,0 +1,66 @@
1
+ const SOCKET_ID = /^\d{1,20}\.\d{1,20}$/;
2
+ const DEFAULT_ACTIVITY_TIMEOUT_SECONDS = 30;
3
+ export function subscribeFrame(channel, auth) {
4
+ return JSON.stringify({ event: "pusher:subscribe", data: { channel, auth } });
5
+ }
6
+ export function pingFrame() {
7
+ return JSON.stringify({ event: "pusher:ping", data: {} });
8
+ }
9
+ export function pongFrame() {
10
+ return JSON.stringify({ event: "pusher:pong", data: {} });
11
+ }
12
+ export function decodeFrame(raw, expected) {
13
+ let frame;
14
+ try {
15
+ frame = JSON.parse(raw);
16
+ }
17
+ catch {
18
+ return { kind: "ignored" };
19
+ }
20
+ if (!frame || typeof frame !== "object" || Array.isArray(frame))
21
+ return { kind: "ignored" };
22
+ const { event, channel, data } = frame;
23
+ if (typeof event !== "string")
24
+ return { kind: "ignored" };
25
+ switch (event) {
26
+ case "pusher:connection_established": {
27
+ const payload = parseData(data);
28
+ const socketId = payload?.socket_id;
29
+ if (typeof socketId !== "string" || !SOCKET_ID.test(socketId))
30
+ return { kind: "error" };
31
+ const timeout = payload?.activity_timeout;
32
+ const activityTimeoutSeconds = typeof timeout === "number" && Number.isInteger(timeout) && timeout >= 5 && timeout <= 600
33
+ ? timeout : DEFAULT_ACTIVITY_TIMEOUT_SECONDS;
34
+ return { kind: "connected", socketId, activityTimeoutSeconds };
35
+ }
36
+ case "pusher_internal:subscription_succeeded":
37
+ return channel === expected.channel ? { kind: "subscribed" } : { kind: "ignored" };
38
+ case "pusher:ping":
39
+ return { kind: "ping" };
40
+ case "pusher:pong":
41
+ return { kind: "pong" };
42
+ case "pusher:error": {
43
+ const code = parseData(data)?.code;
44
+ return typeof code === "number" ? { kind: "error", code } : { kind: "error" };
45
+ }
46
+ case "pusher:subscription_error":
47
+ return { kind: "error" };
48
+ default:
49
+ break;
50
+ }
51
+ if (event.startsWith("client-") || event.startsWith("pusher"))
52
+ return { kind: "ignored" };
53
+ return channel === expected.channel && event === expected.event ? { kind: "signal" } : { kind: "ignored" };
54
+ }
55
+ function parseData(data) {
56
+ if (typeof data === "string") {
57
+ try {
58
+ const parsed = JSON.parse(data);
59
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ return data && typeof data === "object" && !Array.isArray(data) ? data : undefined;
66
+ }
@@ -0,0 +1,144 @@
1
+ import WebSocket from "ws";
2
+ import { decodeFrame, pingFrame, pongFrame, subscribeFrame } from "./pusher-protocol.js";
3
+ const CONNECT_TIMEOUT_MS = 10_000;
4
+ const SUBSCRIBE_TIMEOUT_MS = 10_000;
5
+ /** How long close() waits for the server's close frame before dropping the connection. */
6
+ const CLOSE_GRACE_MS = 1_000;
7
+ const PONG_TIMEOUT_MS = 10_000;
8
+ const MAX_PING_INTERVAL_MS = 30_000;
9
+ /*
10
+ * ws sends no User-Agent of its own, and the ELSA load balancer's WAF (AWS managed common rules) answers a request
11
+ * without one with 403 before Reverb sees it. Measured against the development Reverb on 2026-10-24.
12
+ */
13
+ export function pushSocketOptions(packageVersion, connectTimeoutMs) {
14
+ return { handshakeTimeout: connectTimeoutMs, headers: { "User-Agent": `mtec-ai-local-runner/${packageVersion}` } };
15
+ }
16
+ export function pushSocketUrl(target, packageVersion) {
17
+ const url = new URL(target.url);
18
+ if (url.protocol !== "wss:")
19
+ throw new Error("PUSH_URL_NOT_WSS");
20
+ url.pathname = `/app/${encodeURIComponent(target.appKey)}`;
21
+ url.search = new URLSearchParams({ protocol: "7", client: "mtec-ai-local-runner", version: packageVersion, flash: "false" }).toString();
22
+ return url.toString();
23
+ }
24
+ export class ReverbSocket {
25
+ target;
26
+ events;
27
+ options;
28
+ socket;
29
+ ended = false;
30
+ ownerClosed = false;
31
+ pingIntervalMs;
32
+ connectTimer;
33
+ subscribeTimer;
34
+ pingTimer;
35
+ pongTimer;
36
+ constructor(target, events, options) {
37
+ this.target = target;
38
+ this.events = events;
39
+ this.options = options;
40
+ this.pingIntervalMs = options.maxPingIntervalMs ?? MAX_PING_INTERVAL_MS;
41
+ const connectTimeoutMs = options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
42
+ const url = pushSocketUrl(target, options.packageVersion);
43
+ const create = options.createSocket ?? ((value) => new WebSocket(value, pushSocketOptions(options.packageVersion, connectTimeoutMs)));
44
+ this.socket = create(url);
45
+ this.connectTimer = setTimeout(() => this.fail("connect_timeout"), connectTimeoutMs);
46
+ this.socket.on("message", data => this.onMessage(String(data)));
47
+ this.socket.on("close", () => this.fail("closed"));
48
+ this.socket.on("error", () => this.fail("error"));
49
+ }
50
+ subscribe(auth) {
51
+ if (this.ended)
52
+ return;
53
+ clearTimeout(this.subscribeTimer);
54
+ this.subscribeTimer = setTimeout(() => this.fail("subscribe_timeout"), this.options.subscribeTimeoutMs ?? SUBSCRIBE_TIMEOUT_MS);
55
+ this.send(subscribeFrame(this.target.channel, auth));
56
+ }
57
+ close() {
58
+ if (this.ended)
59
+ return;
60
+ this.ownerClosed = true;
61
+ this.end();
62
+ try {
63
+ this.socket.close();
64
+ }
65
+ catch { /* Closing a socket that is already gone is fine. */ }
66
+ // ws waits up to 30 s for the server's close frame, which would hold a stopping daemon open.
67
+ const drop = setTimeout(() => { try {
68
+ this.socket.terminate();
69
+ }
70
+ catch { /* Already gone. */ } }, CLOSE_GRACE_MS);
71
+ drop.unref?.();
72
+ }
73
+ onMessage(raw) {
74
+ if (this.ended)
75
+ return;
76
+ this.armPing();
77
+ const frame = decodeFrame(raw, { channel: this.target.channel, event: this.target.event });
78
+ switch (frame.kind) {
79
+ case "connected":
80
+ clearTimeout(this.connectTimer);
81
+ this.pingIntervalMs = Math.min(frame.activityTimeoutSeconds * 1000, this.options.maxPingIntervalMs ?? MAX_PING_INTERVAL_MS);
82
+ this.armPing();
83
+ this.events.onConnected(frame.socketId);
84
+ break;
85
+ case "subscribed":
86
+ clearTimeout(this.subscribeTimer);
87
+ this.events.onSubscribed();
88
+ break;
89
+ case "signal":
90
+ this.events.onSignal();
91
+ break;
92
+ case "ping":
93
+ this.send(pongFrame());
94
+ break;
95
+ case "error":
96
+ // The Pusher close code decides how soon the transport reconnects (4000-4099: not unchanged).
97
+ this.fail(frame.code !== undefined && Number.isInteger(frame.code) && frame.code >= 4000 && frame.code <= 4299 ? `protocol_error_${frame.code}` : "protocol_error");
98
+ break;
99
+ default:
100
+ break;
101
+ }
102
+ }
103
+ armPing() {
104
+ clearTimeout(this.pongTimer);
105
+ this.pongTimer = undefined;
106
+ clearTimeout(this.pingTimer);
107
+ if (this.ended)
108
+ return;
109
+ this.pingTimer = setTimeout(() => {
110
+ this.send(pingFrame());
111
+ if (this.ended)
112
+ return;
113
+ this.pongTimer = setTimeout(() => this.fail("pong_timeout"), this.options.pongTimeoutMs ?? PONG_TIMEOUT_MS);
114
+ }, this.pingIntervalMs);
115
+ }
116
+ send(frame) {
117
+ if (this.ended)
118
+ return;
119
+ try {
120
+ this.socket.send(frame);
121
+ }
122
+ catch {
123
+ this.fail("send_failed");
124
+ }
125
+ }
126
+ fail(reason) {
127
+ if (this.ended)
128
+ return;
129
+ this.end();
130
+ try {
131
+ this.socket.terminate();
132
+ }
133
+ catch { /* The socket may already be gone. */ }
134
+ if (!this.ownerClosed)
135
+ this.events.onClosed(reason);
136
+ }
137
+ end() {
138
+ this.ended = true;
139
+ clearTimeout(this.connectTimer);
140
+ clearTimeout(this.subscribeTimer);
141
+ clearTimeout(this.pingTimer);
142
+ clearTimeout(this.pongTimer);
143
+ }
144
+ }