@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.
- package/.env.example +13 -0
- package/README.md +86 -0
- package/dist/cli-error.d.ts +3 -0
- package/dist/cli-error.js +7 -0
- package/dist/cli-error.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +14 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -0
- package/dist/frame-cache.d.ts +77 -0
- package/dist/frame-cache.js +127 -0
- package/dist/frame-cache.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/push-notifier.d.ts +36 -0
- package/dist/push-notifier.js +198 -0
- package/dist/push-notifier.js.map +1 -0
- package/dist/push-registration-store.d.ts +39 -0
- package/dist/push-registration-store.js +98 -0
- package/dist/push-registration-store.js.map +1 -0
- package/dist/relay-cli.d.ts +28 -0
- package/dist/relay-cli.js +256 -0
- package/dist/relay-cli.js.map +1 -0
- package/dist/relay-cluster.d.ts +144 -0
- package/dist/relay-cluster.js +436 -0
- package/dist/relay-cluster.js.map +1 -0
- package/dist/relay-server.d.ts +25 -0
- package/dist/relay-server.js +1090 -0
- package/dist/relay-server.js.map +1 -0
- package/dist/session-store.d.ts +40 -0
- package/dist/session-store.js +159 -0
- package/dist/session-store.js.map +1 -0
- package/dist/tunnel-installer.d.ts +36 -0
- package/dist/tunnel-installer.js +402 -0
- package/dist/tunnel-installer.js.map +1 -0
- package/dist/tunnel.d.ts +35 -0
- package/dist/tunnel.js +334 -0
- package/dist/tunnel.js.map +1 -0
- 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 @@
|
|
|
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
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
|
package/dist/cli.js.map
ADDED
|
@@ -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"}
|
package/dist/config.d.ts
ADDED
|
@@ -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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"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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
|