granttap-mcp 0.8.58 → 0.8.60

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.
@@ -5,11 +5,13 @@ import { configDir } from "../config";
5
5
 
6
6
  const LIMIT = 500;
7
7
  const MAX_AGE_MS = 7 * 24 * 60 * 60_000;
8
- const PROCESSING_LEASE_MS = 5 * 60_000;
8
+ // Cover the ten-minute provider call and completion propagation before retry.
9
+ const PROCESSING_LEASE_MS = 11 * 60_000;
9
10
 
10
11
  type DeliveryRecord = {
11
12
  state: "processing" | "completed";
12
13
  updatedAt: number;
14
+ processingAcknowledged?: boolean;
13
15
  };
14
16
 
15
17
  export type DeliveryStart = "started" | "processing" | "completed";
@@ -55,6 +57,19 @@ function save(records: Record<string, DeliveryRecord>, now: number): void {
55
57
  chmodSync(deliveryLedgerPath(), 0o600);
56
58
  }
57
59
 
60
+ /** Only validated provider work may emit a non-terminal processing receipt. */
61
+ export function acknowledgeProcessingDelivery(messageId: string, now = Date.now()): void {
62
+ const records = load();
63
+ if (records[messageId]?.state !== "processing") return;
64
+ records[messageId] = { state: "processing", updatedAt: now, processingAcknowledged: true };
65
+ save(records, now);
66
+ }
67
+
68
+ export function processingDeliveryAcknowledged(messageId: string): boolean {
69
+ const record = load()[messageId];
70
+ return record?.state === "processing" && record.processingAcknowledged === true;
71
+ }
72
+
58
73
  /** Acquire a durable processing lease before invoking an agent. */
