@privateclaw/privateclaw-relay 0.1.0

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.
Files changed (42) hide show
  1. package/.env.example +13 -0
  2. package/README.md +86 -0
  3. package/dist/cli-error.d.ts +3 -0
  4. package/dist/cli-error.js +7 -0
  5. package/dist/cli-error.js.map +1 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +14 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/config.d.ts +13 -0
  10. package/dist/config.js +36 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/frame-cache.d.ts +77 -0
  13. package/dist/frame-cache.js +127 -0
  14. package/dist/frame-cache.js.map +1 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +5 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/push-notifier.d.ts +36 -0
  19. package/dist/push-notifier.js +198 -0
  20. package/dist/push-notifier.js.map +1 -0
  21. package/dist/push-registration-store.d.ts +39 -0
  22. package/dist/push-registration-store.js +98 -0
  23. package/dist/push-registration-store.js.map +1 -0
  24. package/dist/relay-cli.d.ts +28 -0
  25. package/dist/relay-cli.js +256 -0
  26. package/dist/relay-cli.js.map +1 -0
  27. package/dist/relay-cluster.d.ts +144 -0
  28. package/dist/relay-cluster.js +436 -0
  29. package/dist/relay-cluster.js.map +1 -0
  30. package/dist/relay-server.d.ts +25 -0
  31. package/dist/relay-server.js +1090 -0
  32. package/dist/relay-server.js.map +1 -0
  33. package/dist/session-store.d.ts +40 -0
  34. package/dist/session-store.js +159 -0
  35. package/dist/session-store.js.map +1 -0
  36. package/dist/tunnel-installer.d.ts +36 -0
  37. package/dist/tunnel-installer.js +402 -0
  38. package/dist/tunnel-installer.js.map +1 -0
  39. package/dist/tunnel.d.ts +35 -0
  40. package/dist/tunnel.js +334 -0
  41. package/dist/tunnel.js.map +1 -0
  42. package/package.json +45 -0
