@chatroomcp/chatroom 0.1.5 → 0.1.7

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.
@@ -3,10 +3,6 @@ import { type CloudEntitlement, type CloudLeaseState, type CloudServiceId } from
3
3
  declare const recoveryCredentialSchema: z.ZodObject<{
4
4
  recoveryKey: z.ZodString;
5
5
  }, z.core.$strict>;
6
- declare const registrationSchema: z.ZodObject<{
7
- installationId: z.ZodString;
8
- registered: z.ZodLiteral<true>;
9
- }, z.core.$strict>;
10
6
  declare const sessionSchema: z.ZodObject<{
11
7
  purchaseToken: z.ZodString;
12
8
  managementUrl: z.ZodString;
@@ -29,15 +25,9 @@ declare const restoreSchema: z.ZodObject<{
29
25
  publicPrefix: z.ZodNullable<z.ZodString>;
30
26
  recoveryKey: z.ZodString;
31
27
  }, z.core.$strict>;
32
- declare const prefixSchema: z.ZodObject<{
33
- publicPrefix: z.ZodString;
34
- mcpUrl: z.ZodString;
35
- webUrl: z.ZodString;
36
- }, z.core.$strict>;
37
28
  export declare class CloudApiClient {
38
29
  private readonly baseUrl;
39
30
  constructor(baseUrl: string);
40
- registerDevice(identity: DeviceIdentity): Promise<z.infer<typeof registrationSchema>>;
41
31
  createManagementSession(identity: DeviceIdentity): Promise<z.infer<typeof sessionSchema>>;
42
32
  status(identity: DeviceIdentity, purchaseToken: string | null): Promise<{
43
33
  managementSessionActive: boolean;
@@ -49,7 +39,6 @@ export declare class CloudApiClient {
49
39
  }>;
50
40
  restore(identity: DeviceIdentity, recoveryKey: string): Promise<z.infer<typeof restoreSchema>>;
51
41
  replaceRecoveryKey(identity: DeviceIdentity): Promise<z.infer<typeof recoveryCredentialSchema>>;
52
- setPrefix(identity: DeviceIdentity, prefix: string): Promise<z.infer<typeof prefixSchema>>;
53
42
  lease(identity: DeviceIdentity, requestedServices: CloudServiceId[]): Promise<CloudLeaseState>;
54
43
  private devicePost;
55
44
  }
@@ -7,12 +7,6 @@ const recoveryCredentialSchema = z
7
7
  recoveryKey: z.string().regex(/^crr\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/),
8
8
  })
9
9
  .strict();
10
- const registrationSchema = z
11
- .object({
12
- installationId: z.string().uuid(),
13
- registered: z.literal(true),
14
- })
15
- .strict();
16
10
  const sessionSchema = z
17
11
  .object({
18
12
  purchaseToken: z.string().min(24),
@@ -36,22 +30,11 @@ const restoreSchema = z
36
30
  recoveryKey: z.string().regex(/^crr\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/),
37
31
  })
38
32
  .strict();
