@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.
@@ -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
- recentWakeSentAt = new Map();
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
- if (appId) {
106
- this.recentWakeSentAt.delete(this.wakeKey(sessionId, appId));
107
- return;
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.recentWakeSentAt.get(wakeKey);
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.recentWakeSentAt.set(wakeKey, now);
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({ noServer: true });
766
- const appWss = new WebSocketServer({ noServer: true });
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
- await sessionHub.forwardToApp(socket, message.sessionId, message.envelope, message.targetAppId);
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 sessionHub.detachProvider(socket).catch((error) => {
962
- console.error("[privateclaw-relay] failed to detach provider socket", error);
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 sessionHub.detachApp(socket).catch((error) => {
1011
- console.error("[privateclaw-relay] failed to detach app socket", error);
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 = "";