@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.
- package/dist/plugins/cloud/api-client.d.ts +0 -11
- package/dist/plugins/cloud/api-client.js +0 -21
- package/dist/plugins/cloud/controller.d.ts +0 -2
- package/dist/plugins/cloud/controller.js +0 -17
- package/dist/plugins/cloud/tunnel-client.d.ts +15 -1
- package/dist/plugins/cloud/tunnel-client.js +81 -8
- package/dist/plugins/web/http/cloud-api-router.js +0 -13
- package/dist/web/assets/index-B7jL3mhD.css +1 -0
- package/dist/web/assets/index-L4-YdZVN.js +26 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B4927Tpd.css +0 -1
- package/dist/web/assets/index-KAI80R3I.js +0 -26
|
@@ -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
|
-
|
|
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
|
|
63
|
-
const
|
|
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 (
|
|
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("
|
|
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.
|
|
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);
|