39
- const prefixSchema = z
40
- .object({
41
- publicPrefix: z.string().min(1),
42
- mcpUrl: z.string().url(),
43
- webUrl: z.string().url(),
44
- })
45
- .strict();
46
33
  export class CloudApiClient {
47
34
  baseUrl;
48
35
  constructor(baseUrl) {
49
36
  this.baseUrl = baseUrl;
50
37
  }
51
- async registerDevice(identity) {
52
- const payload = { devicePublicKey: identity.devicePublicKey };
53
- return registrationSchema.parse(await this.devicePost("/v1/device/register", "register", identity, payload));
54
- }
55
38
  async createManagementSession(identity) {
56
39
  const payload = { devicePublicKey: identity.devicePublicKey };
57
40
  return sessionSchema.parse(await this.devicePost("/v1/device/session", "session", identity, payload));
@@ -71,10 +54,6 @@ export class CloudApiClient {
71
54
  const payload = { devicePublicKey: identity.devicePublicKey };
72
55
  return recoveryCredentialSchema.parse(await this.devicePost("/v1/device/recovery-key", "recovery-key", identity, payload));
73
56
  }
74
- async setPrefix(identity, prefix) {
75
- const payload = { devicePublicKey: identity.devicePublicKey, prefix };
76
- return prefixSchema.parse(await this.devicePost("/v1/device/prefix", "prefix", identity, payload));
77
- }
78
57
  async lease(identity, requestedServices) {
79
58
  const payload = {
80
59
  devicePublicKey: identity.devicePublicKey,
@@ -24,7 +24,6 @@ export declare class CloudController {
24
24
  status(): CloudStatus;
25
25
  start(): Promise<void>;
26
26
  stop(): Promise<void>;
27
- registerDevice(): Promise<CloudStatus>;
28
27
  managementUrl(): Promise<{
29
28
  url: string;
30
29
  expiresAt: string;
@@ -35,7 +34,6 @@ export declare class CloudController {
35
34
  recoveryKey: string;
36
35
  }>;
37
36
  replaceRecoveryKey(): Promise<string>;
38
- setPrefix(prefix: string): Promise<CloudStatus>;
39
37
  setService(service: CloudServiceId, enabled: boolean): Promise<CloudStatus>;
40
38
  private refreshLeaseIfNeeded;
41
39
  private connectTunnel;
@@ -65,11 +65,6 @@ export class CloudController {
65
65
  if (this.state.lease)
66
66
  this.connection = "disconnected";
67
67
  }
68
- async registerDevice() {
69
- await this.api.registerDevice(this.identity());
70
- this.lastError = null;
71
- return this.status();
72
- }
73
68
  async managementUrl() {
74
69
  const current = this.state.managementSession;
75
70
  if (current && Date.parse(current.expiresAt) > Date.now()) {
@@ -139,18 +134,6 @@ export class CloudController {
139
134
  const result = await this.api.replaceRecoveryKey(this.identity());
140
135
  return result.recoveryKey;
141
136
  }
142
- async setPrefix(prefix) {
143
- const result = await this.api.setPrefix(this.identity(), prefix);
144
- this.state = { ...this.state, publicPrefix: result.publicPrefix };
145
- await this.store.save(this.state);
146
- if (!this.stopped && this.state.entitlements.length > 0) {
147
- await this.refreshLeaseIfNeeded(true);
148
- this.connectTunnel();
149
- this.scheduleRenewal();
150
- }
151
- this.lastError = null;
152
- return this.status();
153
- }
154
137
  async setService(service, enabled) {
155
138
  if (!CLOUD_SERVICES.includes(service))
156
139
  throw new Error(`Unknown Cloud service: ${service}`);
@@ -1,4 +1,8 @@
1
1
  import { type CloudLeaseState } from "./types.js";
2
+ interface TunnelTimings {
3
+ readyTimeoutMs: number;
4
+ heartbeatIntervalMs: number;
5
+ }
2
6
  export declare class CloudTunnelClient {
3
7
  private lease;
4
8
  private readonly identity;
@@ -6,11 +10,15 @@ export declare class CloudTunnelClient {
6
10
  private readonly callbacks;
7
11
  private socket;
8
12
  private reconnectTimer;
13
+ private readyTimer;
14
+ private heartbeatTimer;
15
+ private awaitingPong;
9
16
  private stopped;
10
17
  private attempts;
11
18
  private closeWhenIdle;
12
19
  private acceptingServices;
13
20
  private readonly streams;
21
+ private readonly timings;
14
22
  constructor(lease: CloudLeaseState, identity: {
15
23
  devicePrivateKey: string;
16
24
  }, local: {
@@ -20,7 +28,7 @@ export declare class CloudTunnelClient {
20
28
  onConnected(): void;
21
29
  onDisconnected(): void;
22
30
  onError(error: Error): void;
23
- });
31
+ }, timings?: Partial<TunnelTimings>);
24
32
  start(): void;
25
33
  stop(): void;
26
34
  drainAndStop(): void;
@@ -32,5 +40,11 @@ export declare class CloudTunnelClient {
32
40
  private handleBinary;
33
41
  private send;
34
42
  private sendBinary;
43
+ private startReadyTimeout;
44
+ private clearReadyTimeout;
45
+ private startHeartbeat;
46
+ private clearSocketTimers;
47
+ private failSocket;
35
48
  private scheduleReconnect;
36
49
  }
50
+ export {};
@@ -5,6 +5,8 @@ import { cloudServiceForPublicService, } from "./types.js";
5
5
  const PROTOCOL = "chatroom-tunnel-v1";
6
6
  const HIGH_WATER = 8 * 1024 * 1024;
7
7
  const LOW_WATER = 2 * 1024 * 1024;
8
+ const READY_TIMEOUT_MS = 10_000;
9
+ const HEARTBEAT_INTERVAL_MS = 20_000;
8
10
  export class CloudTunnelClient {
9
11
  lease;
10
12
  identity;
@@ -12,17 +14,25 @@ export class CloudTunnelClient {
12
14
  callbacks;
13
15
  socket = null;
14
16
  reconnectTimer = null;
17
+ readyTimer = null;
18
+ heartbeatTimer = null;
19
+ awaitingPong = false;
15
20
  stopped = true;
16
21
  attempts = 0;
17
22
  closeWhenIdle = null;
18
23
  acceptingServices;
19
24
  streams = new Map();
20
- constructor(lease, identity, local, callbacks) {
25
+ timings;
26
+ constructor(lease, identity, local, callbacks, timings = {}) {
21
27
  this.lease = lease;
22
28
  this.identity = identity;
23
29
  this.local = local;
24
30
  this.callbacks = callbacks;
25
31
  this.acceptingServices = new Set(lease.services);
32
+ this.timings = {
33
+ readyTimeoutMs: timings.readyTimeoutMs ?? READY_TIMEOUT_MS,
34
+ heartbeatIntervalMs: timings.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS,
35
+ };
26
36
  }
27
37
  start() {
28
38
  if (!this.stopped)
@@ -39,6 +49,7 @@ export class CloudTunnelClient {
39
49
  if (this.reconnectTimer)
40
50
  clearTimeout(this.reconnectTimer);
41
51
  this.reconnectTimer = null;
52
+ this.clearSocketTimers();
42
53
  this.socket?.close();
43
54
  this.socket = null;
44
55
  for (const stream of this.streams.values())
@@ -51,6 +62,7 @@ export class CloudTunnelClient {
51
62
  if (this.reconnectTimer)
52
63
  clearTimeout(this.reconnectTimer);
53
64
  this.reconnectTimer = null;
65
+ this.clearSocketTimers();
54
66
  if (this.streams.size === 0) {
55
67
  this.closeWhenIdle = null;
56
68
  this.socket?.close();
@@ -59,14 +71,21 @@ export class CloudTunnelClient {
59
71
  this.closeWhenIdle = "stop";
60
72
  }
61
73
  updateLease(lease) {
62
- const previousServices = this.lease.services;
63
- const reconnect = lease.token !== this.lease.token ||
64
- lease.tunnelUrl !== this.lease.tunnelUrl;
65
- const removesService = previousServices.some((service) => !lease.services.includes(service));
74
+ const tunnelChanged = lease.tunnelUrl !== this.lease.tunnelUrl;
75
+ const removesService = this.lease.services.some((service) => !lease.services.includes(service));
66
76
  this.lease = lease;
67
77
  this.acceptingServices = new Set(lease.services);
68
- if (!reconnect || this.stopped)
78
+ if (this.stopped)
69
79
  return;
80
+ // A new signed lease on the same tunnel is applied in-band. This keeps the
81
+ // current WebUI/API request alive while service permissions change.
82
+ if (!tunnelChanged) {
83
+ if (this.socket?.readyState === WebSocket.OPEN)
84
+ this.send({ type: "update-lease", lease: lease.token });
85
+ return;
86
+ }
87
+ // Changing the tunnel endpoint still requires a reconnect. If a service is
88
+ // being removed, let current streams finish before moving the connection.
70
89
  if (!removesService || this.streams.size === 0) {
71
90
  this.socket?.close();
72
91
  return;
@@ -79,6 +98,7 @@ export class CloudTunnelClient {
79
98
  const socket = new WebSocket(this.lease.tunnelUrl, PROTOCOL);
80
99
  this.socket = socket;
81
100
  socket.binaryType = "nodebuffer";
101
+ this.startReadyTimeout(socket);
82
102
  socket.on("message", (data, isBinary) => {
83
103
  try {
84
104
  if (isBinary)
@@ -91,9 +111,15 @@ export class CloudTunnelClient {
91
111
  socket.close();
92
112
  }
93
113
  });
94
- socket.on("close", () => {
114
+ socket.on("pong", () => {
95
115
  if (this.socket === socket)
116
+ this.awaitingPong = false;
117
+ });
118
+ socket.on("close", () => {
119
+ if (this.socket === socket) {
120
+ this.clearSocketTimers();
96
121
  this.socket = null;
122
+ }
97
123
  this.closeWhenIdle = null;
98
124
  for (const stream of this.streams.values())
99
125
  stream.request.destroy();
@@ -102,7 +128,7 @@ export class CloudTunnelClient {
102
128
  if (!this.stopped)
103
129
  this.scheduleReconnect();
104
130
  });
105
- socket.on("error", (error) => this.callbacks.onError(error));
131
+ socket.on("error", (error) => this.failSocket(socket, error));
106
132
  }
107
133
  handleControl(message) {
108
134
  if (message.type === "challenge") {
@@ -115,6 +141,9 @@ export class CloudTunnelClient {
115
141
  }
116
142
  if (message.type === "ready") {
117
143
  this.attempts = 0;
144
+ this.clearReadyTimeout();
145
+ if (this.socket)
146
+ this.startHeartbeat(this.socket);
118
147
  this.callbacks.onConnected();
119
148
  return;
120
149
  }
@@ -241,6 +270,50 @@ export class CloudTunnelClient {
241
270
  socket.send(frame, { binary: true });
242
271
  return socket.bufferedAmount < HIGH_WATER;
243
272
  }
273
+ startReadyTimeout(socket) {
274
+ this.clearReadyTimeout();
275
+ this.readyTimer = setTimeout(() => {
276
+ this.readyTimer = null;
277
+ this.failSocket(socket, new Error("Tunnel connection timed out"));
278
+ }, this.timings.readyTimeoutMs);
279
+ this.readyTimer.unref();
280
+ }
281
+ clearReadyTimeout() {
282
+ if (this.readyTimer)
283
+ clearTimeout(this.readyTimer);
284
+ this.readyTimer = null;
285
+ }
286
+ startHeartbeat(socket) {
287
+ if (this.heartbeatTimer)
288
+ clearInterval(this.heartbeatTimer);
289
+ this.awaitingPong = false;
290
+ this.heartbeatTimer = setInterval(() => {
291
+ if (this.socket !== socket || socket.readyState !== WebSocket.OPEN)
292
+ return;
293
+ if (this.awaitingPong) {
294
+ this.failSocket(socket, new Error("Tunnel heartbeat timed out"));
295
+ return;
296
+ }
297
+ this.awaitingPong = true;
298
+ socket.ping();
299
+ }, this.timings.heartbeatIntervalMs);
300
+ this.heartbeatTimer.unref();
301
+ }
302
+ clearSocketTimers() {
303
+ this.clearReadyTimeout();
304
+ if (this.heartbeatTimer)
305
+ clearInterval(this.heartbeatTimer);
306
+ this.heartbeatTimer = null;
307
+ this.awaitingPong = false;
308
+ }
309
+ failSocket(socket, error) {
310
+ if (this.socket !== socket)
311
+ return;
312
+ this.callbacks.onError(error);
313
+ this.clearSocketTimers();
314
+ this.socket = null;
315
+ socket.terminate();
316
+ }
244
317
  scheduleReconnect() {
245
318
  if (this.reconnectTimer || this.stopped)
246
319
  return;
@@ -5,7 +5,6 @@ import { asyncRoute } from "../../../presentation/http/http-utils.js";
5
5
  export function createCloudApiRouter(controller, operations) {
6
6
  const router = Router();
7
7
  router.get("/cloud/status", (_req, res) => res.json(controller.status()));
8
- router.post("/cloud/register", asyncRoute(async (_req, res) => res.json(await operations.run({ pluginId: "cloud", source: "gui", action: "register" }, () => controller.registerDevice()))));
9
8
  router.post("/cloud/sync", asyncRoute(async (_req, res) => res.json(await operations.run({ pluginId: "cloud", source: "gui", action: "sync" }, () => controller.syncStatus()))));
10
9
  router.post("/cloud/management", asyncRoute(async (_req, res) => res.json(await operations.run({ pluginId: "cloud", source: "gui", action: "management" }, () => controller.managementUrl()))));
11
10
  router.post("/cloud/restore", asyncRoute(async (req, res) => {
@@ -16,18 +15,6 @@ export function createCloudApiRouter(controller, operations) {
16
15
  res.json(await operations.run({ pluginId: "cloud", source: "gui", action: "restore", input: body }, () => controller.restore(body.recoveryKey)));
17
16
  }));
18
17
  router.post("/cloud/recovery-key", asyncRoute(async (_req, res) => res.json(await operations.run({ pluginId: "cloud", source: "gui", action: "recovery-key.replace" }, async () => ({ recoveryKey: await controller.replaceRecoveryKey() })))));
19
- router.post("/cloud/prefix", asyncRoute(async (req, res) => {
20
- const body = z
21
- .object({ prefix: z.string().min(1).max(64) })
22
- .strict()
23
- .parse(req.body);
24
- res.json(await operations.run({
25
- pluginId: "cloud",
26
- source: "gui",
27
- action: "prefix.set",
28
- input: body,
29
- }, () => controller.setPrefix(body.prefix)));
30
- }));
31
18
  router.post("/cloud/services/:service", asyncRoute(async (req, res) => {
32
19
  const service = CLOUD_SERVICE_SCHEMA.parse(req.params.service);
33
20
  const body = z.object({ enabled: z.boolean() }).strict().parse(req.body);