@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
package/.env.example ADDED
@@ -0,0 +1,13 @@
1
+ PRIVATECLAW_RELAY_HOST=127.0.0.1
2
+ PRIVATECLAW_RELAY_PORT=8787
3
+ PRIVATECLAW_SESSION_TTL_MS=900000
4
+ PRIVATECLAW_FRAME_CACHE_SIZE=25
5
+ # Optional: enable background wake through Firebase Cloud Messaging.
6
+ # Use either the full service-account JSON...
7
+ # PRIVATECLAW_FCM_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"your-project","client_email":"...","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"}
8
+ # ...or the split fields below.
9
+ # PRIVATECLAW_FCM_PROJECT_ID=
10
+ # PRIVATECLAW_FCM_CLIENT_EMAIL=
11
+ # PRIVATECLAW_FCM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
12
+ # Optional: encrypted frame cache only
13
+ # PRIVATECLAW_REDIS_URL=redis://127.0.0.1:6379
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # `@privateclaw/privateclaw-relay`
2
+
3
+ `@privateclaw/privateclaw-relay` is the standalone relay package for PrivateClaw.
4
+
5
+ It starts the local blind WebSocket relay used by the PrivateClaw provider and app, and it can optionally expose that local relay to the public internet with one command through:
6
+
7
+ - Tailscale Funnel
8
+ - Cloudflare quick tunnels
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install -g @privateclaw/privateclaw-relay
14
+ ```
15
+
16
+ Or run it directly without a global install:
17
+
18
+ ```bash
19
+ npx @privateclaw/privateclaw-relay
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Start a local relay with the default `127.0.0.1:8787` binding:
25
+
26
+ ```bash
27
+ privateclaw-relay
28
+ ```
29
+
30
+ If the default local port `8787` is already occupied, the CLI automatically retries the next free port and prints the final listening URL.
31
+
32
+ Override the bind address or Redis URL:
33
+
34
+ ```bash
35
+ privateclaw-relay --host 0.0.0.0 --port 8787
36
+ privateclaw-relay --redis-url redis://127.0.0.1:6379
37
+ ```
38
+
39
+ Expose the local relay to the internet through Tailscale Funnel:
40
+
41
+ ```bash
42
+ privateclaw-relay --public tailscale
43
+ ```
44
+
45
+ Expose the local relay through a temporary Cloudflare quick tunnel:
46
+
47
+ ```bash
48
+ privateclaw-relay --public cloudflare
49
+ ```
50
+
51
+ If the required `tailscale` or `cloudflared` CLI is missing, `privateclaw-relay` prints platform-aware install commands. In an interactive terminal it can also offer to run the supported install/setup commands for you before retrying the tunnel startup.
52
+
53
+ ## Tunnel notes
54
+
55
+ ### Tailscale Funnel
56
+
57
+ - Requires the `tailscale` CLI to be installed and authenticated locally.
58
+ - Requires Funnel to be enabled for your tailnet.
59
+ - The CLI uses `tailscale funnel --bg <port>` and prints the detected public URL when available.
60
+ - If this CLI created the Funnel endpoint itself, it will try to disable it again on shutdown with `tailscale funnel off`.
61
+ - If `tailscale` is missing, the CLI can offer Homebrew / winget / Linux installer guidance and, on supported setups, run the install flow interactively before retrying Funnel startup.
62
+
63
+ ### Cloudflare Tunnel
64
+
65
+ - Requires the `cloudflared` CLI to be installed locally.
66
+ - Uses a temporary quick tunnel with `cloudflared tunnel --url http://127.0.0.1:<port>`.
67
+ - Quick tunnels use a random `trycloudflare.com` URL and are intended for temporary sharing and testing.
68
+ - If `cloudflared` is missing, the CLI prints platform-aware install guidance and can offer an interactive install on supported setups such as Homebrew or winget.
69
+
70
+ ## Environment variables
71
+
72
+ The relay still reads its runtime config from the process environment:
73
+
74
+ - `PRIVATECLAW_RELAY_HOST`
75
+ - `PRIVATECLAW_RELAY_PORT`
76
+ - `PRIVATECLAW_SESSION_TTL_MS`
77
+ - `PRIVATECLAW_FRAME_CACHE_SIZE`
78
+ - `PRIVATECLAW_RELAY_INSTANCE_ID`
79
+ - `PRIVATECLAW_REDIS_URL`
80
+ - `REDIS_URL`
81
+ - `PRIVATECLAW_FCM_SERVICE_ACCOUNT_JSON`
82
+ - `PRIVATECLAW_FCM_PROJECT_ID`
83
+ - `PRIVATECLAW_FCM_CLIENT_EMAIL`
84
+ - `PRIVATECLAW_FCM_PRIVATE_KEY`
85
+
86
+ See the repository root `README.md` for the larger deployment story, Docker images, Railway configs, and Redis-backed HA notes.
@@ -0,0 +1,3 @@
1
+ export declare class RelayCliUserError extends Error {
2
+ constructor(message: string);
3
+ }
@@ -0,0 +1,7 @@
1
+ export class RelayCliUserError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "RelayCliUserError";
5
+ }
6
+ }
7
+ //# sourceMappingURL=cli-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-error.js","sourceRoot":"","sources":["../src/cli-error.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IAC1C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;IAClC,CAAC;CACF"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { RelayCliUserError } from "./cli-error.js";
3
+ import { runRelayCli } from "./relay-cli.js";
4
+ try {
5
+ await runRelayCli(process.argv.slice(2));
6
+ }
7
+ catch (error) {
8
+ if (error instanceof RelayCliUserError) {
9
+ console.error(error.message);
10
+ process.exit(1);
11
+ }
12
+ throw error;
13
+ }
14
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAE7C,IAAI,CAAC;IACH,MAAM,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;AAC3C,CAAC;AAAC,OAAO,KAAK,EAAE,CAAC;IACf,IAAI,KAAK,YAAY,iBAAiB,EAAE,CAAC;QACvC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAC7B,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,MAAM,KAAK,CAAC;AACd,CAAC"}
@@ -0,0 +1,13 @@
1
+ export interface RelayServerConfig {
2
+ host: string;
3
+ port: number;
4
+ sessionTtlMs: number;
5
+ frameCacheSize: number;
6
+ instanceId?: string;
7
+ redisUrl?: string;
8
+ fcmServiceAccountJson?: string;
9
+ fcmProjectId?: string;
10
+ fcmClientEmail?: string;
11
+ fcmPrivateKey?: string;
12
+ }
13
+ export declare function loadRelayConfig(env?: NodeJS.ProcessEnv): RelayServerConfig;
package/dist/config.js ADDED
@@ -0,0 +1,36 @@
1
+ function parsePositiveInteger(value, fallback, label) {
2
+ if (!value || value.trim() === "") {
3
+ return fallback;
4
+ }
5
+ const parsed = Number.parseInt(value, 10);
6
+ if (!Number.isInteger(parsed) || parsed <= 0) {
7
+ throw new Error(`${label} must be a positive integer.`);
8
+ }
9
+ return parsed;
10
+ }
11
+ export function loadRelayConfig(env = process.env) {
12
+ const host = env.PRIVATECLAW_RELAY_HOST?.trim() || "127.0.0.1";
13
+ const port = parsePositiveInteger(env.PRIVATECLAW_RELAY_PORT?.trim() || env.PORT?.trim(), 8787, "PRIVATECLAW_RELAY_PORT");
14
+ const sessionTtlMs = parsePositiveInteger(env.PRIVATECLAW_SESSION_TTL_MS, 15 * 60 * 1000, "PRIVATECLAW_SESSION_TTL_MS");
15
+ const frameCacheSize = parsePositiveInteger(env.PRIVATECLAW_FRAME_CACHE_SIZE, 25, "PRIVATECLAW_FRAME_CACHE_SIZE");
16
+ const instanceId = env.PRIVATECLAW_RELAY_INSTANCE_ID?.trim() ||
17
+ env.RAILWAY_REPLICA_ID?.trim();
18
+ const redisUrl = env.PRIVATECLAW_REDIS_URL?.trim() || env.REDIS_URL?.trim();
19
+ const fcmServiceAccountJson = env.PRIVATECLAW_FCM_SERVICE_ACCOUNT_JSON?.trim();
20
+ const fcmProjectId = env.PRIVATECLAW_FCM_PROJECT_ID?.trim();
21
+ const fcmClientEmail = env.PRIVATECLAW_FCM_CLIENT_EMAIL?.trim();
22
+ const fcmPrivateKey = env.PRIVATECLAW_FCM_PRIVATE_KEY?.trim();
23
+ return {
24
+ host,
25
+ port,
26
+ sessionTtlMs,
27
+ frameCacheSize,
28
+ ...(instanceId ? { instanceId } : {}),
29
+ ...(redisUrl ? { redisUrl } : {}),
30
+ ...(fcmServiceAccountJson ? { fcmServiceAccountJson } : {}),
31
+ ...(fcmProjectId ? { fcmProjectId } : {}),
32
+ ...(fcmClientEmail ? { fcmClientEmail } : {}),
33
+ ...(fcmPrivateKey ? { fcmPrivateKey } : {}),
34
+ };
35
+ }
36
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAaA,SAAS,oBAAoB,CAC3B,KAAyB,EACzB,QAAgB,EAChB,KAAa;IAEb,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAClC,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAC1C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,MAAM,IAAI,CAAC,EAAE,CAAC;QAC7C,MAAM,IAAI,KAAK,CAAC,GAAG,KAAK,8BAA8B,CAAC,CAAC;IAC1D,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,MAAyB,OAAO,CAAC,GAAG;IAClE,MAAM,IAAI,GAAG,GAAG,CAAC,sBAAsB,EAAE,IAAI,EAAE,IAAI,WAAW,CAAC;IAC/D,MAAM,IAAI,GAAG,oBAAoB,CAC/B,GAAG,CAAC,sBAAsB,EAAE,IAAI,EAAE,IAAI,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,EACtD,IAAI,EACJ,wBAAwB,CACzB,CAAC;IACF,MAAM,YAAY,GAAG,oBAAoB,CACvC,GAAG,CAAC,0BAA0B,EAC9B,EAAE,GAAG,EAAE,GAAG,IAAI,EACd,4BAA4B,CAC7B,CAAC;IACF,MAAM,cAAc,GAAG,oBAAoB,CACzC,GAAG,CAAC,4BAA4B,EAChC,EAAE,EACF,8BAA8B,CAC/B,CAAC;IACF,MAAM,UAAU,GACd,GAAG,CAAC,6BAA6B,EAAE,IAAI,EAAE;QACzC,GAAG,CAAC,kBAAkB,EAAE,IAAI,EAAE,CAAC;IACjC,MAAM,QAAQ,GAAG,GAAG,CAAC,qBAAqB,EAAE,IAAI,EAAE,IAAI,GAAG,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC;IAC5E,MAAM,qBAAqB,GACzB,GAAG,CAAC,oCAAoC,EAAE,IAAI,EAAE,CAAC;IACnD,MAAM,YAAY,GAAG,GAAG,CAAC,0BAA0B,EAAE,IAAI,EAAE,CAAC;IAC5D,MAAM,cAAc,GAAG,GAAG,CAAC,4BAA4B,EAAE,IAAI,EAAE,CAAC;IAChE,MAAM,aAAa,GAAG,GAAG,CAAC,2BAA2B,EAAE,IAAI,EAAE,CAAC;IAE9D,OAAO;QACL,IAAI;QACJ,IAAI;QACJ,YAAY;QACZ,cAAc;QACd,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACrC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjC,GAAG,CAAC,qBAAqB,CAAC,CAAC,CAAC,EAAE,qBAAqB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzC,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7C,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC5C,CAAC;AACJ,CAAC"}
@@ -0,0 +1,77 @@
1
+ import type { CachedRelayFrameTarget, EncryptedEnvelope } from "@privateclaw/protocol";
2
+ export interface EncryptedFrameCache {
3
+ push(params: {
4
+ sessionId: string;
5
+ target: CachedRelayFrameTarget;
6
+ envelope: EncryptedEnvelope;
7
+ appId?: string;
8
+ }): Promise<void>;
9
+ drain(params: {
10
+ sessionId: string;
11
+ target: CachedRelayFrameTarget;
12
+ appId?: string;
13
+ }): Promise<EncryptedEnvelope[]>;
14
+ clearApp(sessionId: string, appId: string): Promise<void>;
15
+ clear(sessionId: string): Promise<void>;
16
+ close(): Promise<void>;
17
+ }
18
+ export interface RedisFrameCacheMultiLike {
19
+ lpush(key: string, value: string): RedisFrameCacheMultiLike;
20
+ ltrim(key: string, start: number, stop: number): RedisFrameCacheMultiLike;
21
+ expire(key: string, seconds: number): RedisFrameCacheMultiLike;
22
+ exec(): Promise<unknown>;
23
+ }
24
+ export interface RedisFrameCacheClientLike {
25
+ multi(): RedisFrameCacheMultiLike;
26
+ eval(script: string, numKeys: number, ...keysAndArgs: string[]): Promise<unknown>;
27
+ scan(cursor: string, matchToken: string, pattern: string, countToken: string, count: number): Promise<[string, string[]]>;
28
+ del(...keys: string[]): Promise<number>;
29
+ quit(): Promise<unknown>;
30
+ }
31
+ export declare class InMemoryEncryptedFrameCache implements EncryptedFrameCache {
32
+ private readonly maxFrames;
33
+ private readonly frames;
34
+ constructor(maxFrames: number);
35
+ private key;
36
+ push(params: {
37
+ sessionId: string;
38
+ target: CachedRelayFrameTarget;
39
+ envelope: EncryptedEnvelope;
40
+ appId?: string;
41
+ }): Promise<void>;
42
+ drain(params: {
43
+ sessionId: string;
44
+ target: CachedRelayFrameTarget;
45
+ appId?: string;
46
+ }): Promise<EncryptedEnvelope[]>;
47
+ clear(sessionId: string): Promise<void>;
48
+ clearApp(sessionId: string, appId: string): Promise<void>;
49
+ close(): Promise<void>;
50
+ }
51
+ export declare class RedisEncryptedFrameCache implements EncryptedFrameCache {
52
+ private readonly maxFrames;
53
+ private readonly redis;
54
+ constructor(redisUrl: string, maxFrames: number, redisClient?: RedisFrameCacheClientLike);
55
+ private key;
56
+ private normalizeRawFrameValue;
57
+ private drainSerializedFrames;
58
+ push(params: {
59
+ sessionId: string;
60
+ target: CachedRelayFrameTarget;
61
+ envelope: EncryptedEnvelope;
62
+ appId?: string;
63
+ }): Promise<void>;
64
+ drain(params: {
65
+ sessionId: string;
66
+ target: CachedRelayFrameTarget;
67
+ appId?: string;
68
+ }): Promise<EncryptedEnvelope[]>;
69
+ clear(sessionId: string): Promise<void>;
70
+ clearApp(sessionId: string, appId: string): Promise<void>;
71
+ close(): Promise<void>;
72
+ }
73
+ export declare function createEncryptedFrameCache(params: {
74
+ redisUrl?: string;
75
+ maxFrames: number;
76
+ }): EncryptedFrameCache;
77
+ export declare function createInMemoryEncryptedFrameCache(maxFrames: number): EncryptedFrameCache;
@@ -0,0 +1,127 @@
1
+ import { Redis } from "ioredis";
2
+ export class InMemoryEncryptedFrameCache {
3
+ maxFrames;
4
+ frames = new Map();
5
+ constructor(maxFrames) {
6
+ this.maxFrames = maxFrames;
7
+ }
8
+ key(sessionId, target, appId) {
9
+ return `${sessionId}:${target}:${appId ?? "*"}`;
10
+ }
11
+ async push(params) {
12
+ const key = this.key(params.sessionId, params.target, params.appId);
13
+ const next = [...(this.frames.get(key) ?? []), params.envelope];
14
+ const bounded = next.slice(-this.maxFrames);
15
+ this.frames.set(key, bounded);
16
+ }
17
+ async drain(params) {
18
+ const globalKey = this.key(params.sessionId, params.target);
19
+ if (params.target !== "app" || !params.appId) {
20
+ const frames = this.frames.get(globalKey) ?? [];
21
+ this.frames.delete(globalKey);
22
+ return frames;
23
+ }
24
+ const targetedKey = this.key(params.sessionId, params.target, params.appId);
25
+ const globalFrames = this.frames.get(globalKey) ?? [];
26
+ const targetedFrames = this.frames.get(targetedKey) ?? [];
27
+ this.frames.delete(globalKey);
28
+ this.frames.delete(targetedKey);
29
+ return [...globalFrames, ...targetedFrames];
30
+ }
31
+ async clear(sessionId) {
32
+ const prefix = `${sessionId}:`;
33
+ for (const key of this.frames.keys()) {
34
+ if (key.startsWith(prefix)) {
35
+ this.frames.delete(key);
36
+ }
37
+ }
38
+ }
39
+ async clearApp(sessionId, appId) {
40
+ this.frames.delete(this.key(sessionId, "app", appId));
41
+ }
42
+ async close() {
43
+ this.frames.clear();
44
+ }
45
+ }
46
+ export class RedisEncryptedFrameCache {
47
+ maxFrames;
48
+ redis;
49
+ constructor(redisUrl, maxFrames, redisClient) {
50
+ this.maxFrames = maxFrames;
51
+ this.redis =
52
+ redisClient ??
53
+ new Redis(redisUrl, { lazyConnect: false, maxRetriesPerRequest: 1 });
54
+ }
55
+ key(sessionId, target, appId) {
56
+ return `privateclaw:frames:${sessionId}:${target}:${appId ?? "*"}`;
57
+ }
58
+ normalizeRawFrameValue(value) {
59
+ if (typeof value === "string") {
60
+ return value;
61
+ }
62
+ if (value instanceof Uint8Array) {
63
+ return Buffer.from(value).toString("utf8");
64
+ }
65
+ return String(value);
66
+ }
67
+ async drainSerializedFrames(key) {
68
+ const values = await this.redis.eval(`
69
+ local values = redis.call("lrange", KEYS[1], 0, -1)
70
+ redis.call("del", KEYS[1])
71
+ return values
72
+ `, 1, key);
73
+ if (!Array.isArray(values)) {
74
+ return [];
75
+ }
76
+ return values.map((value) => this.normalizeRawFrameValue(value)).reverse();
77
+ }
78
+ async push(params) {
79
+ const key = this.key(params.sessionId, params.target, params.appId);
80
+ await this.redis
81
+ .multi()
82
+ .lpush(key, JSON.stringify(params.envelope))
83
+ .ltrim(key, 0, this.maxFrames - 1)
84
+ .expire(key, 3600)
85
+ .exec();
86
+ }
87
+ async drain(params) {
88
+ const globalKey = this.key(params.sessionId, params.target);
89
+ if (params.target !== "app" || !params.appId) {
90
+ return (await this.drainSerializedFrames(globalKey))
91
+ .map((value) => JSON.parse(value));
92
+ }
93
+ const targetedKey = this.key(params.sessionId, params.target, params.appId);
94
+ const [globalValues, targetedValues] = await Promise.all([
95
+ this.drainSerializedFrames(globalKey),
96
+ this.drainSerializedFrames(targetedKey),
97
+ ]);
98
+ return [...globalValues, ...targetedValues].map((value) => JSON.parse(value));
99
+ }
100
+ async clear(sessionId) {
101
+ const match = `privateclaw:frames:${sessionId}:*`;
102
+ let cursor = "0";
103
+ do {
104
+ const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", match, "COUNT", 100);
105
+ if (keys.length > 0) {
106
+ await this.redis.del(...keys);
107
+ }
108
+ cursor = nextCursor;
109
+ } while (cursor !== "0");
110
+ }
111
+ async clearApp(sessionId, appId) {
112
+ await this.redis.del(this.key(sessionId, "app", appId));
113
+ }
114
+ async close() {
115
+ await this.redis.quit();
116
+ }
117
+ }
118
+ export function createEncryptedFrameCache(params) {
119
+ if (params.redisUrl) {
120
+ return new RedisEncryptedFrameCache(params.redisUrl, params.maxFrames);
121
+ }
122
+ return new InMemoryEncryptedFrameCache(params.maxFrames);
123
+ }
124
+ export function createInMemoryEncryptedFrameCache(maxFrames) {
125
+ return new InMemoryEncryptedFrameCache(maxFrames);
126
+ }
127
+ //# sourceMappingURL=frame-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame-cache.js","sourceRoot":"","sources":["../src/frame-cache.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export * from "./config.js";
2
+ export * from "./relay-server.js";
3
+ export * from "./tunnel.js";
4
+ export * from "./tunnel-installer.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./config.js";
2
+ export * from "./relay-server.js";
3
+ export * from "./tunnel.js";
4
+ export * from "./tunnel-installer.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,uBAAuB,CAAC"}
@@ -0,0 +1,36 @@
1
+ import type { RelayPushRegistrationRecord } from "./push-registration-store.js";
2
+ interface FcmServiceAccountCredentials {
3
+ projectId: string;
4
+ clientEmail: string;
5
+ privateKey: string;
6
+ }
7
+ export interface RelayPushSendResult {
8
+ unregisterToken: boolean;
9
+ }
10
+ export interface RelayPushNotifier {
11
+ readonly enabled: boolean;
12
+ sendWake(registration: RelayPushRegistrationRecord): Promise<RelayPushSendResult>;
13
+ close(): Promise<void>;
14
+ }
15
+ export declare class NoopRelayPushNotifier implements RelayPushNotifier {
16
+ readonly enabled = false;
17
+ sendWake(): Promise<RelayPushSendResult>;
18
+ close(): Promise<void>;
19
+ }
20
+ export declare class FcmRelayPushNotifier implements RelayPushNotifier {
21
+ private readonly credentials;
22
+ readonly enabled = true;
23
+ private accessToken;
24
+ private accessTokenExpiresAt;
25
+ constructor(credentials: FcmServiceAccountCredentials);
26
+ private getAccessToken;
27
+ sendWake(registration: RelayPushRegistrationRecord): Promise<RelayPushSendResult>;
28
+ close(): Promise<void>;
29
+ }
30
+ export declare function createRelayPushNotifier(params: {
31
+ fcmServiceAccountJson?: string | undefined;
32
+ fcmProjectId?: string | undefined;
33
+ fcmClientEmail?: string | undefined;
34
+ fcmPrivateKey?: string | undefined;
35
+ }): RelayPushNotifier;
36
+ export {};
@@ -0,0 +1,198 @@
1
+ import { createSign } from "node:crypto";
2
+ const GOOGLE_FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging";
3
+ const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
4
+ export class NoopRelayPushNotifier {
5
+ enabled = false;
6
+ async sendWake() {
7
+ return { unregisterToken: false };
8
+ }
9
+ async close() { }
10
+ }
11
+ function encodeBase64Url(value) {
12
+ return Buffer.from(value, "utf8")
13
+ .toString("base64")
14
+ .replaceAll("=", "")
15
+ .replaceAll("+", "-")
16
+ .replaceAll("/", "_");
17
+ }
18
+ function createJwtAssertion(credentials) {
19
+ const nowSeconds = Math.floor(Date.now() / 1000);
20
+ const header = encodeBase64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
21
+ const payload = encodeBase64Url(JSON.stringify({
22
+ iss: credentials.clientEmail,
23
+ scope: GOOGLE_FCM_SCOPE,
24
+ aud: GOOGLE_OAUTH_TOKEN_URL,
25
+ iat: nowSeconds,
26
+ exp: nowSeconds + 3600,
27
+ }));
28
+ const unsigned = `${header}.${payload}`;
29
+ const signer = createSign("RSA-SHA256");
30
+ signer.update(unsigned);
31
+ signer.end();
32
+ const signature = signer
33
+ .sign(credentials.privateKey, "base64")
34
+ .replaceAll("=", "")
35
+ .replaceAll("+", "-")
36
+ .replaceAll("/", "_");
37
+ return `${unsigned}.${signature}`;
38
+ }
39
+ function parseJsonObject(value) {
40
+ try {
41
+ const parsed = JSON.parse(value);
42
+ if (typeof parsed === "object" && parsed !== null) {
43
+ return parsed;
44
+ }
45
+ }
46
+ catch { }
47
+ return undefined;
48
+ }
49
+ function parseServiceAccountFromJson(rawValue) {
50
+ if (!rawValue) {
51
+ return undefined;
52
+ }
53
+ const parsed = parseJsonObject(rawValue);
54
+ const projectId = parsed?.project_id;
55
+ const clientEmail = parsed?.client_email;
56
+ const privateKey = parsed?.private_key;
57
+ if (typeof projectId !== "string" ||
58
+ projectId.trim() === "" ||
59
+ typeof clientEmail !== "string" ||
60
+ clientEmail.trim() === "" ||
61
+ typeof privateKey !== "string" ||
62
+ privateKey.trim() === "") {
63
+ throw new Error("PRIVATECLAW_FCM_SERVICE_ACCOUNT_JSON must include project_id, client_email, and private_key.");
64
+ }
65
+ return {
66
+ projectId: projectId.trim(),
67
+ clientEmail: clientEmail.trim(),
68
+ privateKey: privateKey.replaceAll("\\n", "\n"),
69
+ };
70
+ }
71
+ function readFcmCredentials(params) {
72
+ const fromJson = parseServiceAccountFromJson(params.fcmServiceAccountJson);
73
+ if (fromJson) {
74
+ return fromJson;
75
+ }
76
+ const projectId = params.fcmProjectId?.trim();
77
+ const clientEmail = params.fcmClientEmail?.trim();
78
+ const privateKey = params.fcmPrivateKey?.trim();
79
+ if (!projectId && !clientEmail && !privateKey) {
80
+ return undefined;
81
+ }
82
+ if (!projectId || !clientEmail || !privateKey) {
83
+ throw new Error("PRIVATECLAW_FCM_PROJECT_ID, PRIVATECLAW_FCM_CLIENT_EMAIL, and PRIVATECLAW_FCM_PRIVATE_KEY must be provided together.");
84
+ }
85
+ return {
86
+ projectId,
87
+ clientEmail,
88
+ privateKey: privateKey.replaceAll("\\n", "\n"),
89
+ };
90
+ }
91
+ function toErrorMessage(status, payload) {
92
+ const remoteMessage = payload?.error?.message;
93
+ if (typeof remoteMessage === "string" && remoteMessage.trim() !== "") {
94
+ return `FCM request failed (${status}): ${remoteMessage}`;
95
+ }
96
+ return `FCM request failed with HTTP ${status}.`;
97
+ }
98
+ function shouldUnregisterToken(payload) {
99
+ const details = payload?.error?.details;
100
+ if (!Array.isArray(details)) {
101
+ return false;
102
+ }
103
+ return details.some((detail) => detail?.errorCode === "UNREGISTERED");
104
+ }
105
+ export class FcmRelayPushNotifier {
106
+ credentials;
107
+ enabled = true;
108
+ accessToken;
109
+ accessTokenExpiresAt = 0;
110
+ constructor(credentials) {
111
+ this.credentials = credentials;
112
+ }
113
+ async getAccessToken() {
114
+ if (this.accessToken &&
115
+ this.accessTokenExpiresAt > Date.now() + 60_000) {
116
+ return this.accessToken;
117
+ }
118
+ const assertion = createJwtAssertion(this.credentials);
119
+ const response = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
120
+ method: "POST",
121
+ headers: { "content-type": "application/x-www-form-urlencoded" },
122
+ body: new URLSearchParams({
123
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
124
+ assertion,
125
+ }),
126
+ });
127
+ if (!response.ok) {
128
+ throw new Error(`Failed to obtain Google OAuth token (${response.status}).`);
129
+ }
130
+ const payload = (await response.json());
131
+ if (typeof payload.access_token !== "string" ||
132
+ typeof payload.expires_in !== "number") {
133
+ throw new Error("Google OAuth token response is missing access token data.");
134
+ }
135
+ this.accessToken = payload.access_token;
136
+ this.accessTokenExpiresAt =
137
+ Date.now() + Math.max(payload.expires_in * 1000, 60_000);
138
+ return this.accessToken;
139
+ }
140
+ async sendWake(registration) {
141
+ const accessToken = await this.getAccessToken();
142
+ const response = await fetch(`https://fcm.googleapis.com/v1/projects/${this.credentials.projectId}/messages:send`, {
143
+ method: "POST",
144
+ headers: {
145
+ authorization: `Bearer ${accessToken}`,
146
+ "content-type": "application/json",
147
+ },
148
+ body: JSON.stringify({
149
+ message: {
150
+ token: registration.token,
151
+ data: {
152
+ type: "privateclaw.wake",
153
+ sessionId: registration.sessionId,
154
+ appId: registration.appId,
155
+ },
156
+ android: {
157
+ priority: "high",
158
+ ttl: "30s",
159
+ collapseKey: registration.sessionId,
160
+ },
161
+ apns: {
162
+ headers: {
163
+ "apns-push-type": "background",
164
+ "apns-priority": "5",
165
+ "apns-collapse-id": registration.sessionId,
166
+ },
167
+ payload: {
168
+ aps: {
169
+ "content-available": 1,
170
+ },
171
+ },
172
+ },
173
+ },
174
+ }),
175
+ });
176
+ if (response.ok) {
177
+ return { unregisterToken: false };
178
+ }
179
+ let payload;
180
+ try {
181
+ payload = (await response.json());
182
+ }
183
+ catch { }
184
+ if (shouldUnregisterToken(payload)) {
185
+ return { unregisterToken: true };
186
+ }
187
+ throw new Error(toErrorMessage(response.status, payload));
188
+ }
189
+ async close() { }
190
+ }
191
+ export function createRelayPushNotifier(params) {
192
+ const credentials = readFcmCredentials(params);
193
+ if (!credentials) {
194
+ return new NoopRelayPushNotifier();
195
+ }
196
+ return new FcmRelayPushNotifier(credentials);
197
+ }
198
+ //# sourceMappingURL=push-notifier.js.map