59
74
  export function beginDelivery(messageId: string, now = Date.now()): DeliveryStart {
60
75
  const records = load();
@@ -3,10 +3,12 @@ import { loadAccountLink } from "../account-recovery/link";
3
3
 
4
4
  /** Account computer identity is stable across QR rooms and provider sessions. */
5
5
  export function managedAdmissionOptions(config: PeerConfig,
6
- request: typeof fetch = fetch): Pick<RelayClientOptions, "upgradeHeaders"> {
6
+ request: typeof fetch = fetch): Pick<RelayClientOptions, "upgradeHeaders" | "connectionLeaseExpiresAt"> {
7
7
  const url = new URL(config.relayUrl);
8
8
  if (config.role !== "machine" || url.protocol !== "wss:" || url.hostname !== "relay.granttap.com") return {};
9
- return { upgradeHeaders: async (): Promise<Record<string, string>> => {
9
+ let expiresAt: number | null = null;
10
+ return { connectionLeaseExpiresAt: () => expiresAt,
11
+ upgradeHeaders: async (): Promise<Record<string, string>> => {
10
12
  const account = loadAccountLink();
11
13
  if (!account) return {};
12
14
  const response = await request("https://granttap.com/api/account/machine/admission", {
@@ -25,6 +27,7 @@ export function managedAdmissionOptions(config: PeerConfig,
25
27
  || body.expiresAt <= Date.now() || body.expiresAt > Date.now() + 360_000) {
26
28
  throw new Error("Managed admission returned an invalid lease.");
27
29
  }
30
+ expiresAt = body.expiresAt;
28
31
  return { "X-GrantTap-Admission": body.ticket };
29
32
  } };
30
33
  }
@@ -11,3 +11,10 @@ Backbone requests responsive.
11
11
 
12
12
  Integration tests remain in `tests/monitor`; background publication tests live
13
13
  beside `mesh-publication`. This module uses the repository MIT License.
14
+
15
+ `message-delivery/index.ts` reserves the durable delivery lease, then
16
+ emits a non-terminal processing receipt only after workspace, attachment and
17
+ provider validation. Duplicate validated requests replay that receipt without
18
+ starting another provider run. The eleven-minute lease covers the ten-minute
19
+ provider timeout. New-task startup events are status updates; the correlated
20
+ result is terminal. Delivery integration tests live in `tests/delivery`.
@@ -32,7 +32,7 @@ import {
32
32
  handleSubscription,
33
33
  } from "./session-commands";
34
34
  import { handleTaskCreate } from "./task-create";
35
- import { handleUserMessage } from "./user-message";
35
+ import { handleInboundUserMessage } from "../message-delivery";
36
36
  import { publishSessionEvents } from "./catalog";
37
37
  import { publishSessionImage } from '../../session-images';
38
38
  import { publishTaskWorkspace } from '../../task-files/relay';
@@ -201,42 +201,6 @@ function handleInboundPairingJoin(payload: Extract<Payload, { type: "pairing.joi
201
201
  return true;
202
202
  }
203
203
 
204
- async function handleInboundUserMessage(
205
- client: RelayClient, payload: Extract<Payload, { type: "user.message" }>,
206
- publish: (forceHistory?: boolean) => Promise<void>,
207
- ): Promise<boolean> {
208
- // Correlated replies belong to the MCP `ask` waiter in one process.
209
- if (payload.requestId) return false;
210
- let deliveryStarted = false;
211
- if (payload.messageId) {
212
- const state = beginDelivery(payload.messageId);
213
- if (state === "completed") {
214
- await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
215
- return true;
216
- }
217
- if (state === "processing") return false;
218
- deliveryStarted = true;
219
- }
220
- try {
221
- const outcome = await handleUserMessage(client, payload);
222
- if (payload.messageId && deliveryStarted) {
223
- if (outcome === "rejected") {
224
- // The phone sends the message again with what was missing; the
225
- // ledger must let that second copy through.
226
- abandonDelivery(payload.messageId);
227
- } else {
228
- completeDelivery(payload.messageId);
229
- await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
230
- }
231
- }
232
- void publish().catch(() => {});
233
- return true;
234
- } catch {
235
- if (payload.messageId && deliveryStarted) abandonDelivery(payload.messageId);
236
- return false;
237
- }
238
- }
239
-
240
204
  async function handleInboundTaskCreate(
241
205
  client: RelayClient, payload: Extract<Payload, { type: "project.task.create" }>,
242
206
  publish: (forceHistory?: boolean) => Promise<void>,
@@ -22,7 +22,8 @@ import { DELIVERY_TIMEOUT_MS } from "./receipts";
22
22
  async function startNewAgentTask(input: {
23
23
  client: RelayClient;
24
24
  message: UserMessage;
25
- say: (text: string, sessionId?: string, wake?: boolean) => Promise<unknown>;
25
+ say: (text: string, sessionId?: string, wake?: boolean, kind?: "status" | "response") => Promise<unknown>;
26
+ onProcessing?: () => Promise<void>;
26
27
  }): Promise<"rejected" | void> {
27
28
  const { client, message, say } = input;
28
29
  const agent = message.agent ?? "codex";
@@ -57,7 +58,8 @@ async function startNewAgentTask(input: {
57
58
  }
58
59
  return "rejected";
59
60
  }
60
- await say(`Creating a new ${displayName} task…`);
61
+ await input.onProcessing?.();
62
+ await say(`Creating a new ${displayName} task…`, undefined, false, "status");
61
63
  const create = {
62
64
  claude: createClaudeSession,
63
65
  codex: createCodexSession,
@@ -77,16 +79,17 @@ async function startNewAgentTask(input: {
77
79
  export async function handleUserMessage(
78
80
  client: RelayClient,
79
81
  message: UserMessage,
82
+ onProcessing?: () => Promise<void>,
80
83
  ): Promise<"accepted" | "rejected" | void> {
81
- const say = (text: string, sessionId?: string, wake = false) => {
82
- const payload = agentEventForUserMessage(message, text, sessionId);
84
+ const say = (text: string, sessionId?: string, wake = false, kind: "status" | "response" = "response") => {
85
+ const payload = agentEventForUserMessage(message, text, sessionId, kind);
83
86
  const options = { ttlMs: 15 * 60_000, wake: wake || undefined };
84
87
  return (sessionId
85
88
  ? sendSessionPayload(client, payload, sessionId, "phone", options)
86
89
  : client.send(payload, "phone", options)).catch(() => {});
87
90
  };
88
91
  if (!message.sessionId) {
89
- return startNewAgentTask({ client, message, say });
92
+ return startNewAgentTask({ client, message, say, onProcessing });
90
93
  }
91
94
  const resolved = resolveMessageAttachments(message, (attachmentId) => takeAttachment(attachmentId, client.room));
92
95
  if (!resolved.ok) {
@@ -135,6 +138,7 @@ export async function handleUserMessage(
135
138
 
136
139
  // No "sent, waiting" line from a middleman: the delivery receipt already
137
140
  // marks the person's bubble, and the next words in the chat are the answer.
141
+ await onProcessing?.();
138
142
  const startedAt = Date.now();
139
143
  const result = await deliverToSession(target, message.text, DELIVERY_TIMEOUT_MS, attachments, {
140
144
  preferredMcp: message.preferredMcp,
@@ -0,0 +1,11 @@
1
+ # Phone message delivery
2
+
3
+ Public entry: `index.ts`. The handler owns the durable delivery lease and receipt
4
+ sequence for non-correlated phone messages. Validation happens before the first
5
+ processing receipt, and duplicate validated messages never start another run.
6
+ Terminal responses remain correlated to the original message id.
7
+
8
+ Integration tests: `tests/delivery/processing-receipt.test.ts`; attachment rejection
9
+ coverage: `tests/attachment/attachment-uploads.test.ts`. See the monitor README.
10
+
11
+ This module uses the repository MIT License.
@@ -0,0 +1,44 @@
1
+ import type { Payload } from "../../../../../packages/protocol/schema";
2
+ import type { RelayClient } from "../../../../../packages/core/relay-client";
3
+ import { abandonDelivery, beginDelivery, completeDelivery,
4
+ acknowledgeProcessingDelivery, processingDeliveryAcknowledged } from "../../delivery";
5
+ import { sendDeliveryReceipt } from "../handlers/receipts";
6
+ import { handleUserMessage } from "../handlers/user-message";
7
+
8
+ /** Receipts acknowledge durable receipt, while the correlated response is terminal. */
9
+ export async function handleInboundUserMessage(
10
+ client: RelayClient, payload: Extract<Payload, { type: "user.message" }>,
11
+ publish: (forceHistory?: boolean) => Promise<void>,
12
+ ): Promise<boolean> {
13
+ if (payload.requestId) return false;
14
+ let deliveryStarted = false;
15
+ if (payload.messageId) {
16
+ const state = beginDelivery(payload.messageId);
17
+ if (state !== "started") {
18
+ if (state === "completed" || processingDeliveryAcknowledged(payload.messageId)) {
19
+ await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
20
+ }
21
+ return true;
22
+ }
23
+ deliveryStarted = true;
24
+ }
25
+ try {
26
+ const outcome = await handleUserMessage(client, payload, async () => {
27
+ if (!payload.messageId) return;
28
+ acknowledgeProcessingDelivery(payload.messageId);
29
+ await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
30
+ });
31
+ if (payload.messageId && deliveryStarted) {
32
+ if (outcome === "rejected") abandonDelivery(payload.messageId);
33
+ else {
34
+ completeDelivery(payload.messageId);
35
+ await sendDeliveryReceipt(client, payload.messageId, "accepted", undefined, payload.sessionId);
36
+ }
37
+ }
38
+ void publish().catch(() => {});
39
+ return true;
40
+ } catch {
41
+ if (payload.messageId && deliveryStarted) abandonDelivery(payload.messageId);
42
+ return false;
43
+ }
44
+ }
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
10
10
  Install the supported CLI on this computer, then run:
11
11
 
12
12
  ```bash
13
- npm install -g granttap-mcp@0.8.58
13
+ npm install -g granttap-mcp@0.8.60
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -4,7 +4,7 @@
4
4
  "command": "node",
5
5
  "args": [
6
6
  "-e",
7
- "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.58\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
7
+ "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.60\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
8
8
  ]
9
9
  }
10
10
  }
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
7
7
  const { existsSync } = require("node:fs");
8
8
  const { delimiter, dirname, join } = require("node:path");
9
9
 
10
- const PACKAGE = "granttap-mcp@0.8.58";
10
+ const PACKAGE = "granttap-mcp@0.8.60";
11
11
  const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
12
12
  const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
13
13
  ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.58",
3
+ "version": "0.8.60",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,8 @@ export type PeerConfig = {
22
22
  export type RelayClientOptions = {
23
23
  /** Fresh managed admission at each socket upgrade; own relays need no ticket. */
24
24
  upgradeHeaders?: () => Promise<Record<string, string>>;
25
+ /** Verified server lease deadline, refreshed without reconnecting the socket. */
26
+ connectionLeaseExpiresAt?: () => number | null;
25
27
  autoReconnect?: boolean;
26
28
  minReconnectMs?: number;
27
29
  maxReconnectMs?: number;
@@ -10,6 +10,7 @@
10
10
  * the Cloudflare Durable Object deployment.
11
11
  */
12
12
  import WebSocket from "ws";
13
+ import { ManagedRelayLease, RelaySocketHealth } from "./socket-lifecycle";
13
14
  import { OutboundFrameQueue, sendEncryptedPayload } from "./outbound-scheduler";
14
15
  import { createHash } from "node:crypto";
15
16
  import { Envelope, Payload, type Role } from "../protocol/schema";
@@ -31,8 +32,8 @@ export class RelayClient {
31
32
  private connectPromise?: Promise<void>;
32
33
  private reconnectTimer?: NodeJS.Timeout;
33
34
  private reconnectDelay: number;
34
- private livenessTimer?: NodeJS.Timeout;
35
- private awaitingPong = false;
35
+ private readonly socketHealth = new RelaySocketHealth();
36
+ private readonly admissionLease = new ManagedRelayLease();
36
37
  private readonly sessionKeys = new Map<string, string>();
37
38
  /**
38
39
  * Delivery ids are relay-controlled metadata, so replay protection is bound
@@ -96,14 +97,12 @@ export class RelayClient {
96
97
  reject(new Error(`relay connect timeout (${this.cfg.relayUrl})`));
97
98
  }, timeoutMs);
98
99
 
99
- ws.on("pong", () => {
100
- if (this.ws === ws) this.awaitingPong = false;
101
- });
102
100
  ws.on("open", () => {
103
101
  opened = true;
104
102
  clearTimeout(timer);
105
103
  this.reconnectDelay = this.opts.minReconnectMs ?? 1_000;
106
- this.startLivenessChecks(ws);
104
+ this.socketHealth.start(ws, this.opts.pingIntervalMs ?? 20_000);
105
+ this.admissionLease.start(ws, this.opts);
107
106
  // Announce ourselves. The envelope's cleartext (room, from) is what the
108
107
  // relay uses to register this socket; the Hello body stays encrypted.
109
108
  void this
@@ -122,7 +121,8 @@ export class RelayClient {
122
121
  ws.on("close", () => {
123
122
  frames.cancel();
124
123
  clearTimeout(timer);
125
- this.stopLivenessChecks();
124
+ this.socketHealth.stop(ws);
125
+ this.admissionLease.stop(ws);
126
126
  if (this.ws === ws) this.ws = undefined;
127
127
  if (!opened) reject(new Error(`relay connection closed (${this.cfg.relayUrl})`));
128
128
  this.scheduleReconnect();
@@ -317,7 +317,8 @@ export class RelayClient {
317
317
 
318
318
  close(): void {
319
319
  this.frames?.cancel();
320
- this.stopLivenessChecks();
320
+ this.socketHealth.stop();
321
+ this.admissionLease.stop();
321
322
  this.intentionalClose = true;
322
323
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
323
324
  this.reconnectTimer = undefined;
@@ -329,48 +330,6 @@ export class RelayClient {
329
330
  return this.ws?.readyState === WebSocket.OPEN;
330
331
  }
331
332
 
332
- /**
333
- * Prove the socket is still there, rather than trusting `readyState`.
334
- *
335
- * A machine that changes network — joining a hotspot, waking on another
336
- * Wi-Fi — leaves its relay socket half-open. No close event arrives, so
337
- * nothing reconnects, and the computer keeps reporting itself as connected
338
- * while everything it sends goes nowhere. A ping with a deadline is the only
339
- * thing that tells the two apart.
340
- */
341
- private startLivenessChecks(ws: WebSocket): void {
342
- this.stopLivenessChecks();
343
- const interval = this.opts.pingIntervalMs ?? 20_000;
344
- this.awaitingPong = false;
345
- this.livenessTimer = setInterval(() => {
346
- if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) {
347
- this.stopLivenessChecks();
348
- return;
349
- }
350
- if (this.awaitingPong) {
351
- // The previous ping was never answered. Terminating fires `close`,
352
- // which is what schedules the reconnect.
353
- this.stopLivenessChecks();
354
- ws.terminate();
355
- return;
356
- }
357
- this.awaitingPong = true;
358
- try {
359
- ws.ping();
360
- } catch {
361
- this.stopLivenessChecks();
362
- ws.terminate();
363
- }
364
- }, interval);
365
- this.livenessTimer.unref?.();
366
- }
367
-
368
- private stopLivenessChecks(): void {
369
- if (this.livenessTimer) clearInterval(this.livenessTimer);
370
- this.livenessTimer = undefined;
371
- this.awaitingPong = false;
372
- }
373
-
374
333
  private scheduleReconnect(): void {
375
334
  if (!this.opts.autoReconnect || this.intentionalClose || this.reconnectTimer) return;
376
335
  const delay = this.reconnectDelay;
@@ -0,0 +1,11 @@
1
+ # Relay socket lifecycle
2
+
3
+ Public entry point: `index.ts`. `RelayClient` uses this module for network health
4
+ and short managed admission leases. Admission refresh stays on the existing
5
+ socket and does not rotate task keys or reload transcript state. A failed lookup
6
+ cannot extend the server's signed expiry. Closing or replacing a socket cancels
7
+ only its own timers.
8
+
9
+ The real WebSocket integration is in `tests/core/managed-relay-renewal.test.ts`.
10
+
11
+ Licensed under the MIT License in the repository root.
@@ -0,0 +1,2 @@
1
+ export { ManagedRelayLease } from "./managed-relay-lease";
2
+ export { RelaySocketHealth } from "./relay-socket-health";
@@ -0,0 +1,49 @@
1
+ import WebSocket from "ws";
2
+ import type { RelayClientOptions } from "../relay-client-types";
3
+
4
+ /** Renew a computer's signed admission on its existing socket; no chat keys change. */
5
+ export class ManagedRelayLease {
6
+ private socket?: WebSocket;
7
+ private timer?: NodeJS.Timeout;
8
+
9
+ start(socket: WebSocket, options: RelayClientOptions): void {
10
+ this.stop();
11
+ this.socket = socket;
12
+ socket.once("close", () => this.stop(socket));
13
+ this.schedule(socket, options);
14
+ }
15
+
16
+ stop(socket?: WebSocket): void {
17
+ if (socket && this.socket !== socket) return;
18
+ if (this.timer) clearTimeout(this.timer);
19
+ this.timer = undefined;
20
+ this.socket = undefined;
21
+ }
22
+
23
+ private schedule(socket: WebSocket, options: RelayClientOptions, retry = false): void {
24
+ const expiry = options.connectionLeaseExpiresAt?.();
25
+ if (!expiry || !Number.isFinite(expiry) || !options.upgradeHeaders) return;
26
+ const remaining = expiry - Date.now();
27
+ const margin = Math.min(30_000, Math.max(10, remaining * 0.15));
28
+ const delay = retry ? 10_000 : Math.max(10, remaining - margin);
29
+ this.timer = setTimeout(() => void this.renew(socket, options), delay);
30
+ this.timer.unref();
31
+ }
32
+
33
+ private async renew(socket: WebSocket, options: RelayClientOptions): Promise<void> {
34
+ this.timer = undefined;
35
+ if (this.socket !== socket || socket.readyState !== WebSocket.OPEN) return;
36
+ try {
37
+ const headers = await options.upgradeHeaders!();
38
+ if (this.socket !== socket || socket.readyState !== WebSocket.OPEN) return;
39
+ const ticket = headers["X-GrantTap-Admission"];
40
+ if (!ticket) throw new Error("No refreshed relay admission");
41
+ await new Promise<void>((resolve, reject) => socket.send(
42
+ JSON.stringify({ type: "relay.admission", ticket }), error => error ? reject(error) : resolve()));
43
+ this.schedule(socket, options);
44
+ } catch {
45
+ // The relay's signed expiry remains authoritative; a failed lookup cannot extend it.
46
+ if (this.socket === socket && socket.readyState === WebSocket.OPEN) this.schedule(socket, options, true);
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,31 @@
1
+ import WebSocket from "ws";
2
+
3
+ /** Detect half-open network sockets independently of managed authorization. */
4
+ export class RelaySocketHealth {
5
+ private socket?: WebSocket;
6
+ private timer?: NodeJS.Timeout;
7
+ private awaitingPong = false;
8
+
9
+ start(socket: WebSocket, interval: number): void {
10
+ this.stop();
11
+ this.socket = socket;
12
+ socket.on("pong", () => { if (this.socket === socket) this.awaitingPong = false; });
13
+ this.timer = setInterval(() => {
14
+ if (this.socket !== socket || socket.readyState !== WebSocket.OPEN) { this.stop(socket); return; }
15
+ if (this.awaitingPong) { this.stop(socket); socket.terminate(); return; }
16
+ this.awaitingPong = true;
17
+ try { socket.ping(); }
18
+ catch { this.stop(socket); socket.terminate(); }
19
+ }, interval);
20
+ this.timer.unref();
21
+ socket.once("close", () => this.stop(socket));
22
+ }
23
+
24
+ stop(socket?: WebSocket): void {
25
+ if (socket && this.socket !== socket) return;
26
+ if (this.timer) clearInterval(this.timer);
27
+ this.timer = undefined;
28
+ this.socket = undefined;
29
+ this.awaitingPong = false;
30
+ }
31
+ }