@privateclaw/privateclaw-relay 0.1.6 → 0.1.8
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/.env.example +5 -0
- package/README.md +20 -0
- package/dist/admin-api.d.ts +11 -0
- package/dist/admin-api.js +111 -0
- package/dist/admin-api.js.map +1 -0
- package/dist/admin-metrics-store.d.ts +245 -0
- package/dist/admin-metrics-store.js +952 -0
- package/dist/admin-metrics-store.js.map +1 -0
- package/dist/admin-web/admin.css +379 -0
- package/dist/admin-web/admin.js +366 -0
- package/dist/admin-web/index.html +145 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/relay-cluster.js +102 -6
- package/dist/relay-cluster.js.map +1 -1
- package/dist/relay-server.d.ts +17 -0
- package/dist/relay-server.js +345 -27
- package/dist/relay-server.js.map +1 -1
- package/dist/relay-web.d.ts +2 -0
- package/dist/relay-web.js +35 -6
- package/dist/relay-web.js.map +1 -1
- package/dist/web/chat/index.html +2 -2
- package/dist/web/scripts/chat.js +166 -4
- package/dist/web/scripts/i18n.js +15 -0
- package/dist/web/scripts/session-client.js +65 -0
- package/dist/web/styles.css +180 -0
- package/package.json +1 -1
package/dist/relay-server.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { createServer, } from "node:http";
|
|
3
3
|
import { WebSocket, WebSocketServer } from "ws";
|
|
4
|
+
import { handleRelayAdminRequest } from "./admin-api.js";
|
|
5
|
+
import { createRelayAdminMetricsStore, } from "./admin-metrics-store.js";
|
|
4
6
|
import { createEncryptedFrameCache, } from "./frame-cache.js";
|
|
5
7
|
import { createRelayPushRegistrationStore, } from "./push-registration-store.js";
|
|
6
8
|
import { createRelayPushNotifier, } from "./push-notifier.js";
|
|
7
9
|
import { createRedisRelayClusterClient, RelayClaimConflictError, } from "./relay-cluster.js";
|
|
8
10
|
import { createRelaySessionStore, } from "./session-store.js";
|
|
9
|
-
import { serveRelayWebRequest } from "./relay-web.js";
|
|
11
|
+
import { resolveRelayAdminWebRootDir, serveRelayWebRequest, } from "./relay-web.js";
|
|
10
12
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
11
13
|
const PUSH_WAKE_COOLDOWN_MS = 5_000;
|
|
14
|
+
const DEFAULT_PROTOCOL_MESSAGE_BYTES = 24 * 1024 * 1024;
|
|
15
|
+
const DEFAULT_APP_MESSAGES_PER_MINUTE = 120;
|
|
16
|
+
const DEFAULT_PROVIDER_MESSAGES_PER_MINUTE = 600;
|
|
17
|
+
const MESSAGE_SIZE_SLACK_BYTES = 1 * 1024 * 1024;
|
|
18
|
+
const APP_MESSAGE_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
12
19
|
class RelayProtocolError extends Error {
|
|
13
20
|
code;
|
|
14
21
|
constructor(code, message) {
|
|
@@ -44,6 +51,101 @@ function parseJson(raw) {
|
|
|
44
51
|
throw new RelayProtocolError("invalid_json", "Relay messages must be valid JSON.");
|
|
45
52
|
}
|
|
46
53
|
}
|
|
54
|
+
function assertMessageSizeWithinLimit(raw, maxBytes, actorLabel) {
|
|
55
|
+
const rawBytes = Buffer.byteLength(raw, "utf8");
|
|
56
|
+
if (rawBytes <= maxBytes) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
throw new RelayProtocolError("message_too_large", `${actorLabel} message exceeds the ${maxBytes}-byte relay limit.`);
|
|
60
|
+
}
|
|
61
|
+
class FixedWindowRateLimiter {
|
|
62
|
+
params;
|
|
63
|
+
entries = new Map();
|
|
64
|
+
constructor(params) {
|
|
65
|
+
this.params = params;
|
|
66
|
+
}
|
|
67
|
+
now() {
|
|
68
|
+
return this.params.now?.() ?? Date.now();
|
|
69
|
+
}
|
|
70
|
+
async recordAdminMetric(action, label) {
|
|
71
|
+
try {
|
|
72
|
+
await action();
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
console.error(`[privateclaw-relay] failed to record ${label}`, error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
allow(key) {
|
|
79
|
+
const now = this.now();
|
|
80
|
+
const existing = this.entries.get(key);
|
|
81
|
+
if (!existing || now - existing.windowStartedAt >= this.params.windowMs) {
|
|
82
|
+
this.entries.set(key, { count: 1, windowStartedAt: now });
|
|
83
|
+
this.prune(now);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (existing.count >= this.params.maxPerWindow) {
|
|
87
|
+
this.prune(now);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
existing.count += 1;
|
|
91
|
+
this.prune(now);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
prune(now) {
|
|
95
|
+
if (this.entries.size < 512) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
99
|
+
if (now - entry.windowStartedAt >= this.params.windowMs * 2) {
|
|
100
|
+
this.entries.delete(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export class WakeCooldownTracker {
|
|
106
|
+
params;
|
|
107
|
+
entries = new Map();
|
|
108
|
+
constructor(params) {
|
|
109
|
+
this.params = params;
|
|
110
|
+
}
|
|
111
|
+
now() {
|
|
112
|
+
return this.params.now?.() ?? Date.now();
|
|
113
|
+
}
|
|
114
|
+
prune(now) {
|
|
115
|
+
const pruneAfterMs = this.params.pruneAfterMs ?? this.params.cooldownMs;
|
|
116
|
+
for (const [key, sentAt] of this.entries.entries()) {
|
|
117
|
+
if (now - sentAt >= pruneAfterMs) {
|
|
118
|
+
this.entries.delete(key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
getLastSentAt(key) {
|
|
123
|
+
const now = this.now();
|
|
124
|
+
this.prune(now);
|
|
125
|
+
return this.entries.get(key);
|
|
126
|
+
}
|
|
127
|
+
recordSent(key) {
|
|
128
|
+
const now = this.now();
|
|
129
|
+
this.entries.set(key, now);
|
|
130
|
+
this.prune(now);
|
|
131
|
+
}
|
|
132
|
+
clearSession(sessionId, appId) {
|
|
133
|
+
if (appId) {
|
|
134
|
+
this.entries.delete(`${sessionId}:${appId}`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const prefix = `${sessionId}:`;
|
|
138
|
+
for (const key of this.entries.keys()) {
|
|
139
|
+
if (key.startsWith(prefix)) {
|
|
140
|
+
this.entries.delete(key);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
entryCount() {
|
|
145
|
+
this.prune(this.now());
|
|
146
|
+
return this.entries.size;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
47
149
|
function toRelayProtocolError(error) {
|
|
48
150
|
if (error instanceof RelayProtocolError) {
|
|
49
151
|
return error;
|
|
@@ -87,10 +189,14 @@ class SessionHub {
|
|
|
87
189
|
sessionProviders = new Map();
|
|
88
190
|
sessionApps = new Map();
|
|
89
191
|
appSessions = new Map();
|
|
90
|
-
|
|
192
|
+
wakeCooldownTracker;
|
|
91
193
|
cluster;
|
|
92
194
|
constructor(params) {
|
|
93
195
|
this.params = params;
|
|
196
|
+
this.wakeCooldownTracker = new WakeCooldownTracker({
|
|
197
|
+
cooldownMs: PUSH_WAKE_COOLDOWN_MS,
|
|
198
|
+
...(params.now ? { now: params.now } : {}),
|
|
199
|
+
});
|
|
94
200
|
}
|
|
95
201
|
setCluster(cluster) {
|
|
96
202
|
this.cluster = cluster;
|
|
@@ -98,20 +204,22 @@ class SessionHub {
|
|
|
98
204
|
now() {
|
|
99
205
|
return this.params.now?.() ?? Date.now();
|
|
100
206
|
}
|
|
207
|
+
async recordAdminMetric(action, label) {
|
|
208
|
+
try {
|
|
209
|
+
await action();
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
console.error(`[privateclaw-relay] failed to record ${label}`, error);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
101
215
|
wakeKey(sessionId, appId) {
|
|
102
216
|
return `${sessionId}:${appId}`;
|
|
103
217
|
}
|
|
104
218
|
clearWakeState(sessionId, appId) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const prefix = `${sessionId}:`;
|
|
110
|
-
for (const key of this.recentWakeSentAt.keys()) {
|
|
111
|
-
if (key.startsWith(prefix)) {
|
|
112
|
-
this.recentWakeSentAt.delete(key);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
219
|
+
this.wakeCooldownTracker.clearSession(sessionId, appId);
|
|
220
|
+
}
|
|
221
|
+
getProviderRateLimitKey(providerSocket) {
|
|
222
|
+
return this.requireProviderId(providerSocket);
|
|
115
223
|
}
|
|
116
224
|
get usesPersistentSessions() {
|
|
117
225
|
return this.params.sessionStore.persistent;
|
|
@@ -165,7 +273,7 @@ class SessionHub {
|
|
|
165
273
|
await this.cluster.subscribeApp(binding.sessionId, binding.appId);
|
|
166
274
|
}
|
|
167
275
|
}
|
|
168
|
-
async forgetLocalAppBinding(appSocket, binding) {
|
|
276
|
+
async forgetLocalAppBinding(appSocket, binding, reason) {
|
|
169
277
|
const appSockets = this.sessionApps.get(binding.sessionId);
|
|
170
278
|
if (!appSockets || appSockets.get(binding.appId) !== appSocket) {
|
|
171
279
|
this.appSessions.delete(appSocket);
|
|
@@ -180,6 +288,9 @@ class SessionHub {
|
|
|
180
288
|
await this.cluster.unsubscribeApp(binding.sessionId, binding.appId);
|
|
181
289
|
await this.cluster.releaseApp(binding);
|
|
182
290
|
}
|
|
291
|
+
if (reason) {
|
|
292
|
+
await this.recordAdminMetric(() => this.params.adminMetricsStore.recordAppDetached(binding.sessionId, binding.appId, this.now(), reason), "app detach metrics");
|
|
293
|
+
}
|
|
183
294
|
return true;
|
|
184
295
|
}
|
|
185
296
|
async attachProvider(providerId, providerSocket) {
|
|
@@ -219,6 +330,7 @@ class SessionHub {
|
|
|
219
330
|
};
|
|
220
331
|
await this.params.sessionStore.saveSession(session);
|
|
221
332
|
await this.rememberLocalProviderSession(providerId, session.sessionId);
|
|
333
|
+
await this.recordAdminMetric(() => this.params.adminMetricsStore.recordSessionCreated(session, this.now()), "session creation metrics");
|
|
222
334
|
return session;
|
|
223
335
|
}
|
|
224
336
|
async requireSession(sessionId) {
|
|
@@ -267,6 +379,7 @@ class SessionHub {
|
|
|
267
379
|
};
|
|
268
380
|
await this.params.sessionStore.saveSession(renewedSession);
|
|
269
381
|
await this.params.pushRegistrationStore.touchSession(sessionId, renewedSession.expiresAt);
|
|
382
|
+
await this.recordAdminMetric(() => this.params.adminMetricsStore.recordSessionRenewed(renewedSession, this.now()), "session renewal metrics");
|
|
270
383
|
return renewedSession;
|
|
271
384
|
}
|
|
272
385
|
async registerAppPushToken(sessionId, appId, token) {
|
|
@@ -331,13 +444,13 @@ class SessionHub {
|
|
|
331
444
|
for (const registration of matchingRegistrations) {
|
|
332
445
|
const wakeKey = this.wakeKey(registration.sessionId, registration.appId);
|
|
333
446
|
const now = this.now();
|
|
334
|
-
const lastWakeSentAt = this.
|
|
447
|
+
const lastWakeSentAt = this.wakeCooldownTracker.getLastSentAt(wakeKey);
|
|
335
448
|
if (lastWakeSentAt !== undefined &&
|
|
336
449
|
now - lastWakeSentAt < PUSH_WAKE_COOLDOWN_MS) {
|
|
337
450
|
console.info(`[privateclaw-relay] wake skipped: cooldown session=${registration.sessionId} appId=${registration.appId} sinceMs=${now - lastWakeSentAt}`);
|
|
338
451
|
continue;
|
|
339
452
|
}
|
|
340
|
-
this.
|
|
453
|
+
this.wakeCooldownTracker.recordSent(wakeKey);
|
|
341
454
|
try {
|
|
342
455
|
const result = await this.params.pushNotifier.sendWake(registration);
|
|
343
456
|
console.info(`[privateclaw-relay] wake sent session=${registration.sessionId} appId=${registration.appId} unregisterToken=${result.unregisterToken}`);
|
|
@@ -389,6 +502,7 @@ class SessionHub {
|
|
|
389
502
|
}
|
|
390
503
|
}
|
|
391
504
|
await this.rememberLocalAppBinding(binding, appSocket);
|
|
505
|
+
await this.recordAdminMetric(() => this.params.adminMetricsStore.recordAppAttached(session, normalizedAppId, this.now()), "app attach metrics");
|
|
392
506
|
return session;
|
|
393
507
|
}
|
|
394
508
|
deliverLocalToApp(sessionId, envelope, targetAppId) {
|
|
@@ -493,7 +607,7 @@ class SessionHub {
|
|
|
493
607
|
if (!binding || binding.appId !== appId) {
|
|
494
608
|
continue;
|
|
495
609
|
}
|
|
496
|
-
await this.forgetLocalAppBinding(appSocket, binding);
|
|
610
|
+
await this.forgetLocalAppBinding(appSocket, binding, reason);
|
|
497
611
|
sendJson(appSocket, {
|
|
498
612
|
type: "relay:session_closed",
|
|
499
613
|
sessionId,
|
|
@@ -521,6 +635,7 @@ class SessionHub {
|
|
|
521
635
|
await this.params.sessionStore.deleteSession(sessionId);
|
|
522
636
|
await this.params.pushRegistrationStore.clearSession(sessionId);
|
|
523
637
|
this.clearWakeState(sessionId);
|
|
638
|
+
await this.recordAdminMetric(() => this.params.adminMetricsStore.recordSessionClosed(sessionId, reason, this.now()), "session close metrics");
|
|
524
639
|
if (!this.hasLocalSession(sessionId) && !this.cluster) {
|
|
525
640
|
await this.params.frameCache.clear(sessionId);
|
|
526
641
|
return;
|
|
@@ -571,7 +686,7 @@ class SessionHub {
|
|
|
571
686
|
if (!binding) {
|
|
572
687
|
return;
|
|
573
688
|
}
|
|
574
|
-
await this.forgetLocalAppBinding(appSocket, binding);
|
|
689
|
+
await this.forgetLocalAppBinding(appSocket, binding, "app_disconnected");
|
|
575
690
|
}
|
|
576
691
|
async closeLocalApp(sessionId, appId, reason) {
|
|
577
692
|
const appSocket = this.sessionApps.get(sessionId)?.get(appId);
|
|
@@ -582,7 +697,7 @@ class SessionHub {
|
|
|
582
697
|
if (!binding) {
|
|
583
698
|
return;
|
|
584
699
|
}
|
|
585
|
-
await this.forgetLocalAppBinding(appSocket, binding);
|
|
700
|
+
await this.forgetLocalAppBinding(appSocket, binding, reason);
|
|
586
701
|
sendJson(appSocket, {
|
|
587
702
|
type: "relay:session_closed",
|
|
588
703
|
sessionId,
|
|
@@ -644,6 +759,36 @@ class SessionHub {
|
|
|
644
759
|
appBindings,
|
|
645
760
|
});
|
|
646
761
|
}
|
|
762
|
+
getAdminLocalSnapshot() {
|
|
763
|
+
const providerIds = [...this.providerSockets.entries()]
|
|
764
|
+
.filter(([, socket]) => socket.readyState === WebSocket.OPEN)
|
|
765
|
+
.map(([providerId]) => providerId)
|
|
766
|
+
.sort();
|
|
767
|
+
const participantBindings = [];
|
|
768
|
+
const sessionIds = new Set(this.sessionProviders.keys());
|
|
769
|
+
for (const [appSocket, binding] of this.appSessions.entries()) {
|
|
770
|
+
const currentSocket = this.sessionApps.get(binding.sessionId)?.get(binding.appId);
|
|
771
|
+
if (currentSocket !== appSocket || appSocket.readyState !== WebSocket.OPEN) {
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
participantBindings.push({
|
|
775
|
+
sessionId: binding.sessionId,
|
|
776
|
+
appId: binding.appId,
|
|
777
|
+
});
|
|
778
|
+
sessionIds.add(binding.sessionId);
|
|
779
|
+
}
|
|
780
|
+
participantBindings.sort((left, right) => left.sessionId.localeCompare(right.sessionId) ||
|
|
781
|
+
left.appId.localeCompare(right.appId));
|
|
782
|
+
return {
|
|
783
|
+
activeProviders: providerIds.length,
|
|
784
|
+
activeApps: participantBindings.length,
|
|
785
|
+
localSessions: sessionIds.size,
|
|
786
|
+
providerIds,
|
|
787
|
+
sessionIds: [...sessionIds].sort(),
|
|
788
|
+
participantBindings,
|
|
789
|
+
memoryUsage: process.memoryUsage(),
|
|
790
|
+
};
|
|
791
|
+
}
|
|
647
792
|
async closeAll(reason) {
|
|
648
793
|
const sessionIds = new Set([
|
|
649
794
|
...this.sessionProviders.keys(),
|
|
@@ -655,6 +800,10 @@ class SessionHub {
|
|
|
655
800
|
}
|
|
656
801
|
}
|
|
657
802
|
export function createRelayServer(config, deps = {}) {
|
|
803
|
+
const maxMessageBytes = config.maxMessageBytes ?? DEFAULT_PROTOCOL_MESSAGE_BYTES;
|
|
804
|
+
const appMessagesPerMinute = config.appMessagesPerMinute ?? DEFAULT_APP_MESSAGES_PER_MINUTE;
|
|
805
|
+
const providerMessagesPerMinute = config.providerMessagesPerMinute ?? DEFAULT_PROVIDER_MESSAGES_PER_MINUTE;
|
|
806
|
+
const websocketMaxPayloadBytes = maxMessageBytes + MESSAGE_SIZE_SLACK_BYTES;
|
|
658
807
|
const ownsFrameCache = !deps.frameCache;
|
|
659
808
|
const frameCache = deps.frameCache ??
|
|
660
809
|
createEncryptedFrameCache({
|
|
@@ -679,12 +828,31 @@ export function createRelayServer(config, deps = {}) {
|
|
|
679
828
|
fcmClientEmail: config.fcmClientEmail,
|
|
680
829
|
fcmPrivateKey: config.fcmPrivateKey,
|
|
681
830
|
});
|
|
831
|
+
const ownsAdminMetricsStore = !deps.adminMetricsStore;
|
|
832
|
+
const adminMetricsStore = deps.adminMetricsStore ??
|
|
833
|
+
createRelayAdminMetricsStore({
|
|
834
|
+
...(config.redisUrl ? { redisUrl: config.redisUrl } : {}),
|
|
835
|
+
});
|
|
836
|
+
const relayStartedAt = Date.now();
|
|
837
|
+
const adminWebRootDir = config.adminToken &&
|
|
838
|
+
(config.adminWebRootDir ?? resolveRelayAdminWebRootDir());
|
|
682
839
|
const sessionHub = new SessionHub({
|
|
683
840
|
defaultTtlMs: config.sessionTtlMs,
|
|
684
841
|
frameCache,
|
|
685
842
|
sessionStore,
|
|
686
843
|
pushRegistrationStore,
|
|
687
844
|
pushNotifier,
|
|
845
|
+
adminMetricsStore,
|
|
846
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
847
|
+
});
|
|
848
|
+
const appMessageRateLimiter = new FixedWindowRateLimiter({
|
|
849
|
+
maxPerWindow: appMessagesPerMinute,
|
|
850
|
+
windowMs: APP_MESSAGE_RATE_LIMIT_WINDOW_MS,
|
|
851
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
852
|
+
});
|
|
853
|
+
const providerMessageRateLimiter = new FixedWindowRateLimiter({
|
|
854
|
+
maxPerWindow: providerMessagesPerMinute,
|
|
855
|
+
windowMs: APP_MESSAGE_RATE_LIMIT_WINDOW_MS,
|
|
688
856
|
...(deps.now ? { now: deps.now } : {}),
|
|
689
857
|
});
|
|
690
858
|
const clusterCallbacks = {
|
|
@@ -722,6 +890,22 @@ export function createRelayServer(config, deps = {}) {
|
|
|
722
890
|
let startedPort = config.port;
|
|
723
891
|
let startedUrl = "";
|
|
724
892
|
let started = false;
|
|
893
|
+
async function recordAdminMetric(action, label) {
|
|
894
|
+
try {
|
|
895
|
+
await action();
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
console.error(`[privateclaw-relay] failed to record ${label}`, error);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
async function recordInstanceHeartbeat() {
|
|
902
|
+
await recordAdminMetric(() => adminMetricsStore.recordInstanceHeartbeat({
|
|
903
|
+
instanceId: clusterNodeId,
|
|
904
|
+
startedAt: relayStartedAt,
|
|
905
|
+
recordedAt: deps.now?.() ?? Date.now(),
|
|
906
|
+
snapshot: sessionHub.getAdminLocalSnapshot(),
|
|
907
|
+
}), "relay instance heartbeat");
|
|
908
|
+
}
|
|
725
909
|
async function handleHttpRequest(request, response) {
|
|
726
910
|
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${config.host}:${startedPort}`}`);
|
|
727
911
|
try {
|
|
@@ -732,6 +916,29 @@ export function createRelayServer(config, deps = {}) {
|
|
|
732
916
|
response.end(JSON.stringify({ ok: true, sessions, instanceId: clusterNodeId }));
|
|
733
917
|
return;
|
|
734
918
|
}
|
|
919
|
+
const handledAdminApi = await handleRelayAdminRequest({
|
|
920
|
+
request,
|
|
921
|
+
response,
|
|
922
|
+
url,
|
|
923
|
+
metricsStore: adminMetricsStore,
|
|
924
|
+
...(config.adminToken ? { adminToken: config.adminToken } : {}),
|
|
925
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
926
|
+
});
|
|
927
|
+
if (handledAdminApi) {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (adminWebRootDir && url.pathname.startsWith("/admin")) {
|
|
931
|
+
const handledAdminWeb = await serveRelayWebRequest({
|
|
932
|
+
request,
|
|
933
|
+
response,
|
|
934
|
+
url,
|
|
935
|
+
webRootDir: adminWebRootDir,
|
|
936
|
+
mountPath: "/admin",
|
|
937
|
+
});
|
|
938
|
+
if (handledAdminWeb) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
735
942
|
if (config.webRootDir) {
|
|
736
943
|
const handled = await serveRelayWebRequest({
|
|
737
944
|
request,
|
|
@@ -762,8 +969,14 @@ export function createRelayServer(config, deps = {}) {
|
|
|
762
969
|
const server = createServer((request, response) => {
|
|
763
970
|
void handleHttpRequest(request, response);
|
|
764
971
|
});
|
|
765
|
-
const providerWss = new WebSocketServer({
|
|
766
|
-
|
|
972
|
+
const providerWss = new WebSocketServer({
|
|
973
|
+
noServer: true,
|
|
974
|
+
maxPayload: websocketMaxPayloadBytes,
|
|
975
|
+
});
|
|
976
|
+
const appWss = new WebSocketServer({
|
|
977
|
+
noServer: true,
|
|
978
|
+
maxPayload: websocketMaxPayloadBytes,
|
|
979
|
+
});
|
|
767
980
|
const expiryTimer = setInterval(() => {
|
|
768
981
|
void sessionHub.purgeExpiredSessions().catch((error) => {
|
|
769
982
|
console.error("[privateclaw-relay] failed to purge expired sessions", error);
|
|
@@ -775,6 +988,7 @@ export function createRelayServer(config, deps = {}) {
|
|
|
775
988
|
void sessionHub.refreshClusterPresence().catch((error) => {
|
|
776
989
|
console.error("[privateclaw-relay] failed to refresh relay presence", error);
|
|
777
990
|
});
|
|
991
|
+
void recordInstanceHeartbeat();
|
|
778
992
|
}, HEARTBEAT_INTERVAL_MS);
|
|
779
993
|
function terminateSockets(server) {
|
|
780
994
|
for (const socket of server.clients) {
|
|
@@ -788,11 +1002,23 @@ export function createRelayServer(config, deps = {}) {
|
|
|
788
1002
|
}
|
|
789
1003
|
async function handleProviderMessage(socket, raw) {
|
|
790
1004
|
let requestId;
|
|
1005
|
+
let requestType = "unknown";
|
|
791
1006
|
try {
|
|
1007
|
+
assertMessageSizeWithinLimit(raw, maxMessageBytes, "Provider");
|
|
792
1008
|
const message = parseJson(raw);
|
|
793
1009
|
if (!isObject(message) || typeof message.type !== "string") {
|
|
794
1010
|
throw new RelayProtocolError("invalid_message", "Provider message is missing a type field.");
|
|
795
1011
|
}
|
|
1012
|
+
requestType = message.type;
|
|
1013
|
+
if ("requestId" in message &&
|
|
1014
|
+
typeof message.requestId === "string" &&
|
|
1015
|
+
message.requestId !== "") {
|
|
1016
|
+
requestId = message.requestId;
|
|
1017
|
+
}
|
|
1018
|
+
const providerRateLimitKey = sessionHub.getProviderRateLimitKey(socket);
|
|
1019
|
+
if (!providerMessageRateLimiter.allow(providerRateLimitKey)) {
|
|
1020
|
+
throw new RelayProtocolError("rate_limit_exceeded", "Too many provider messages. Slow down and retry.");
|
|
1021
|
+
}
|
|
796
1022
|
switch (message.type) {
|
|
797
1023
|
case "provider:create_session": {
|
|
798
1024
|
const requestIdValue = message.requestId;
|
|
@@ -824,6 +1050,12 @@ export function createRelayServer(config, deps = {}) {
|
|
|
824
1050
|
sessionId: session.sessionId,
|
|
825
1051
|
expiresAt: new Date(session.expiresAt).toISOString(),
|
|
826
1052
|
});
|
|
1053
|
+
await recordInstanceHeartbeat();
|
|
1054
|
+
await recordAdminMetric(() => adminMetricsStore.recordRequest({
|
|
1055
|
+
actor: "provider",
|
|
1056
|
+
type: requestType,
|
|
1057
|
+
ok: true,
|
|
1058
|
+
}), "provider request metrics");
|
|
827
1059
|
return;
|
|
828
1060
|
}
|
|
829
1061
|
case "provider:renew_session": {
|
|
@@ -847,6 +1079,12 @@ export function createRelayServer(config, deps = {}) {
|
|
|
847
1079
|
sessionId: session.sessionId,
|
|
848
1080
|
expiresAt: new Date(session.expiresAt).toISOString(),
|
|
849
1081
|
});
|
|
1082
|
+
await recordInstanceHeartbeat();
|
|
1083
|
+
await recordAdminMetric(() => adminMetricsStore.recordRequest({
|
|
1084
|
+
actor: "provider",
|
|
1085
|
+
type: requestType,
|
|
1086
|
+
ok: true,
|
|
1087
|
+
}), "provider request metrics");
|
|
850
1088
|
return;
|
|
851
1089
|
}
|
|
852
1090
|
case "provider:frame": {
|
|
@@ -858,7 +1096,16 @@ export function createRelayServer(config, deps = {}) {
|
|
|
858
1096
|
typeof message.targetAppId !== "string") {
|
|
859
1097
|
throw new RelayProtocolError("invalid_target_app_id", "provider:frame targetAppId must be a string when provided.");
|
|
860
1098
|
}
|
|
861
|
-
|
|
1099
|
+
const sessionIdValue = message.sessionId;
|
|
1100
|
+
await sessionHub.forwardToApp(socket, sessionIdValue, message.envelope, message.targetAppId);
|
|
1101
|
+
await recordAdminMetric(async () => {
|
|
1102
|
+
await adminMetricsStore.recordProviderFrame(sessionIdValue, deps.now?.() ?? Date.now());
|
|
1103
|
+
await adminMetricsStore.recordRequest({
|
|
1104
|
+
actor: "provider",
|
|
1105
|
+
type: requestType,
|
|
1106
|
+
ok: true,
|
|
1107
|
+
});
|
|
1108
|
+
}, "provider frame metrics");
|
|
862
1109
|
return;
|
|
863
1110
|
}
|
|
864
1111
|
case "provider:close_session": {
|
|
@@ -868,6 +1115,12 @@ export function createRelayServer(config, deps = {}) {
|
|
|
868
1115
|
await sessionHub.closeSessionForProvider(socket, message.sessionId, typeof message.reason === "string"
|
|
869
1116
|
? message.reason
|
|
870
1117
|
: "provider_closed");
|
|
1118
|
+
await recordInstanceHeartbeat();
|
|
1119
|
+
await recordAdminMetric(() => adminMetricsStore.recordRequest({
|
|
1120
|
+
actor: "provider",
|
|
1121
|
+
type: requestType,
|
|
1122
|
+
ok: true,
|
|
1123
|
+
}), "provider request metrics");
|
|
871
1124
|
return;
|
|
872
1125
|
}
|
|
873
1126
|
case "provider:close_app": {
|
|
@@ -879,6 +1132,12 @@ export function createRelayServer(config, deps = {}) {
|
|
|
879
1132
|
await sessionHub.closeAppForProvider(socket, message.sessionId, message.appId, typeof message.reason === "string"
|
|
880
1133
|
? message.reason
|
|
881
1134
|
: "provider_closed_app");
|
|
1135
|
+
await recordInstanceHeartbeat();
|
|
1136
|
+
await recordAdminMetric(() => adminMetricsStore.recordRequest({
|
|
1137
|
+
actor: "provider",
|
|
1138
|
+
type: requestType,
|
|
1139
|
+
ok: true,
|
|
1140
|
+
}), "provider request metrics");
|
|
882
1141
|
return;
|
|
883
1142
|
}
|
|
884
1143
|
default:
|
|
@@ -887,6 +1146,14 @@ export function createRelayServer(config, deps = {}) {
|
|
|
887
1146
|
}
|
|
888
1147
|
catch (error) {
|
|
889
1148
|
const relayError = toRelayProtocolError(error);
|
|
1149
|
+
await recordAdminMetric(() => adminMetricsStore.recordRequest({
|
|
1150
|
+
actor: "provider",
|
|
1151
|
+
type: requestType === "unknown" && relayError.code === "invalid_json"
|
|
1152
|
+
? "invalid_json"
|
|
1153
|
+
: requestType,
|
|
1154
|
+
ok: false,
|
|
1155
|
+
errorCode: relayError.code,
|
|
1156
|
+
}), "provider error metrics");
|
|
890
1157
|
sendJson(socket, {
|
|
891
1158
|
type: "relay:error",
|
|
892
1159
|
code: relayError.code,
|
|
@@ -896,26 +1163,50 @@ export function createRelayServer(config, deps = {}) {
|
|
|
896
1163
|
}
|
|
897
1164
|
}
|
|
898
1165
|
async function handleAppMessage(socket, sessionId, appId, raw) {
|
|
1166
|
+
let requestType = "unknown";
|
|
899
1167
|
try {
|
|
1168
|
+
assertMessageSizeWithinLimit(raw, maxMessageBytes, "App");
|
|
1169
|
+
if (!appMessageRateLimiter.allow(`${sessionId}:${appId}`)) {
|
|
1170
|
+
throw new RelayProtocolError("rate_limit_exceeded", "Too many app messages. Slow down and retry.");
|
|
1171
|
+
}
|
|
900
1172
|
const message = parseJson(raw);
|
|
901
1173
|
if (!isObject(message) || typeof message.type !== "string") {
|
|
902
1174
|
throw new RelayProtocolError("invalid_message", "App message is missing a type field.");
|
|
903
1175
|
}
|
|
1176
|
+
requestType = message.type;
|
|
904
1177
|
switch (message.type) {
|
|
905
1178
|
case "app:frame":
|
|
906
1179
|
if (!isEncryptedEnvelope(message.envelope)) {
|
|
907
1180
|
throw new RelayProtocolError("invalid_frame", "app:frame must include a valid encrypted envelope.");
|
|
908
1181
|
}
|
|
909
1182
|
await sessionHub.forwardToProvider(sessionId, message.envelope);
|
|
1183
|
+
await recordAdminMetric(async () => {
|
|
1184
|
+
await adminMetricsStore.recordAppFrame(sessionId, appId, deps.now?.() ?? Date.now());
|
|
1185
|
+
await adminMetricsStore.recordRequest({
|
|
1186
|
+
actor: "app",
|
|
1187
|
+
type: requestType,
|
|
1188
|
+
ok: true,
|
|
1189
|
+
});
|
|
1190
|
+
}, "app frame metrics");
|
|
910
1191
|
return;
|
|
911
1192
|
case "app:register_push":
|
|
912
1193
|
if (typeof message.token !== "string") {
|
|
913
1194
|
throw new RelayProtocolError("invalid_push_token", "app:register_push requires a string token.");
|
|
914
1195
|
}
|
|
915
1196
|
await sessionHub.registerAppPushToken(sessionId, appId, message.token);
|
|
1197
|
+
await recordAdminMetric(() => adminMetricsStore.recordRequest({
|
|
1198
|
+
actor: "app",
|
|
1199
|
+
type: requestType,
|
|
1200
|
+
ok: true,
|
|
1201
|
+
}), "app request metrics");
|
|
916
1202
|
return;
|
|
917
1203
|
case "app:unregister_push":
|
|
918
1204
|
await sessionHub.unregisterAppPushToken(sessionId, appId);
|
|
1205
|
+
await recordAdminMetric(() => adminMetricsStore.recordRequest({
|
|
1206
|
+
actor: "app",
|
|
1207
|
+
type: requestType,
|
|
1208
|
+
ok: true,
|
|
1209
|
+
}), "app request metrics");
|
|
919
1210
|
return;
|
|
920
1211
|
default:
|
|
921
1212
|
throw new RelayProtocolError("unsupported_message", `Unsupported app message type: ${String(message.type)}`);
|
|
@@ -923,6 +1214,14 @@ export function createRelayServer(config, deps = {}) {
|
|
|
923
1214
|
}
|
|
924
1215
|
catch (error) {
|
|
925
1216
|
const relayError = toRelayProtocolError(error);
|
|
1217
|
+
await recordAdminMetric(() => adminMetricsStore.recordRequest({
|
|
1218
|
+
actor: "app",
|
|
1219
|
+
type: requestType === "unknown" && relayError.code === "invalid_json"
|
|
1220
|
+
? "invalid_json"
|
|
1221
|
+
: requestType,
|
|
1222
|
+
ok: false,
|
|
1223
|
+
errorCode: relayError.code,
|
|
1224
|
+
}), "app error metrics");
|
|
926
1225
|
sendJson(socket, {
|
|
927
1226
|
type: "relay:error",
|
|
928
1227
|
code: relayError.code,
|
|
@@ -939,6 +1238,7 @@ export function createRelayServer(config, deps = {}) {
|
|
|
939
1238
|
void (async () => {
|
|
940
1239
|
try {
|
|
941
1240
|
await sessionHub.attachProvider(providerId, socket);
|
|
1241
|
+
await recordInstanceHeartbeat();
|
|
942
1242
|
}
|
|
943
1243
|
catch (error) {
|
|
944
1244
|
const relayError = toRelayProtocolError(error);
|
|
@@ -958,9 +1258,15 @@ export function createRelayServer(config, deps = {}) {
|
|
|
958
1258
|
void handleProviderMessage(socket, data.toString());
|
|
959
1259
|
});
|
|
960
1260
|
socket.on("close", () => {
|
|
961
|
-
void
|
|
962
|
-
|
|
963
|
-
|
|
1261
|
+
void (async () => {
|
|
1262
|
+
try {
|
|
1263
|
+
await sessionHub.detachProvider(socket);
|
|
1264
|
+
}
|
|
1265
|
+
catch (error) {
|
|
1266
|
+
console.error("[privateclaw-relay] failed to detach provider socket", error);
|
|
1267
|
+
}
|
|
1268
|
+
await recordInstanceHeartbeat();
|
|
1269
|
+
})();
|
|
964
1270
|
});
|
|
965
1271
|
})();
|
|
966
1272
|
});
|
|
@@ -983,6 +1289,7 @@ export function createRelayServer(config, deps = {}) {
|
|
|
983
1289
|
let session;
|
|
984
1290
|
try {
|
|
985
1291
|
session = await sessionHub.attachApp(sessionId, appId, socket);
|
|
1292
|
+
await recordInstanceHeartbeat();
|
|
986
1293
|
}
|
|
987
1294
|
catch (error) {
|
|
988
1295
|
const relayError = toRelayProtocolError(error);
|
|
@@ -1007,9 +1314,15 @@ export function createRelayServer(config, deps = {}) {
|
|
|
1007
1314
|
void handleAppMessage(socket, sessionId, appId, data.toString());
|
|
1008
1315
|
});
|
|
1009
1316
|
socket.on("close", () => {
|
|
1010
|
-
void
|
|
1011
|
-
|
|
1012
|
-
|
|
1317
|
+
void (async () => {
|
|
1318
|
+
try {
|
|
1319
|
+
await sessionHub.detachApp(socket);
|
|
1320
|
+
}
|
|
1321
|
+
catch (error) {
|
|
1322
|
+
console.error("[privateclaw-relay] failed to detach app socket", error);
|
|
1323
|
+
}
|
|
1324
|
+
await recordInstanceHeartbeat();
|
|
1325
|
+
})();
|
|
1013
1326
|
});
|
|
1014
1327
|
})();
|
|
1015
1328
|
});
|
|
@@ -1046,6 +1359,9 @@ export function createRelayServer(config, deps = {}) {
|
|
|
1046
1359
|
if (ownsFrameCache) {
|
|
1047
1360
|
closes.push(frameCache.close());
|
|
1048
1361
|
}
|
|
1362
|
+
if (ownsAdminMetricsStore) {
|
|
1363
|
+
closes.push(adminMetricsStore.close());
|
|
1364
|
+
}
|
|
1049
1365
|
await Promise.all(closes);
|
|
1050
1366
|
}
|
|
1051
1367
|
return {
|
|
@@ -1073,6 +1389,7 @@ export function createRelayServer(config, deps = {}) {
|
|
|
1073
1389
|
startedPort = address.port;
|
|
1074
1390
|
startedUrl = `http://${config.host}:${startedPort}`;
|
|
1075
1391
|
started = true;
|
|
1392
|
+
await recordInstanceHeartbeat();
|
|
1076
1393
|
return { port: startedPort, url: startedUrl };
|
|
1077
1394
|
},
|
|
1078
1395
|
async stop() {
|
|
@@ -1100,6 +1417,7 @@ export function createRelayServer(config, deps = {}) {
|
|
|
1100
1417
|
resolve();
|
|
1101
1418
|
});
|
|
1102
1419
|
});
|
|
1420
|
+
await recordAdminMetric(() => adminMetricsStore.unregisterInstance(clusterNodeId), "relay instance shutdown");
|
|
1103
1421
|
await closeOwnedResources();
|
|
1104
1422
|
started = false;
|
|
1105
1423
|
startedUrl = "";
|