@@ -0,0 +1,1090 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { WebSocket, WebSocketServer } from "ws";
4
+ import { createEncryptedFrameCache, } from "./frame-cache.js";
5
+ import { createRelayPushRegistrationStore, } from "./push-registration-store.js";
6
+ import { createRelayPushNotifier, } from "./push-notifier.js";
7
+ import { createRedisRelayClusterClient, RelayClaimConflictError, } from "./relay-cluster.js";
8
+ import { createRelaySessionStore, } from "./session-store.js";
9
+ const HEARTBEAT_INTERVAL_MS = 15_000;
10
+ const PUSH_WAKE_COOLDOWN_MS = 5_000;
11
+ class RelayProtocolError extends Error {
12
+ code;
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = "RelayProtocolError";
17
+ }
18
+ }
19
+ function isObject(value) {
20
+ return typeof value === "object" && value !== null;
21
+ }
22
+ function isEncryptedEnvelope(value) {
23
+ if (!isObject(value)) {
24
+ return false;
25
+ }
26
+ return (value.version === 1 &&
27
+ typeof value.messageId === "string" &&
28
+ typeof value.iv === "string" &&
29
+ typeof value.ciphertext === "string" &&
30
+ typeof value.tag === "string" &&
31
+ typeof value.sentAt === "string");
32
+ }
33
+ function sendJson(socket, message) {
34
+ if (socket.readyState === WebSocket.OPEN) {
35
+ socket.send(JSON.stringify(message));
36
+ }
37
+ }
38
+ function parseJson(raw) {
39
+ try {
40
+ return JSON.parse(raw);
41
+ }
42
+ catch {
43
+ throw new RelayProtocolError("invalid_json", "Relay messages must be valid JSON.");
44
+ }
45
+ }
46
+ function toRelayProtocolError(error) {
47
+ if (error instanceof RelayProtocolError) {
48
+ return error;
49
+ }
50
+ if (error instanceof RelayClaimConflictError) {
51
+ return new RelayProtocolError("session_in_use", error.message);
52
+ }
53
+ if (error instanceof Error) {
54
+ return new RelayProtocolError("internal_error", error.message);
55
+ }
56
+ return new RelayProtocolError("internal_error", String(error));
57
+ }
58
+ function setupHeartbeat(socket) {
59
+ socket.isAlive = true;
60
+ socket.on("pong", () => {
61
+ socket.isAlive = true;
62
+ });
63
+ }
64
+ function pingServerClients(server) {
65
+ for (const rawSocket of server.clients) {
66
+ const socket = rawSocket;
67
+ if (socket.isAlive === false) {
68
+ socket.terminate();
69
+ continue;
70
+ }
71
+ socket.isAlive = false;
72
+ if (socket.readyState === WebSocket.OPEN) {
73
+ socket.ping();
74
+ }
75
+ }
76
+ }
77
+ function isSocketActive(socket) {
78
+ return (socket.readyState === WebSocket.OPEN ||
79
+ socket.readyState === WebSocket.CONNECTING);
80
+ }
81
+ class SessionHub {
82
+ params;
83
+ providerSockets = new Map();
84
+ socketProviders = new Map();
85
+ providerSessions = new Map();
86
+ sessionProviders = new Map();
87
+ sessionApps = new Map();
88
+ appSessions = new Map();
89
+ recentWakeSentAt = new Map();
90
+ cluster;
91
+ constructor(params) {
92
+ this.params = params;
93
+ }
94
+ setCluster(cluster) {
95
+ this.cluster = cluster;
96
+ }
97
+ now() {
98
+ return this.params.now?.() ?? Date.now();
99
+ }
100
+ wakeKey(sessionId, appId) {
101
+ return `${sessionId}:${appId}`;
102
+ }
103
+ clearWakeState(sessionId, appId) {
104
+ if (appId) {
105
+ this.recentWakeSentAt.delete(this.wakeKey(sessionId, appId));
106
+ return;
107
+ }
108
+ const prefix = `${sessionId}:`;
109
+ for (const key of this.recentWakeSentAt.keys()) {
110
+ if (key.startsWith(prefix)) {
111
+ this.recentWakeSentAt.delete(key);
112
+ }
113
+ }
114
+ }
115
+ get usesPersistentSessions() {
116
+ return this.params.sessionStore.persistent;
117
+ }
118
+ get localSessionCount() {
119
+ return new Set([
120
+ ...this.sessionProviders.keys(),
121
+ ...this.sessionApps.keys(),
122
+ ]).size;
123
+ }
124
+ async countSessions() {
125
+ return this.params.sessionStore.countSessions(this.now());
126
+ }
127
+ async rememberLocalProviderSession(providerId, sessionId) {
128
+ const previousProviderId = this.sessionProviders.get(sessionId);
129
+ if (previousProviderId === providerId) {
130
+ return;
131
+ }
132
+ if (previousProviderId) {
133
+ await this.forgetLocalProviderSession(previousProviderId, sessionId);
134
+ }
135
+ const sessionIds = this.providerSessions.get(providerId) ?? new Set();
136
+ const added = !sessionIds.has(sessionId);
137
+ sessionIds.add(sessionId);
138
+ this.providerSessions.set(providerId, sessionIds);
139
+ this.sessionProviders.set(sessionId, providerId);
140
+ if (added && this.cluster) {
141
+ await this.cluster.subscribeProvider(providerId, sessionId);
142
+ }
143
+ }
144
+ async forgetLocalProviderSession(providerId, sessionId) {
145
+ if (this.sessionProviders.get(sessionId) !== providerId) {
146
+ return;
147
+ }
148
+ this.sessionProviders.delete(sessionId);
149
+ const sessionIds = this.providerSessions.get(providerId);
150
+ sessionIds?.delete(sessionId);
151
+ if (sessionIds?.size === 0) {
152
+ this.providerSessions.delete(providerId);
153
+ }
154
+ if (this.cluster) {
155
+ await this.cluster.unsubscribeProvider(providerId, sessionId);
156
+ }
157
+ }
158
+ async rememberLocalAppBinding(binding, appSocket) {
159
+ const appSockets = this.sessionApps.get(binding.sessionId) ?? new Map();
160
+ appSockets.set(binding.appId, appSocket);
161
+ this.sessionApps.set(binding.sessionId, appSockets);
162
+ this.appSessions.set(appSocket, binding);
163
+ if (this.cluster) {
164
+ await this.cluster.subscribeApp(binding.sessionId, binding.appId);
165
+ }
166
+ }
167
+ async forgetLocalAppBinding(appSocket, binding) {
168
+ const appSockets = this.sessionApps.get(binding.sessionId);
169
+ if (!appSockets || appSockets.get(binding.appId) !== appSocket) {
170
+ this.appSessions.delete(appSocket);
171
+ return false;
172
+ }
173
+ appSockets.delete(binding.appId);
174
+ if (appSockets.size === 0) {
175
+ this.sessionApps.delete(binding.sessionId);
176
+ }
177
+ this.appSessions.delete(appSocket);
178
+ if (this.cluster) {
179
+ await this.cluster.unsubscribeApp(binding.sessionId, binding.appId);
180
+ await this.cluster.releaseApp(binding);
181
+ }
182
+ return true;
183
+ }
184
+ async attachProvider(providerId, providerSocket) {
185
+ const previousSocket = this.providerSockets.get(providerId);
186
+ if (previousSocket &&
187
+ previousSocket !== providerSocket &&
188
+ isSocketActive(previousSocket)) {
189
+ await this.closeLocalProvider(providerId, "provider_reconnected");
190
+ }
191
+ this.providerSockets.set(providerId, providerSocket);
192
+ this.socketProviders.set(providerSocket, providerId);
193
+ if (this.cluster) {
194
+ const claim = await this.cluster.claimProvider(providerId);
195
+ if (claim.previousNodeId) {
196
+ await this.cluster.publishProviderReconnected(providerId, claim.previousNodeId);
197
+ }
198
+ }
199
+ const sessionIds = await this.params.sessionStore.listProviderSessions(providerId, this.now());
200
+ for (const sessionId of sessionIds) {
201
+ await this.rememberLocalProviderSession(providerId, sessionId);
202
+ }
203
+ }
204
+ requireProviderId(providerSocket) {
205
+ const providerId = this.socketProviders.get(providerSocket);
206
+ if (!providerId) {
207
+ throw new RelayProtocolError("provider_not_ready", "Provider connection is not registered with the relay yet.");
208
+ }
209
+ return providerId;
210
+ }
211
+ async createSession(providerSocket, params) {
212
+ const providerId = this.requireProviderId(providerSocket);
213
+ const session = {
214
+ sessionId: randomUUID(),
215
+ expiresAt: this.now() + (params?.ttlMs ?? this.params.defaultTtlMs),
216
+ providerId,
217
+ groupMode: params?.groupMode === true,
218
+ };
219
+ await this.params.sessionStore.saveSession(session);
220
+ await this.rememberLocalProviderSession(providerId, session.sessionId);
221
+ return session;
222
+ }
223
+ async requireSession(sessionId) {
224
+ const session = await this.params.sessionStore.getSession(sessionId);
225
+ if (!session) {
226
+ throw new RelayProtocolError("unknown_session", "PrivateClaw session not found. Generate a fresh QR code.");
227
+ }
228
+ if (session.expiresAt <= this.now()) {
229
+ await this.closeSession(sessionId, "session_expired");
230
+ throw new RelayProtocolError("session_expired", "PrivateClaw session expired. Generate a fresh QR code.");
231
+ }
232
+ return session;
233
+ }
234
+ async assertProviderOwnsSession(providerSocket, sessionId) {
235
+ const providerId = this.requireProviderId(providerSocket);
236
+ const session = await this.requireSession(sessionId);
237
+ if (session.providerId !== providerId) {
238
+ throw new RelayProtocolError("provider_session_mismatch", "Provider does not own this PrivateClaw session.");
239
+ }
240
+ return session;
241
+ }
242
+ hasLocalProviderSession(sessionId, providerId) {
243
+ if (this.sessionProviders.get(sessionId) !== providerId) {
244
+ return false;
245
+ }
246
+ const providerSocket = this.providerSockets.get(providerId);
247
+ return !!providerSocket && providerSocket.readyState === WebSocket.OPEN;
248
+ }
249
+ async assertSessionHasLiveProvider(session) {
250
+ if (this.hasLocalProviderSession(session.sessionId, session.providerId)) {
251
+ return;
252
+ }
253
+ if (this.cluster) {
254
+ const hasRemoteSubscriber = await this.cluster.hasProviderSessionSubscriber(session.sessionId);
255
+ if (hasRemoteSubscriber) {
256
+ return;
257
+ }
258
+ }
259
+ throw new RelayProtocolError("provider_unavailable", "PrivateClaw session host is currently offline. Ask the host to reopen the session.");
260
+ }
261
+ async renewSession(providerSocket, sessionId, ttlMs) {
262
+ const session = await this.assertProviderOwnsSession(providerSocket, sessionId);
263
+ const renewedSession = {
264
+ ...session,
265
+ expiresAt: this.now() + ttlMs,
266
+ };
267
+ await this.params.sessionStore.saveSession(renewedSession);
268
+ await this.params.pushRegistrationStore.touchSession(sessionId, renewedSession.expiresAt);
269
+ return renewedSession;
270
+ }
271
+ async registerAppPushToken(sessionId, appId, token) {
272
+ const session = await this.requireSession(sessionId);
273
+ const normalizedToken = token.trim();
274
+ if (normalizedToken === "") {
275
+ throw new RelayProtocolError("invalid_push_token", "app:register_push requires a non-empty token.");
276
+ }
277
+ await this.params.pushRegistrationStore.saveRegistration({
278
+ sessionId,
279
+ appId,
280
+ token: normalizedToken,
281
+ updatedAt: this.now(),
282
+ }, session.expiresAt);
283
+ console.info(`[privateclaw-relay] registered push token session=${sessionId} appId=${appId} tokenLength=${normalizedToken.length}`);
284
+ }
285
+ async bufferAppFrameForReplay(params) {
286
+ await this.params.frameCache.push({
287
+ sessionId: params.sessionId,
288
+ target: "app",
289
+ envelope: params.envelope,
290
+ ...(params.appId ? { appId: params.appId } : {}),
291
+ });
292
+ await this.notifyBufferedAppFrames(params.sessionId, params.appId);
293
+ }
294
+ async bufferOfflineGroupAppFrames(sessionId, envelope) {
295
+ const registrations = await this.params.pushRegistrationStore.listRegistrations(sessionId);
296
+ const activeLocalAppIds = new Set(this.listActiveLocalAppIds(sessionId));
297
+ let bufferedCount = 0;
298
+ for (const registration of registrations) {
299
+ if (activeLocalAppIds.has(registration.appId)) {
300
+ continue;
301
+ }
302
+ if (this.cluster && (await this.cluster.hasAppBinding(sessionId, registration.appId))) {
303
+ continue;
304
+ }
305
+ await this.bufferAppFrameForReplay({
306
+ sessionId,
307
+ envelope,
308
+ appId: registration.appId,
309
+ });
310
+ bufferedCount += 1;
311
+ }
312
+ return bufferedCount;
313
+ }
314
+ async unregisterAppPushToken(sessionId, appId) {
315
+ await this.params.pushRegistrationStore.deleteRegistration(sessionId, appId);
316
+ this.clearWakeState(sessionId, appId);
317
+ console.info(`[privateclaw-relay] unregistered push token session=${sessionId} appId=${appId}`);
318
+ }
319
+ async notifyBufferedAppFrames(sessionId, targetAppId) {
320
+ if (!this.params.pushNotifier.enabled) {
321
+ console.info(`[privateclaw-relay] wake skipped: notifier disabled session=${sessionId} targetAppId=${targetAppId ?? "all"}`);
322
+ return;
323
+ }
324
+ const registrations = await this.params.pushRegistrationStore.listRegistrations(sessionId);
325
+ const matchingRegistrations = registrations.filter((registration) => !targetAppId || registration.appId === targetAppId);
326
+ if (matchingRegistrations.length === 0) {
327
+ console.info(`[privateclaw-relay] wake skipped: no push registrations session=${sessionId} targetAppId=${targetAppId ?? "all"}`);
328
+ return;
329
+ }
330
+ for (const registration of matchingRegistrations) {
331
+ const wakeKey = this.wakeKey(registration.sessionId, registration.appId);
332
+ const now = this.now();
333
+ const lastWakeSentAt = this.recentWakeSentAt.get(wakeKey);
334
+ if (lastWakeSentAt !== undefined &&
335
+ now - lastWakeSentAt < PUSH_WAKE_COOLDOWN_MS) {
336
+ console.info(`[privateclaw-relay] wake skipped: cooldown session=${registration.sessionId} appId=${registration.appId} sinceMs=${now - lastWakeSentAt}`);
337
+ continue;
338
+ }
339
+ this.recentWakeSentAt.set(wakeKey, now);
340
+ try {
341
+ const result = await this.params.pushNotifier.sendWake(registration);
342
+ console.info(`[privateclaw-relay] wake sent session=${registration.sessionId} appId=${registration.appId} unregisterToken=${result.unregisterToken}`);
343
+ if (result.unregisterToken) {
344
+ await this.params.pushRegistrationStore.deleteRegistration(registration.sessionId, registration.appId);
345
+ this.clearWakeState(registration.sessionId, registration.appId);
346
+ }
347
+ }
348
+ catch (error) {
349
+ this.clearWakeState(registration.sessionId, registration.appId);
350
+ console.error("[privateclaw-relay] failed to send wake notification", error);
351
+ }
352
+ }
353
+ }
354
+ listActiveLocalAppIds(sessionId) {
355
+ const appSockets = this.sessionApps.get(sessionId);
356
+ if (!appSockets) {
357
+ return [];
358
+ }
359
+ return [...appSockets.entries()]
360
+ .filter(([, socket]) => isSocketActive(socket))
361
+ .map(([appId]) => appId);
362
+ }
363
+ async attachApp(sessionId, appId, appSocket) {
364
+ const session = await this.requireSession(sessionId);
365
+ await this.assertSessionHasLiveProvider(session);
366
+ const normalizedAppId = appId.trim() || "legacy-app";
367
+ const previousSocket = this.sessionApps.get(sessionId)?.get(normalizedAppId);
368
+ if (previousSocket &&
369
+ previousSocket !== appSocket &&
370
+ isSocketActive(previousSocket)) {
371
+ await this.closeLocalApp(sessionId, normalizedAppId, "app_reconnected");
372
+ }
373
+ const binding = {
374
+ sessionId,
375
+ appId: normalizedAppId,
376
+ groupMode: session.groupMode,
377
+ };
378
+ if (this.cluster) {
379
+ const claim = await this.cluster.claimApp(binding);
380
+ if (claim.previousNodeId) {
381
+ await this.cluster.publishAppReconnected(sessionId, normalizedAppId, claim.previousNodeId);
382
+ }
383
+ }
384
+ else if (!session.groupMode) {
385
+ const activeLocalAppIds = this.listActiveLocalAppIds(sessionId);
386
+ if (activeLocalAppIds.some((entryAppId) => entryAppId !== normalizedAppId)) {
387
+ throw new RelayProtocolError("session_in_use", "This PrivateClaw session is already attached to another app.");
388
+ }
389
+ }
390
+ await this.rememberLocalAppBinding(binding, appSocket);
391
+ return session;
392
+ }
393
+ deliverLocalToApp(sessionId, envelope, targetAppId) {
394
+ const appSockets = this.sessionApps.get(sessionId);
395
+ if (!appSockets) {
396
+ return 0;
397
+ }
398
+ if (targetAppId) {
399
+ const socket = appSockets.get(targetAppId);
400
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
401
+ return 0;
402
+ }
403
+ sendJson(socket, { type: "relay:frame", sessionId, envelope });
404
+ return 1;
405
+ }
406
+ let delivered = 0;
407
+ for (const socket of appSockets.values()) {
408
+ if (socket.readyState !== WebSocket.OPEN) {
409
+ continue;
410
+ }
411
+ delivered += 1;
412
+ sendJson(socket, { type: "relay:frame", sessionId, envelope });
413
+ }
414
+ return delivered;
415
+ }
416
+ deliverLocalToProvider(sessionId, envelope) {
417
+ const providerId = this.sessionProviders.get(sessionId);
418
+ if (!providerId) {
419
+ return 0;
420
+ }
421
+ const providerSocket = this.providerSockets.get(providerId);
422
+ if (!providerSocket || providerSocket.readyState !== WebSocket.OPEN) {
423
+ return 0;
424
+ }
425
+ sendJson(providerSocket, { type: "relay:frame", sessionId, envelope });
426
+ return 1;
427
+ }
428
+ async forwardToApp(providerSocket, sessionId, envelope, targetAppId) {
429
+ const session = await this.assertProviderOwnsSession(providerSocket, sessionId);
430
+ const localDelivered = this.deliverLocalToApp(sessionId, envelope, targetAppId);
431
+ const remoteDelivered = this.cluster
432
+ ? await this.cluster.publishFrameToApp(sessionId, envelope, targetAppId)
433
+ : 0;
434
+ if (targetAppId) {
435
+ if (localDelivered > 0 || remoteDelivered > 0) {
436
+ return;
437
+ }
438
+ await this.bufferAppFrameForReplay({ sessionId, envelope, appId: targetAppId });
439
+ return;
440
+ }
441
+ if (!session.groupMode) {
442
+ if (localDelivered > 0 || remoteDelivered > 0) {
443
+ return;
444
+ }
445
+ await this.bufferAppFrameForReplay({ sessionId, envelope });
446
+ return;
447
+ }
448
+ const bufferedGroupRecipients = await this.bufferOfflineGroupAppFrames(sessionId, envelope);
449
+ if (localDelivered === 0 && remoteDelivered === 0 && bufferedGroupRecipients === 0) {
450
+ await this.bufferAppFrameForReplay({ sessionId, envelope });
451
+ }
452
+ }
453
+ async forwardToProvider(sessionId, envelope) {
454
+ const session = await this.requireSession(sessionId);
455
+ await this.assertSessionHasLiveProvider(session);
456
+ const localDelivered = this.deliverLocalToProvider(sessionId, envelope);
457
+ const remoteDelivered = this.cluster
458
+ ? await this.cluster.publishFrameToProvider(sessionId, envelope)
459
+ : 0;
460
+ if (localDelivered > 0 || remoteDelivered > 0) {
461
+ return;
462
+ }
463
+ await this.params.frameCache.push({
464
+ sessionId,
465
+ target: "provider",
466
+ envelope,
467
+ });
468
+ }
469
+ async replayBufferedFrames(sessionId, target, socket, appId) {
470
+ const frames = await this.params.frameCache.drain({
471
+ sessionId,
472
+ target,
473
+ ...(appId ? { appId } : {}),
474
+ });
475
+ for (const envelope of frames) {
476
+ sendJson(socket, { type: "relay:frame", sessionId, envelope });
477
+ }
478
+ }
479
+ async replayBufferedFramesForProvider(providerId, socket) {
480
+ const sessionIds = await this.params.sessionStore.listProviderSessions(providerId, this.now());
481
+ for (const sessionId of sessionIds) {
482
+ await this.replayBufferedFrames(sessionId, "provider", socket);
483
+ }
484
+ }
485
+ hasLocalSession(sessionId) {
486
+ return this.sessionProviders.has(sessionId) || this.sessionApps.has(sessionId);
487
+ }
488
+ async closeLocalSession(sessionId, reason) {
489
+ const appSockets = [...(this.sessionApps.get(sessionId)?.entries() ?? [])];
490
+ for (const [appId, appSocket] of appSockets) {
491
+ const binding = this.appSessions.get(appSocket);
492
+ if (!binding || binding.appId !== appId) {
493
+ continue;
494
+ }
495
+ await this.forgetLocalAppBinding(appSocket, binding);
496
+ sendJson(appSocket, {
497
+ type: "relay:session_closed",
498
+ sessionId,
499
+ reason,
500
+ });
501
+ if (isSocketActive(appSocket)) {
502
+ appSocket.close(1000, reason);
503
+ }
504
+ }
505
+ const providerId = this.sessionProviders.get(sessionId);
506
+ if (!providerId) {
507
+ return;
508
+ }
509
+ const providerSocket = this.providerSockets.get(providerId);
510
+ await this.forgetLocalProviderSession(providerId, sessionId);
511
+ if (providerSocket) {
512
+ sendJson(providerSocket, {
513
+ type: "relay:session_closed",
514
+ sessionId,
515
+ reason,
516
+ });
517
+ }
518
+ }
519
+ async closeSession(sessionId, reason) {
520
+ await this.params.sessionStore.deleteSession(sessionId);
521
+ await this.params.pushRegistrationStore.clearSession(sessionId);
522
+ this.clearWakeState(sessionId);
523
+ if (!this.hasLocalSession(sessionId) && !this.cluster) {
524
+ await this.params.frameCache.clear(sessionId);
525
+ return;
526
+ }
527
+ await this.closeLocalSession(sessionId, reason);
528
+ if (this.cluster) {
529
+ await this.cluster.publishSessionClosed(sessionId, reason);
530
+ }
531
+ await this.params.frameCache.clear(sessionId);
532
+ }
533
+ async closeSessionForProvider(providerSocket, sessionId, reason) {
534
+ await this.assertProviderOwnsSession(providerSocket, sessionId);
535
+ await this.closeSession(sessionId, reason);
536
+ }
537
+ async closeApp(sessionId, appId, reason) {
538
+ await this.params.pushRegistrationStore.deleteRegistration(sessionId, appId);
539
+ this.clearWakeState(sessionId, appId);
540
+ await this.params.frameCache.clearApp(sessionId, appId);
541
+ await this.closeLocalApp(sessionId, appId, reason);
542
+ if (this.cluster) {
543
+ await this.cluster.publishAppClosed(sessionId, appId, reason);
544
+ }
545
+ }
546
+ async closeAppForProvider(providerSocket, sessionId, appId, reason) {
547
+ await this.assertProviderOwnsSession(providerSocket, sessionId);
548
+ await this.closeApp(sessionId, appId, reason);
549
+ }
550
+ async detachProvider(providerSocket) {
551
+ const providerId = this.socketProviders.get(providerSocket);
552
+ if (!providerId) {
553
+ return;
554
+ }
555
+ this.socketProviders.delete(providerSocket);
556
+ if (this.providerSockets.get(providerId) !== providerSocket) {
557
+ return;
558
+ }
559
+ this.providerSockets.delete(providerId);
560
+ const sessionIds = [...(this.providerSessions.get(providerId) ?? [])];
561
+ for (const sessionId of sessionIds) {
562
+ await this.forgetLocalProviderSession(providerId, sessionId);
563
+ }
564
+ if (this.cluster) {
565
+ await this.cluster.releaseProvider(providerId);
566
+ }
567
+ }
568
+ async detachApp(appSocket) {
569
+ const binding = this.appSessions.get(appSocket);
570
+ if (!binding) {
571
+ return;
572
+ }
573
+ await this.forgetLocalAppBinding(appSocket, binding);
574
+ }
575
+ async closeLocalApp(sessionId, appId, reason) {
576
+ const appSocket = this.sessionApps.get(sessionId)?.get(appId);
577
+ if (!appSocket) {
578
+ return;
579
+ }
580
+ const binding = this.appSessions.get(appSocket);
581
+ if (!binding) {
582
+ return;
583
+ }
584
+ await this.forgetLocalAppBinding(appSocket, binding);
585
+ sendJson(appSocket, {
586
+ type: "relay:session_closed",
587
+ sessionId,
588
+ reason,
589
+ });
590
+ if (isSocketActive(appSocket)) {
591
+ appSocket.close(1012, reason);
592
+ }
593
+ }
594
+ async closeLocalProvider(providerId, reason) {
595
+ const providerSocket = this.providerSockets.get(providerId);
596
+ if (!providerSocket) {
597
+ return;
598
+ }
599
+ this.providerSockets.delete(providerId);
600
+ this.socketProviders.delete(providerSocket);
601
+ const sessionIds = [...(this.providerSessions.get(providerId) ?? [])];
602
+ for (const sessionId of sessionIds) {
603
+ await this.forgetLocalProviderSession(providerId, sessionId);
604
+ }
605
+ if (this.cluster) {
606
+ await this.cluster.releaseProvider(providerId);
607
+ }
608
+ if (isSocketActive(providerSocket)) {
609
+ providerSocket.close(1012, reason);
610
+ }
611
+ }
612
+ async purgeExpiredSessions() {
613
+ const sessionIds = new Set([
614
+ ...this.sessionProviders.keys(),
615
+ ...this.sessionApps.keys(),
616
+ ]);
617
+ for (const sessionId of sessionIds) {
618
+ const session = await this.params.sessionStore.getSession(sessionId);
619
+ if (!session) {
620
+ await this.params.pushRegistrationStore.clearSession(sessionId);
621
+ await this.closeLocalSession(sessionId, "session_expired");
622
+ continue;
623
+ }
624
+ if (session.expiresAt <= this.now()) {
625
+ await this.closeSession(sessionId, "session_expired");
626
+ }
627
+ }
628
+ }
629
+ async refreshClusterPresence() {
630
+ if (!this.cluster) {
631
+ return;
632
+ }
633
+ const providerIds = [...this.providerSockets.keys()];
634
+ const appBindings = [];
635
+ for (const [appSocket, binding] of this.appSessions.entries()) {
636
+ const currentSocket = this.sessionApps.get(binding.sessionId)?.get(binding.appId);
637
+ if (currentSocket === appSocket) {
638
+ appBindings.push(binding);
639
+ }
640
+ }
641
+ await this.cluster.refreshPresence({
642
+ providerIds,
643
+ appBindings,
644
+ });
645
+ }
646
+ async closeAll(reason) {
647
+ const sessionIds = new Set([
648
+ ...this.sessionProviders.keys(),
649
+ ...this.sessionApps.keys(),
650
+ ]);
651
+ for (const sessionId of sessionIds) {
652
+ await this.closeSession(sessionId, reason);
653
+ }
654
+ }
655
+ }
656
+ export function createRelayServer(config, deps = {}) {
657
+ const ownsFrameCache = !deps.frameCache;
658
+ const frameCache = deps.frameCache ??
659
+ createEncryptedFrameCache({
660
+ maxFrames: config.frameCacheSize,
661
+ ...(config.redisUrl ? { redisUrl: config.redisUrl } : {}),
662
+ });
663
+ const ownsSessionStore = !deps.sessionStore;
664
+ const sessionStore = deps.sessionStore ??
665
+ createRelaySessionStore({
666
+ ...(config.redisUrl ? { redisUrl: config.redisUrl } : {}),
667
+ });
668
+ const ownsPushRegistrationStore = !deps.pushRegistrationStore;
669
+ const pushRegistrationStore = deps.pushRegistrationStore ??
670
+ createRelayPushRegistrationStore({
671
+ ...(config.redisUrl ? { redisUrl: config.redisUrl } : {}),
672
+ });
673
+ const ownsPushNotifier = !deps.pushNotifier;
674
+ const pushNotifier = deps.pushNotifier ??
675
+ createRelayPushNotifier({
676
+ fcmServiceAccountJson: config.fcmServiceAccountJson,
677
+ fcmProjectId: config.fcmProjectId,
678
+ fcmClientEmail: config.fcmClientEmail,
679
+ fcmPrivateKey: config.fcmPrivateKey,
680
+ });
681
+ const sessionHub = new SessionHub({
682
+ defaultTtlMs: config.sessionTtlMs,
683
+ frameCache,
684
+ sessionStore,
685
+ pushRegistrationStore,
686
+ pushNotifier,
687
+ ...(deps.now ? { now: deps.now } : {}),
688
+ });
689
+ const clusterCallbacks = {
690
+ onRemoteAppFrame: async (sessionId, envelope, targetAppId) => {
691
+ sessionHub.deliverLocalToApp(sessionId, envelope, targetAppId);
692
+ },
693
+ onRemoteProviderFrame: async (sessionId, envelope) => {
694
+ sessionHub.deliverLocalToProvider(sessionId, envelope);
695
+ },
696
+ onRemoteSessionClosed: async (sessionId, reason) => {
697
+ await sessionHub.closeLocalSession(sessionId, reason);
698
+ },
699
+ onRemoteAppClosed: async (sessionId, appId, reason) => {
700
+ await sessionHub.closeLocalApp(sessionId, appId, reason);
701
+ },
702
+ onRemoteAppReconnected: async (sessionId, appId) => {
703
+ await sessionHub.closeLocalApp(sessionId, appId, "app_reconnected");
704
+ },
705
+ onRemoteProviderReconnected: async (providerId) => {
706
+ await sessionHub.closeLocalProvider(providerId, "provider_reconnected");
707
+ },
708
+ };
709
+ const clusterNodeId = config.instanceId?.trim() || randomUUID();
710
+ const ownsCluster = !deps.cluster && !deps.clusterFactory && !!config.redisUrl;
711
+ const cluster = deps.cluster ??
712
+ deps.clusterFactory?.(clusterCallbacks) ??
713
+ (config.redisUrl
714
+ ? createRedisRelayClusterClient({
715
+ redisUrl: config.redisUrl,
716
+ nodeId: clusterNodeId,
717
+ callbacks: clusterCallbacks,
718
+ })
719
+ : undefined);
720
+ sessionHub.setCluster(cluster);
721
+ let startedPort = config.port;
722
+ let startedUrl = "";
723
+ let started = false;
724
+ const server = createServer((request, response) => {
725
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${config.host}:${startedPort}`}`);
726
+ if (request.method === "GET" &&
727
+ (url.pathname === "/healthz" || url.pathname === "/api/health")) {
728
+ void sessionHub
729
+ .countSessions()
730
+ .then((sessions) => {
731
+ response.writeHead(200, { "content-type": "application/json" });
732
+ response.end(JSON.stringify({ ok: true, sessions, instanceId: clusterNodeId }));
733
+ })
734
+ .catch((error) => {
735
+ response.writeHead(500, { "content-type": "application/json" });
736
+ response.end(JSON.stringify({
737
+ ok: false,
738
+ error: error instanceof Error ? error.message : String(error),
739
+ }));
740
+ });
741
+ return;
742
+ }
743
+ response.writeHead(404, { "content-type": "application/json" });
744
+ response.end(JSON.stringify({ error: "not_found" }));
745
+ });
746
+ const providerWss = new WebSocketServer({ noServer: true });
747
+ const appWss = new WebSocketServer({ noServer: true });
748
+ const expiryTimer = setInterval(() => {
749
+ void sessionHub.purgeExpiredSessions().catch((error) => {
750
+ console.error("[privateclaw-relay] failed to purge expired sessions", error);
751
+ });
752
+ }, 5_000);
753
+ const heartbeatTimer = setInterval(() => {
754
+ pingServerClients(providerWss);
755
+ pingServerClients(appWss);
756
+ void sessionHub.refreshClusterPresence().catch((error) => {
757
+ console.error("[privateclaw-relay] failed to refresh relay presence", error);
758
+ });
759
+ }, HEARTBEAT_INTERVAL_MS);
760
+ function terminateSockets(server) {
761
+ for (const socket of server.clients) {
762
+ socket.terminate();
763
+ }
764
+ }
765
+ async function closeWebSocketServer(server) {
766
+ await new Promise((resolve) => {
767
+ server.close(() => resolve());
768
+ });
769
+ }
770
+ async function handleProviderMessage(socket, raw) {
771
+ let requestId;
772
+ try {
773
+ const message = parseJson(raw);
774
+ if (!isObject(message) || typeof message.type !== "string") {
775
+ throw new RelayProtocolError("invalid_message", "Provider message is missing a type field.");
776
+ }
777
+ switch (message.type) {
778
+ case "provider:create_session": {
779
+ const requestIdValue = message.requestId;
780
+ const ttlMsValue = message.ttlMs;
781
+ const groupModeValue = message.groupMode;
782
+ if (typeof requestIdValue !== "string" || requestIdValue === "") {
783
+ throw new RelayProtocolError("invalid_request_id", "provider:create_session requires a requestId.");
784
+ }
785
+ requestId = requestIdValue;
786
+ if (ttlMsValue !== undefined &&
787
+ (typeof ttlMsValue !== "number" ||
788
+ !Number.isInteger(ttlMsValue) ||
789
+ ttlMsValue <= 0)) {
790
+ throw new RelayProtocolError("invalid_ttl", "provider:create_session ttlMs must be a positive integer.");
791
+ }
792
+ if (groupModeValue !== undefined &&
793
+ typeof groupModeValue !== "boolean") {
794
+ throw new RelayProtocolError("invalid_group_mode", "provider:create_session groupMode must be a boolean when provided.");
795
+ }
796
+ const session = await sessionHub.createSession(socket, {
797
+ ...(typeof ttlMsValue === "number" ? { ttlMs: ttlMsValue } : {}),
798
+ ...(typeof groupModeValue === "boolean"
799
+ ? { groupMode: groupModeValue }
800
+ : {}),
801
+ });
802
+ sendJson(socket, {
803
+ type: "relay:session_created",
804
+ requestId: requestIdValue,
805
+ sessionId: session.sessionId,
806
+ expiresAt: new Date(session.expiresAt).toISOString(),
807
+ });
808
+ return;
809
+ }
810
+ case "provider:renew_session": {
811
+ const requestIdValue = message.requestId;
812
+ if (typeof requestIdValue !== "string" || requestIdValue === "") {
813
+ throw new RelayProtocolError("invalid_request_id", "provider:renew_session requires a requestId.");
814
+ }
815
+ requestId = requestIdValue;
816
+ if (typeof message.sessionId !== "string") {
817
+ throw new RelayProtocolError("invalid_renew_request", "provider:renew_session requires a sessionId.");
818
+ }
819
+ if (typeof message.ttlMs !== "number" ||
820
+ !Number.isInteger(message.ttlMs) ||
821
+ message.ttlMs <= 0) {
822
+ throw new RelayProtocolError("invalid_ttl", "provider:renew_session ttlMs must be a positive integer.");
823
+ }
824
+ const session = await sessionHub.renewSession(socket, message.sessionId, message.ttlMs);
825
+ sendJson(socket, {
826
+ type: "relay:session_renewed",
827
+ requestId: requestIdValue,
828
+ sessionId: session.sessionId,
829
+ expiresAt: new Date(session.expiresAt).toISOString(),
830
+ });
831
+ return;
832
+ }
833
+ case "provider:frame": {
834
+ if (typeof message.sessionId !== "string" ||
835
+ !isEncryptedEnvelope(message.envelope)) {
836
+ throw new RelayProtocolError("invalid_frame", "provider:frame must include a valid sessionId and encrypted envelope.");
837
+ }
838
+ if (message.targetAppId !== undefined &&
839
+ typeof message.targetAppId !== "string") {
840
+ throw new RelayProtocolError("invalid_target_app_id", "provider:frame targetAppId must be a string when provided.");
841
+ }
842
+ await sessionHub.forwardToApp(socket, message.sessionId, message.envelope, message.targetAppId);
843
+ return;
844
+ }
845
+ case "provider:close_session": {
846
+ if (typeof message.sessionId !== "string") {
847
+ throw new RelayProtocolError("invalid_close_request", "provider:close_session requires a sessionId.");
848
+ }
849
+ await sessionHub.closeSessionForProvider(socket, message.sessionId, typeof message.reason === "string"
850
+ ? message.reason
851
+ : "provider_closed");
852
+ return;
853
+ }
854
+ case "provider:close_app": {
855
+ if (typeof message.sessionId !== "string" ||
856
+ typeof message.appId !== "string" ||
857
+ message.appId.trim() === "") {
858
+ throw new RelayProtocolError("invalid_close_request", "provider:close_app requires a sessionId and appId.");
859
+ }
860
+ await sessionHub.closeAppForProvider(socket, message.sessionId, message.appId, typeof message.reason === "string"
861
+ ? message.reason
862
+ : "provider_closed_app");
863
+ return;
864
+ }
865
+ default:
866
+ throw new RelayProtocolError("unsupported_message", `Unsupported provider message type: ${String(message.type)}`);
867
+ }
868
+ }
869
+ catch (error) {
870
+ const relayError = toRelayProtocolError(error);
871
+ sendJson(socket, {
872
+ type: "relay:error",
873
+ code: relayError.code,
874
+ message: relayError.message,
875
+ ...(requestId ? { requestId } : {}),
876
+ });
877
+ }
878
+ }
879
+ async function handleAppMessage(socket, sessionId, appId, raw) {
880
+ try {
881
+ const message = parseJson(raw);
882
+ if (!isObject(message) || typeof message.type !== "string") {
883
+ throw new RelayProtocolError("invalid_message", "App message is missing a type field.");
884
+ }
885
+ switch (message.type) {
886
+ case "app:frame":
887
+ if (!isEncryptedEnvelope(message.envelope)) {
888
+ throw new RelayProtocolError("invalid_frame", "app:frame must include a valid encrypted envelope.");
889
+ }
890
+ await sessionHub.forwardToProvider(sessionId, message.envelope);
891
+ return;
892
+ case "app:register_push":
893
+ if (typeof message.token !== "string") {
894
+ throw new RelayProtocolError("invalid_push_token", "app:register_push requires a string token.");
895
+ }
896
+ await sessionHub.registerAppPushToken(sessionId, appId, message.token);
897
+ return;
898
+ case "app:unregister_push":
899
+ await sessionHub.unregisterAppPushToken(sessionId, appId);
900
+ return;
901
+ default:
902
+ throw new RelayProtocolError("unsupported_message", `Unsupported app message type: ${String(message.type)}`);
903
+ }
904
+ }
905
+ catch (error) {
906
+ const relayError = toRelayProtocolError(error);
907
+ sendJson(socket, {
908
+ type: "relay:error",
909
+ code: relayError.code,
910
+ message: relayError.message,
911
+ sessionId,
912
+ });
913
+ }
914
+ }
915
+ providerWss.on("connection", (rawSocket, request) => {
916
+ const socket = rawSocket;
917
+ setupHeartbeat(socket);
918
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${config.host}:${startedPort}`}`);
919
+ const providerId = url.searchParams.get("providerId")?.trim() || randomUUID();
920
+ void (async () => {
921
+ try {
922
+ await sessionHub.attachProvider(providerId, socket);
923
+ }
924
+ catch (error) {
925
+ const relayError = toRelayProtocolError(error);
926
+ sendJson(socket, {
927
+ type: "relay:error",
928
+ code: relayError.code,
929
+ message: relayError.message,
930
+ });
931
+ socket.close(1011, relayError.code);
932
+ return;
933
+ }
934
+ sendJson(socket, { type: "relay:provider_ready" });
935
+ void sessionHub.replayBufferedFramesForProvider(providerId, socket).catch((error) => {
936
+ console.error("[privateclaw-relay] failed to replay buffered provider frames", error);
937
+ });
938
+ socket.on("message", (data) => {
939
+ void handleProviderMessage(socket, data.toString());
940
+ });
941
+ socket.on("close", () => {
942
+ void sessionHub.detachProvider(socket).catch((error) => {
943
+ console.error("[privateclaw-relay] failed to detach provider socket", error);
944
+ });
945
+ });
946
+ })();
947
+ });
948
+ appWss.on("connection", (rawSocket, request) => {
949
+ const socket = rawSocket;
950
+ setupHeartbeat(socket);
951
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${config.host}:${startedPort}`}`);
952
+ const sessionId = url.searchParams.get("sessionId");
953
+ const appId = url.searchParams.get("appId")?.trim() || "legacy-app";
954
+ if (!sessionId) {
955
+ sendJson(socket, {
956
+ type: "relay:error",
957
+ code: "missing_session_id",
958
+ message: "App connections must include a sessionId query parameter.",
959
+ });
960
+ socket.close(1008, "missing_session_id");
961
+ return;
962
+ }
963
+ void (async () => {
964
+ let session;
965
+ try {
966
+ session = await sessionHub.attachApp(sessionId, appId, socket);
967
+ }
968
+ catch (error) {
969
+ const relayError = toRelayProtocolError(error);
970
+ sendJson(socket, {
971
+ type: "relay:error",
972
+ code: relayError.code,
973
+ message: relayError.message,
974
+ sessionId,
975
+ });
976
+ socket.close(1008, relayError.code);
977
+ return;
978
+ }
979
+ sendJson(socket, {
980
+ type: "relay:attached",
981
+ sessionId,
982
+ expiresAt: new Date(session.expiresAt).toISOString(),
983
+ });
984
+ void sessionHub.replayBufferedFrames(sessionId, "app", socket, appId).catch((error) => {
985
+ console.error("[privateclaw-relay] failed to replay buffered app frames", error);
986
+ });
987
+ socket.on("message", (data) => {
988
+ void handleAppMessage(socket, sessionId, appId, data.toString());
989
+ });
990
+ socket.on("close", () => {
991
+ void sessionHub.detachApp(socket).catch((error) => {
992
+ console.error("[privateclaw-relay] failed to detach app socket", error);
993
+ });
994
+ });
995
+ })();
996
+ });
997
+ server.on("upgrade", (request, socket, head) => {
998
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${config.host}:${startedPort}`}`);
999
+ if (url.pathname === "/ws/provider") {
1000
+ providerWss.handleUpgrade(request, socket, head, (websocket) => {
1001
+ providerWss.emit("connection", websocket, request);
1002
+ });
1003
+ return;
1004
+ }
1005
+ if (url.pathname === "/ws/app") {
1006
+ appWss.handleUpgrade(request, socket, head, (websocket) => {
1007
+ appWss.emit("connection", websocket, request);
1008
+ });
1009
+ return;
1010
+ }
1011
+ socket.destroy();
1012
+ });
1013
+ async function closeOwnedResources() {
1014
+ const closes = [];
1015
+ if (ownsCluster && cluster) {
1016
+ closes.push(cluster.close());
1017
+ }
1018
+ if (ownsSessionStore) {
1019
+ closes.push(sessionStore.close());
1020
+ }
1021
+ if (ownsPushRegistrationStore) {
1022
+ closes.push(pushRegistrationStore.close());
1023
+ }
1024
+ if (ownsPushNotifier) {
1025
+ closes.push(pushNotifier.close());
1026
+ }
1027
+ if (ownsFrameCache) {
1028
+ closes.push(frameCache.close());
1029
+ }
1030
+ await Promise.all(closes);
1031
+ }
1032
+ return {
1033
+ get port() {
1034
+ return startedPort;
1035
+ },
1036
+ get url() {
1037
+ return startedUrl;
1038
+ },
1039
+ async start() {
1040
+ if (started) {
1041
+ return { port: startedPort, url: startedUrl };
1042
+ }
1043
+ await new Promise((resolve, reject) => {
1044
+ server.once("error", reject);
1045
+ server.listen(config.port, config.host, () => {
1046
+ server.removeListener("error", reject);
1047
+ resolve();
1048
+ });
1049
+ });
1050
+ const address = server.address();
1051
+ if (!address || typeof address === "string") {
1052
+ throw new Error("Relay server failed to acquire a TCP port.");
1053
+ }
1054
+ startedPort = address.port;
1055
+ startedUrl = `http://${config.host}:${startedPort}`;
1056
+ started = true;
1057
+ return { port: startedPort, url: startedUrl };
1058
+ },
1059
+ async stop() {
1060
+ clearInterval(expiryTimer);
1061
+ clearInterval(heartbeatTimer);
1062
+ if (!started) {
1063
+ await closeOwnedResources();
1064
+ return;
1065
+ }
1066
+ if (!sessionHub.usesPersistentSessions) {
1067
+ await sessionHub.closeAll("relay_shutdown");
1068
+ }
1069
+ terminateSockets(providerWss);
1070
+ terminateSockets(appWss);
1071
+ await Promise.all([
1072
+ closeWebSocketServer(providerWss),
1073
+ closeWebSocketServer(appWss),
1074
+ ]);
1075
+ await new Promise((resolve, reject) => {
1076
+ server.close((error) => {
1077
+ if (error) {
1078
+ reject(error);
1079
+ return;
1080
+ }
1081
+ resolve();
1082
+ });
1083
+ });
1084
+ await closeOwnedResources();
1085
+ started = false;
1086
+ startedUrl = "";
1087
+ },
1088
+ };
1089
+ }
1090
+ //# sourceMappingURL=relay-server.js.map