@privateclaw/privateclaw-relay 0.1.7 → 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,12 +1,14 @@
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;
12
14
  const DEFAULT_PROTOCOL_MESSAGE_BYTES = 24 * 1024 * 1024;
@@ -65,6 +67,14 @@ class FixedWindowRateLimiter {
65
67
  now() {
66
68
  return this.params.now?.() ?? Date.now();
67
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
+ }
68
78
  allow(key) {
69
79
  const now = this.now();
70
80
  const existing = this.entries.get(key);
@@ -194,6 +204,14 @@ class SessionHub {
194
204
  now() {
195
205
  return this.params.now?.() ?? Date.now();
196
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
+ }
197
215
  wakeKey(sessionId, appId) {
198
216
  return `${sessionId}:${appId}`;
199
217
  }
@@ -255,7 +273,7 @@ class SessionHub {
255
273
  await this.cluster.subscribeApp(binding.sessionId, binding.appId);
256
274
  }
257
275
  }
258
- async forgetLocalAppBinding(appSocket, binding) {
276
+ async forgetLocalAppBinding(appSocket, binding, reason) {
259
277
  const appSockets = this.sessionApps.get(binding.sessionId);
260
278
  if (!appSockets || appSockets.get(binding.appId) !== appSocket) {
261
279
  this.appSessions.delete(appSocket);
@@ -270,6 +288,9 @@ class SessionHub {
270
288
  await this.cluster.unsubscribeApp(binding.sessionId, binding.appId);
271
289
  await this.cluster.releaseApp(binding);
272
290
  }
291
+ if (reason) {
292
+ await this.recordAdminMetric(() => this.params.adminMetricsStore.recordAppDetached(binding.sessionId, binding.appId, this.now(), reason), "app detach metrics");
293
+ }
273
294
  return true;
274
295
  }
275
296
  async attachProvider(providerId, providerSocket) {
@@ -309,6 +330,7 @@ class SessionHub {
309
330
  };
310
331
  await this.params.sessionStore.saveSession(session);
311
332
  await this.rememberLocalProviderSession(providerId, session.sessionId);
333
+ await this.recordAdminMetric(() => this.params.adminMetricsStore.recordSessionCreated(session, this.now()), "session creation metrics");
312
334
  return session;
313
335
  }
314
336
  async requireSession(sessionId) {
@@ -357,6 +379,7 @@ class SessionHub {
357
379
  };
358
380
  await this.params.sessionStore.saveSession(renewedSession);
359
381
  await this.params.pushRegistrationStore.touchSession(sessionId, renewedSession.expiresAt);
382
+ await this.recordAdminMetric(() => this.params.adminMetricsStore.recordSessionRenewed(renewedSession, this.now()), "session renewal metrics");
360
383
  return renewedSession;
361
384
  }
362
385
  async registerAppPushToken(sessionId, appId, token) {
@@ -479,6 +502,7 @@ class SessionHub {
479
502
  }
480
503
  }
481
504
  await this.rememberLocalAppBinding(binding, appSocket);
505
+ await this.recordAdminMetric(() => this.params.adminMetricsStore.recordAppAttached(session, normalizedAppId, this.now()), "app attach metrics");
482
506
  return session;
483
507
  }
484
508
  deliverLocalToApp(sessionId, envelope, targetAppId) {
@@ -583,7 +607,7 @@ class SessionHub {
583
607
  if (!binding || binding.appId !== appId) {
584
608
  continue;
585
609
  }
586
- await this.forgetLocalAppBinding(appSocket, binding);
610
+ await this.forgetLocalAppBinding(appSocket, binding, reason);
587
611
  sendJson(appSocket, {
588
612
  type: "relay:session_closed",
589
613
  sessionId,
@@ -611,6 +635,7 @@ class SessionHub {
611
635
  await this.params.sessionStore.deleteSession(sessionId);
612
636
  await this.params.pushRegistrationStore.clearSession(sessionId);
613
637
  this.clearWakeState(sessionId);
638
+ await this.recordAdminMetric(() => this.params.adminMetricsStore.recordSessionClosed(sessionId, reason, this.now()), "session close metrics");
614
639
  if (!this.hasLocalSession(sessionId) && !this.cluster) {
615
640
  await this.params.frameCache.clear(sessionId);
616
641
  return;
@@ -661,7 +686,7 @@ class SessionHub {
661
686
  if (!binding) {
662
687
  return;
663
688
  }
664
- await this.forgetLocalAppBinding(appSocket, binding);
689
+ await this.forgetLocalAppBinding(appSocket, binding, "app_disconnected");
665
690
  }
666
691
  async closeLocalApp(sessionId, appId, reason) {
667
692
  const appSocket = this.sessionApps.get(sessionId)?.get(appId);
@@ -672,7 +697,7 @@ class SessionHub {
672
697
  if (!binding) {
673
698
  return;
674
699
  }
675
- await this.forgetLocalAppBinding(appSocket, binding);
700
+ await this.forgetLocalAppBinding(appSocket, binding, reason);
676
701
  sendJson(appSocket, {
677
702
  type: "relay:session_closed",
678
703
  sessionId,
@@ -734,6 +759,36 @@ class SessionHub {
734
759
  appBindings,
735
760
  });
736
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
+ }
737
792
  async closeAll(reason) {
738
793
  const sessionIds = new Set([
739
794
  ...this.sessionProviders.keys(),
@@ -773,12 +828,21 @@ export function createRelayServer(config, deps = {}) {
773
828
  fcmClientEmail: config.fcmClientEmail,
774
829
  fcmPrivateKey: config.fcmPrivateKey,
775
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());
776
839
  const sessionHub = new SessionHub({
777
840
  defaultTtlMs: config.sessionTtlMs,
778
841
  frameCache,
779
842
  sessionStore,
780
843
  pushRegistrationStore,
781
844
  pushNotifier,
845
+ adminMetricsStore,
782
846
  ...(deps.now ? { now: deps.now } : {}),
783
847
  });
784
848
  const appMessageRateLimiter = new FixedWindowRateLimiter({
@@ -826,6 +890,22 @@ export function createRelayServer(config, deps = {}) {
826
890
  let startedPort = config.port;
827
891
  let startedUrl = "";
828
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
+ }
829
909
  async function handleHttpRequest(request, response) {
830
910
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${config.host}:${startedPort}`}`);
831
911
  try {
@@ -836,6 +916,29 @@ export function createRelayServer(config, deps = {}) {
836
916
  response.end(JSON.stringify({ ok: true, sessions, instanceId: clusterNodeId }));
837
917
  return;
838
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
+ }
839
942
  if (config.webRootDir) {
840
943
  const handled = await serveRelayWebRequest({
841
944
  request,
@@ -885,6 +988,7 @@ export function createRelayServer(config, deps = {}) {
885
988
  void sessionHub.refreshClusterPresence().catch((error) => {
886
989
  console.error("[privateclaw-relay] failed to refresh relay presence", error);
887
990
  });
991
+ void recordInstanceHeartbeat();
888
992
  }, HEARTBEAT_INTERVAL_MS);
889
993
  function terminateSockets(server) {
890
994
  for (const socket of server.clients) {
@@ -898,12 +1002,14 @@ export function createRelayServer(config, deps = {}) {
898
1002
  }
899
1003
  async function handleProviderMessage(socket, raw) {
900
1004
  let requestId;
1005
+ let requestType = "unknown";
901
1006
  try {
902
1007
  assertMessageSizeWithinLimit(raw, maxMessageBytes, "Provider");
903
1008
  const message = parseJson(raw);
904
1009
  if (!isObject(message) || typeof message.type !== "string") {
905
1010
  throw new RelayProtocolError("invalid_message", "Provider message is missing a type field.");
906
1011
  }
1012
+ requestType = message.type;
907
1013
  if ("requestId" in message &&
908
1014
  typeof message.requestId === "string" &&
909
1015
  message.requestId !== "") {
@@ -944,6 +1050,12 @@ export function createRelayServer(config, deps = {}) {
944
1050
  sessionId: session.sessionId,
945
1051
  expiresAt: new Date(session.expiresAt).toISOString(),
946
1052
  });
1053
+ await recordInstanceHeartbeat();
1054
+ await recordAdminMetric(() => adminMetricsStore.recordRequest({
1055
+ actor: "provider",
1056
+ type: requestType,
1057
+ ok: true,
1058
+ }), "provider request metrics");
947
1059
  return;
948
1060
  }
949
1061
  case "provider:renew_session": {
@@ -967,6 +1079,12 @@ export function createRelayServer(config, deps = {}) {
967
1079
  sessionId: session.sessionId,
968
1080
  expiresAt: new Date(session.expiresAt).toISOString(),
969
1081
  });
1082
+ await recordInstanceHeartbeat();
1083
+ await recordAdminMetric(() => adminMetricsStore.recordRequest({
1084
+ actor: "provider",
1085
+ type: requestType,
1086
+ ok: true,
1087
+ }), "provider request metrics");
970
1088
  return;
971
1089
  }
972
1090
  case "provider:frame": {
@@ -978,7 +1096,16 @@ export function createRelayServer(config, deps = {}) {
978
1096
  typeof message.targetAppId !== "string") {
979
1097
  throw new RelayProtocolError("invalid_target_app_id", "provider:frame targetAppId must be a string when provided.");
980
1098
  }
981
- 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");
982
1109
  return;
983
1110
  }
984
1111
  case "provider:close_session": {
@@ -988,6 +1115,12 @@ export function createRelayServer(config, deps = {}) {
988
1115
  await sessionHub.closeSessionForProvider(socket, message.sessionId, typeof message.reason === "string"
989
1116
  ? message.reason
990
1117
  : "provider_closed");
1118
+ await recordInstanceHeartbeat();
1119
+ await recordAdminMetric(() => adminMetricsStore.recordRequest({
1120
+ actor: "provider",
1121
+ type: requestType,
1122
+ ok: true,
1123
+ }), "provider request metrics");
991
1124
  return;
992
1125
  }
993
1126
  case "provider:close_app": {
@@ -999,6 +1132,12 @@ export function createRelayServer(config, deps = {}) {
999
1132
  await sessionHub.closeAppForProvider(socket, message.sessionId, message.appId, typeof message.reason === "string"
1000
1133
  ? message.reason
1001
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");
1002
1141
  return;
1003
1142
  }
1004
1143
  default:
@@ -1007,6 +1146,14 @@ export function createRelayServer(config, deps = {}) {
1007
1146
  }
1008
1147
  catch (error) {
1009
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");
1010
1157
  sendJson(socket, {
1011
1158
  type: "relay:error",
1012
1159
  code: relayError.code,
@@ -1016,6 +1163,7 @@ export function createRelayServer(config, deps = {}) {
1016
1163
  }
1017
1164
  }
1018
1165
  async function handleAppMessage(socket, sessionId, appId, raw) {
1166
+ let requestType = "unknown";
1019
1167
  try {
1020
1168
  assertMessageSizeWithinLimit(raw, maxMessageBytes, "App");
1021
1169
  if (!appMessageRateLimiter.allow(`${sessionId}:${appId}`)) {
@@ -1025,21 +1173,40 @@ export function createRelayServer(config, deps = {}) {
1025
1173
  if (!isObject(message) || typeof message.type !== "string") {
1026
1174
  throw new RelayProtocolError("invalid_message", "App message is missing a type field.");
1027
1175
  }
1176
+ requestType = message.type;
1028
1177
  switch (message.type) {
1029
1178
  case "app:frame":
1030
1179
  if (!isEncryptedEnvelope(message.envelope)) {
1031
1180
  throw new RelayProtocolError("invalid_frame", "app:frame must include a valid encrypted envelope.");
1032
1181
  }
1033
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");
1034
1191
  return;
1035
1192
  case "app:register_push":
1036
1193
  if (typeof message.token !== "string") {
1037
1194
  throw new RelayProtocolError("invalid_push_token", "app:register_push requires a string token.");
1038
1195
  }
1039
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");
1040
1202
  return;
1041
1203
  case "app:unregister_push":
1042
1204
  await sessionHub.unregisterAppPushToken(sessionId, appId);
1205
+ await recordAdminMetric(() => adminMetricsStore.recordRequest({
1206
+ actor: "app",
1207
+ type: requestType,
1208
+ ok: true,
1209
+ }), "app request metrics");
1043
1210
  return;
1044
1211
  default:
1045
1212
  throw new RelayProtocolError("unsupported_message", `Unsupported app message type: ${String(message.type)}`);
@@ -1047,6 +1214,14 @@ export function createRelayServer(config, deps = {}) {
1047
1214
  }
1048
1215
  catch (error) {
1049
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");
1050
1225
  sendJson(socket, {
1051
1226
  type: "relay:error",
1052
1227
  code: relayError.code,
@@ -1063,6 +1238,7 @@ export function createRelayServer(config, deps = {}) {
1063
1238
  void (async () => {
1064
1239
  try {
1065
1240
  await sessionHub.attachProvider(providerId, socket);
1241
+ await recordInstanceHeartbeat();
1066
1242
  }
1067
1243
  catch (error) {
1068
1244
  const relayError = toRelayProtocolError(error);
@@ -1082,9 +1258,15 @@ export function createRelayServer(config, deps = {}) {
1082
1258
  void handleProviderMessage(socket, data.toString());
1083
1259
  });
1084
1260
  socket.on("close", () => {
1085
- void sessionHub.detachProvider(socket).catch((error) => {
1086
- console.error("[privateclaw-relay] failed to detach provider socket", error);
1087
- });
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
+ })();
1088
1270
  });
1089
1271
  })();
1090
1272
  });
@@ -1107,6 +1289,7 @@ export function createRelayServer(config, deps = {}) {
1107
1289
  let session;
1108
1290
  try {
1109
1291
  session = await sessionHub.attachApp(sessionId, appId, socket);
1292
+ await recordInstanceHeartbeat();
1110
1293
  }
1111
1294
  catch (error) {
1112
1295
  const relayError = toRelayProtocolError(error);
@@ -1131,9 +1314,15 @@ export function createRelayServer(config, deps = {}) {
1131
1314
  void handleAppMessage(socket, sessionId, appId, data.toString());
1132
1315
  });
1133
1316
  socket.on("close", () => {
1134
- void sessionHub.detachApp(socket).catch((error) => {
1135
- console.error("[privateclaw-relay] failed to detach app socket", error);
1136
- });
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
+ })();
1137
1326
  });
1138
1327
  })();
1139
1328
  });
@@ -1170,6 +1359,9 @@ export function createRelayServer(config, deps = {}) {
1170
1359
  if (ownsFrameCache) {
1171
1360
  closes.push(frameCache.close());
1172
1361
  }
1362
+ if (ownsAdminMetricsStore) {
1363
+ closes.push(adminMetricsStore.close());
1364
+ }
1173
1365
  await Promise.all(closes);
1174
1366
  }
1175
1367
  return {
@@ -1197,6 +1389,7 @@ export function createRelayServer(config, deps = {}) {
1197
1389
  startedPort = address.port;
1198
1390
  startedUrl = `http://${config.host}:${startedPort}`;
1199
1391
  started = true;
1392
+ await recordInstanceHeartbeat();
1200
1393
  return { port: startedPort, url: startedUrl };
1201
1394
  },
1202
1395
  async stop() {
@@ -1224,6 +1417,7 @@ export function createRelayServer(config, deps = {}) {
1224
1417
  resolve();
1225
1418
  });
1226
1419
  });
1420
+ await recordAdminMetric(() => adminMetricsStore.unregisterInstance(clusterNodeId), "relay instance shutdown");
1227
1421
  await closeOwnedResources();
1228
1422
  started = false;
1229
1423
  startedUrl = "";