@openchambery/relay-server 1.19.3-beta.8 → 1.19.3
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/DOCUMENTATION.md +32 -0
- package/README.md +37 -0
- package/package.json +3 -3
- package/src/cli.js +20 -1
- package/src/push/combined.js +34 -0
- package/src/push/handler.js +292 -0
- package/src/push/server.js +11 -251
package/DOCUMENTATION.md
CHANGED
|
@@ -41,6 +41,38 @@ openchamber-push-relay --host 127.0.0.1 --port 8788
|
|
|
41
41
|
|
|
42
42
|
The Host maps the effective Relay `wss://`/`ws://` URL to the same host as `https://`/`http://` `/v1/push/send`. Set `OPENCHAMBER_PUSH_RELAY_URL` on the Host to override. After a Relay switch, the Host re-registers persisted tokens and binds them before the first send. iOS Live Activity uses the same Push origin: `POST /v1/push/register-live-activity-token`, `POST /v1/push/unregister-live-activity-token`, and `POST /v1/push/live-activity`. Each Live Activity APNs request authenticates with the Host signing key, uses topic `{bundleId}.push-type.liveactivity`, and carries only `aps.timestamp`, `aps.event`, `aps.content-state`, and optional `dismissal-date` / `stale-date`. Successful `end` deliveries delete the token binding.
|
|
43
43
|
|
|
44
|
+
### Combined mode (single port)
|
|
45
|
+
|
|
46
|
+
When `openchamber-relay` sees any non-empty `OPENCHAMBER_PUSH_RELAY_APNS_*` variable, it mounts Push HTTP on the same listener at `/v1/push/*`. Missing required APNs fields fail startup instead of silently skipping Push. With no such variables, Layer 1 does not load the Push module.
|
|
47
|
+
|
|
48
|
+
Combined mode ignores `OPENCHAMBER_PUSH_RELAY_HOST` and `OPENCHAMBER_PUSH_RELAY_PORT`. Public `/healthz` and `/readyz` stay Layer 1. The standalone `openchamber-push-relay` entry is unchanged.
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
export OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
|
|
52
|
+
export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
|
|
53
|
+
export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
|
|
54
|
+
export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
|
|
55
|
+
export OPENCHAMBER_PUSH_RELAY_DATABASE_PATH=/var/lib/openchamber/push-relay.sqlite
|
|
56
|
+
openchamber-relay --public-url wss://relay.example.com/ws
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Minimal Compose environment:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
services:
|
|
63
|
+
relay:
|
|
64
|
+
image: openchamber-relay:<version>
|
|
65
|
+
environment:
|
|
66
|
+
OPENCHAMBER_RELAY_SERVER_PUBLIC_URL: wss://relay.example.com/ws
|
|
67
|
+
OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID}
|
|
68
|
+
OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID}
|
|
69
|
+
OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID: com.yee94.openchamber
|
|
70
|
+
OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_p8
|
|
71
|
+
OPENCHAMBER_PUSH_RELAY_DATABASE_PATH: /data/push-relay.sqlite
|
|
72
|
+
ports:
|
|
73
|
+
- "127.0.0.1:8787:8787"
|
|
74
|
+
```
|
|
75
|
+
|
|
44
76
|
### Caddy
|
|
45
77
|
|
|
46
78
|
```caddyfile
|
package/README.md
CHANGED
|
@@ -59,6 +59,43 @@ openchamber-push-relay --host 127.0.0.1 --port 8788
|
|
|
59
59
|
|
|
60
60
|
Keep Push on loopback behind the same TLS reverse proxy. Route only `/v1/push/*` to port 8788.
|
|
61
61
|
|
|
62
|
+
### Combined mode (single port)
|
|
63
|
+
|
|
64
|
+
`openchamber-relay` can mount Push on the same listener. If any `OPENCHAMBER_PUSH_RELAY_APNS_*` variable is non-empty, Layer 1 loads Push and serves `/v1/push/*` on the Relay port. Partial APNs configuration fails startup; it does not silently disable Push. With no APNs variables set, Layer 1 behavior is unchanged and Push is not loaded.
|
|
65
|
+
|
|
66
|
+
In combined mode:
|
|
67
|
+
|
|
68
|
+
- Push routes share the Relay port at `/v1/push/*`.
|
|
69
|
+
- `/healthz` and `/readyz` remain Layer 1 endpoints.
|
|
70
|
+
- `OPENCHAMBER_PUSH_RELAY_HOST` and `OPENCHAMBER_PUSH_RELAY_PORT` are ignored.
|
|
71
|
+
- `openchamber-push-relay` is unchanged and remains the isolated two-process option.
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
export OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID='<apns-key-id>'
|
|
75
|
+
export OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID='<apns-team-id>'
|
|
76
|
+
export OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID=com.yee94.openchamber
|
|
77
|
+
export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
|
|
78
|
+
export OPENCHAMBER_PUSH_RELAY_DATABASE_PATH=/var/lib/openchamber/push-relay.sqlite
|
|
79
|
+
openchamber-relay --public-url wss://relay.example.com/ws
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Minimal Compose environment for combined mode:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
services:
|
|
86
|
+
relay:
|
|
87
|
+
image: openchamber-relay:<version>
|
|
88
|
+
environment:
|
|
89
|
+
OPENCHAMBER_RELAY_SERVER_PUBLIC_URL: wss://relay.example.com/ws
|
|
90
|
+
OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_KEY_ID}
|
|
91
|
+
OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID: ${OPENCHAMBER_PUSH_RELAY_APNS_TEAM_ID}
|
|
92
|
+
OPENCHAMBER_PUSH_RELAY_APNS_BUNDLE_ID: com.yee94.openchamber
|
|
93
|
+
OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH: /run/secrets/apns_p8
|
|
94
|
+
OPENCHAMBER_PUSH_RELAY_DATABASE_PATH: /data/push-relay.sqlite
|
|
95
|
+
ports:
|
|
96
|
+
- "127.0.0.1:8787:8787"
|
|
97
|
+
```
|
|
98
|
+
|
|
62
99
|
OpenChamber Hosts do not need a separate Push URL when they already have a Relay URL. The effective `wss://` or `ws://` Relay URL maps to the same host as `https://` or `http://` `/v1/push/send` (register is `/v1/push/register-token`). Set `OPENCHAMBER_PUSH_RELAY_URL` on the Host to override that mapping. After a Relay switch, the Host re-registers persisted device tokens and binds them again before the first send.
|
|
63
100
|
|
|
64
101
|
### Build a standalone executable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openchambery/relay-server",
|
|
3
|
-
"version": "1.19.3
|
|
3
|
+
"version": "1.19.3",
|
|
4
4
|
"description": "Self-hosted private relay server for OpenChamber",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"scripts": {
|
|
47
47
|
"test": "vitest run",
|
|
48
48
|
"test:node": "node test/node-smoke.js",
|
|
49
|
-
"type-check": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js && node --check src/push/index.js && node --check src/push/server.js && node --check src/push/cli.js && node --check src/push/config.js && node --check src/push/schema.js && node --check src/push/crypto.js && node --check src/push/store.js && node --check src/push/guard.js && node --check src/push/apns.js && node --check bin/openchamber-push-relay.js",
|
|
50
|
-
"lint": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js && node --check src/push/index.js && node --check src/push/server.js && node --check src/push/cli.js && node --check src/push/config.js && node --check src/push/schema.js && node --check src/push/crypto.js && node --check src/push/store.js && node --check src/push/guard.js && node --check src/push/apns.js && node --check bin/openchamber-push-relay.js",
|
|
49
|
+
"type-check": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js && node --check src/push/index.js && node --check src/push/server.js && node --check src/push/cli.js && node --check src/push/config.js && node --check src/push/schema.js && node --check src/push/crypto.js && node --check src/push/store.js && node --check src/push/guard.js && node --check src/push/apns.js && node --check bin/openchamber-push-relay.js && node --check src/push/handler.js && node --check src/push/combined.js",
|
|
50
|
+
"lint": "node --check src/index.js && node --check src/cli.js && node --check bin/openchamber-relay.js && node --check bin/cli-entry.js && node --check src/push/index.js && node --check src/push/server.js && node --check src/push/cli.js && node --check src/push/config.js && node --check src/push/schema.js && node --check src/push/crypto.js && node --check src/push/store.js && node --check src/push/guard.js && node --check src/push/apns.js && node --check bin/openchamber-push-relay.js && node --check src/push/handler.js && node --check src/push/combined.js",
|
|
51
51
|
"build:standalone": "bun build --compile --outfile dist/openchamber-relay bin/openchamber-relay.js"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
package/src/cli.js
CHANGED
|
@@ -71,6 +71,16 @@ export const buildRelayConfig = (parsed = {}, env = process.env) => {
|
|
|
71
71
|
|
|
72
72
|
const helpText = 'Usage: openchamber-relay [--host HOST] [--port PORT] [--path PATH] [--public-url WS_URL] [--trust-proxy] [--json] [--quiet]\nEnable --trust-proxy only when public ingress reaches this relay through a trusted reverse proxy.\n';
|
|
73
73
|
const writeJson = (stdout, payload) => stdout.write(`${JSON.stringify(payload)}\n`);
|
|
74
|
+
const hasPushApnsEnv = (env) => {
|
|
75
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
76
|
+
if (key.startsWith('OPENCHAMBER_PUSH_RELAY_APNS_') && typeof value === 'string' && value.trim().length > 0) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
const loadCombinedPushMount = async () => {
|
|
81
|
+
// Computed specifier keeps bun --compile from bundling Push/SQLite into the Layer 1 binary.
|
|
82
|
+
return (await import(['.', 'push', 'combined.js'].join('/'))).createCombinedPushMount;
|
|
83
|
+
};
|
|
74
84
|
|
|
75
85
|
export const runRelayServerCli = async (argv, dependencies = {}) => {
|
|
76
86
|
const processLike = dependencies.process ?? process; const stdout = dependencies.stdout ?? process.stdout; const stderr = dependencies.stderr ?? process.stderr; const version = dependencies.version ?? '0.0.0';
|
|
@@ -87,15 +97,24 @@ export const runRelayServerCli = async (argv, dependencies = {}) => {
|
|
|
87
97
|
let config;
|
|
88
98
|
try { config = buildRelayConfig(parsed, processLike.env ?? {}); } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
|
|
89
99
|
try {
|
|
100
|
+
const env = processLike.env ?? {};
|
|
101
|
+
const createPushMount = dependencies.createPushMount ?? (
|
|
102
|
+
hasPushApnsEnv(env) ? await loadCombinedPushMount() : () => null
|
|
103
|
+
);
|
|
104
|
+
const mount = createPushMount(env, dependencies.pushMountDeps ?? {});
|
|
105
|
+
if (mount) config.requestHandler = mount.requestHandler;
|
|
90
106
|
const relay = await (dependencies.start ?? startPrivateRelayServer)(config);
|
|
107
|
+
if (mount) await mount.start();
|
|
91
108
|
const url = config.publicUrl ?? relay.wsUrl;
|
|
92
|
-
respond(json ? { status: 'ok', url, host: config.host, port: relay.address?.()?.port ?? config.port, path: config.path } : { message: `Relay listening at ${url}` });
|
|
109
|
+
respond(json ? { status: 'ok', url, host: config.host, port: relay.address?.()?.port ?? config.port, path: config.path, ...(mount ? { push: true } : {}) } : { message: `Relay listening at ${url}` });
|
|
110
|
+
if (mount && !json) respond({ message: 'Push relay mounted at /v1/push/*' });
|
|
93
111
|
let stopping = false;
|
|
94
112
|
const stop = async () => {
|
|
95
113
|
if (stopping) return Promise.resolve();
|
|
96
114
|
stopping = true;
|
|
97
115
|
processLike.off?.('SIGINT', stop); processLike.off?.('SIGTERM', stop);
|
|
98
116
|
try {
|
|
117
|
+
if (mount) await mount.stop();
|
|
99
118
|
await relay.stop();
|
|
100
119
|
processLike.exit?.(0);
|
|
101
120
|
} catch {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { buildPushRelayConfig, ENV_PREFIX } from './config.js';
|
|
2
|
+
import { createPushRelayHandler } from './handler.js';
|
|
3
|
+
|
|
4
|
+
const APNS_ENV_PREFIX = `${ENV_PREFIX}APNS_`;
|
|
5
|
+
|
|
6
|
+
const hasPushApnsEnv = (env) => {
|
|
7
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
8
|
+
if (!key.startsWith(APNS_ENV_PREFIX)) continue;
|
|
9
|
+
if (typeof value === 'string' && value.trim().length > 0) return true;
|
|
10
|
+
}
|
|
11
|
+
return false;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const createCombinedPushMount = (env = process.env, deps = {}) => {
|
|
15
|
+
if (!hasPushApnsEnv(env)) return null;
|
|
16
|
+
const config = buildPushRelayConfig({}, env);
|
|
17
|
+
// Combined mode ignores OPENCHAMBER_PUSH_RELAY_HOST / OPENCHAMBER_PUSH_RELAY_PORT;
|
|
18
|
+
// Push HTTP is mounted on the Layer 1 listener at /v1/push/* instead of opening its own port.
|
|
19
|
+
const handler = createPushRelayHandler({
|
|
20
|
+
databasePath: config.databasePath,
|
|
21
|
+
trustProxy: config.trustProxy,
|
|
22
|
+
limits: config.limits,
|
|
23
|
+
apns: config.apns,
|
|
24
|
+
apnsProvider: deps.apnsProvider,
|
|
25
|
+
clock: deps.clock,
|
|
26
|
+
claimHealthEndpoints: false,
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
requestHandler: handler.handleRequest,
|
|
30
|
+
start: () => handler.activate(),
|
|
31
|
+
stop: () => handler.deactivate(),
|
|
32
|
+
getSnapshot: handler.getSnapshot,
|
|
33
|
+
};
|
|
34
|
+
};
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { createApnsProvider } from './apns.js';
|
|
2
|
+
import { normalizePushRelayOptions, resolvePushRelayClientIp } from './config.js';
|
|
3
|
+
import { deriveServerId, verifyP1363 } from './crypto.js';
|
|
4
|
+
import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter, createWorkTracker } from './guard.js';
|
|
5
|
+
import { JSON_BODY_BYTES, buildLiveActivityPayload, validateLiveActivityBody, validateLiveActivityRegisterBody, validateRegisterBody, validateSendBody } from './schema.js';
|
|
6
|
+
import { createTokenStore } from './store.js';
|
|
7
|
+
|
|
8
|
+
const WINDOW_MS = 60_000;
|
|
9
|
+
const STOP_DEADLINE_MS = 5_500;
|
|
10
|
+
|
|
11
|
+
const sendJson = (response, status, payload, method = 'GET') => {
|
|
12
|
+
if (response.writableEnded) return;
|
|
13
|
+
response.setHeader('cache-control', 'no-store');
|
|
14
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
15
|
+
response.end(method === 'HEAD' ? undefined : JSON.stringify(payload));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
|
|
19
|
+
let done = false;
|
|
20
|
+
const fail = (error) => { if (done) return; done = true; reject(error); };
|
|
21
|
+
const succeed = (value) => { if (done) return; done = true; resolve(value); };
|
|
22
|
+
const tooLarge = () => {
|
|
23
|
+
const error = new Error('payload too large');
|
|
24
|
+
error.code = 'PAYLOAD_TOO_LARGE';
|
|
25
|
+
fail(error);
|
|
26
|
+
};
|
|
27
|
+
const drain = () => {
|
|
28
|
+
request.removeListener('data', onData);
|
|
29
|
+
request.resume();
|
|
30
|
+
};
|
|
31
|
+
const declared = Number(request.headers['content-length']);
|
|
32
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
33
|
+
tooLarge();
|
|
34
|
+
request.resume();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let size = 0;
|
|
39
|
+
const onData = (chunk) => {
|
|
40
|
+
size += chunk.length;
|
|
41
|
+
if (size > maxBytes) {
|
|
42
|
+
drain();
|
|
43
|
+
tooLarge();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
};
|
|
48
|
+
request.on('data', onData);
|
|
49
|
+
request.on('end', () => succeed(Buffer.concat(chunks)));
|
|
50
|
+
request.on('error', fail);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const createPushRelayHandler = (options = {}) => {
|
|
54
|
+
const claimHealthEndpoints = options.claimHealthEndpoints !== false;
|
|
55
|
+
const config = normalizePushRelayOptions(options);
|
|
56
|
+
const limits = config.limits;
|
|
57
|
+
const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...config.clock };
|
|
58
|
+
const resolveClientIp = config.resolveClientIp ?? ((request) => resolvePushRelayClientIp(request, config.trustProxy));
|
|
59
|
+
const ownedStore = !config.store;
|
|
60
|
+
const ownedApns = !config.apnsProvider;
|
|
61
|
+
const openStore = () => config.store ?? createTokenStore(config.databasePath);
|
|
62
|
+
const openApns = () => config.apnsProvider ?? createApnsProvider({ ...config.apns, clock, http2: config.http2 });
|
|
63
|
+
let store = openStore();
|
|
64
|
+
let apns;
|
|
65
|
+
try {
|
|
66
|
+
apns = openApns();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (ownedStore) try { store.close(); } catch { /* ignore */ }
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
const liveStore = () => {
|
|
72
|
+
if (!ownedStore) return store;
|
|
73
|
+
if (state === 'stopping') return store;
|
|
74
|
+
try { store.count(); return store; } catch {
|
|
75
|
+
store = createTokenStore(config.databasePath);
|
|
76
|
+
return store;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const replay = createReplayGuard({ replayMs: limits.replayMs, maxReplayEntries: limits.maxReplayEntries, now: () => clock.now() });
|
|
80
|
+
const registerIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.registerLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
81
|
+
const sendIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.sendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
82
|
+
const sendServerLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.serverSendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
83
|
+
const inFlight = createInFlightGate(limits.maxInFlight);
|
|
84
|
+
const httpWork = createWorkTracker();
|
|
85
|
+
const reasons = { authRejected: 0, policyRejected: 0, limited: 0, replayRejected: 0 };
|
|
86
|
+
let stopPromise = null; let state = 'idle';
|
|
87
|
+
const snapshot = () => {
|
|
88
|
+
let tokenCount = 0;
|
|
89
|
+
try { tokenCount = store.count(); } catch { /* closed after stop */ }
|
|
90
|
+
return { state, tokenCount, inFlight: inFlight.active, replayEntries: replay.size, reasons: { ...reasons } };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const authenticate = (jwk, message, signature, ts) => {
|
|
94
|
+
if (Math.abs(clock.now() - ts) > limits.timestampSkewMs) { reasons.authRejected += 1; return 'timestamp'; }
|
|
95
|
+
if (!verifyP1363(message, jwk, signature)) { reasons.authRejected += 1; return 'invalid_signature'; }
|
|
96
|
+
return null;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleRegister = (parsed) => {
|
|
100
|
+
const message = parsed.kind
|
|
101
|
+
? `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`
|
|
102
|
+
: `${parsed.ts}.${parsed.token}.${parsed.platform}`;
|
|
103
|
+
const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
|
|
104
|
+
if (authError) return { status: 401, body: { error: authError } };
|
|
105
|
+
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
106
|
+
const replayKey = `${parsed.kind ? 'register-live-activity' : 'register'}.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
107
|
+
if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
|
|
108
|
+
const tokens = liveStore();
|
|
109
|
+
const existing = tokens.get(parsed.token);
|
|
110
|
+
if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
|
|
111
|
+
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
112
|
+
tokens.upsert(parsed.token, serverId, parsed.platform, clock.now());
|
|
113
|
+
return { status: 200, body: { ok: true } };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleUnregisterLiveActivity = (parsed) => {
|
|
117
|
+
const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`, parsed.sig, parsed.ts);
|
|
118
|
+
if (authError) return { status: 401, body: { error: authError } };
|
|
119
|
+
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
120
|
+
const replayKey = `unregister-live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
121
|
+
if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
|
|
122
|
+
const tokens = liveStore();
|
|
123
|
+
const existing = tokens.get(parsed.token);
|
|
124
|
+
if (existing && existing.serverId !== serverId) { reasons.authRejected += 1; return { status: 401, body: { error: 'invalid_signature' } }; }
|
|
125
|
+
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
126
|
+
if (existing) tokens.delete(parsed.token);
|
|
127
|
+
return { status: 200, body: { ok: true } };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleSend = async (parsed) => {
|
|
131
|
+
const sorted = [...parsed.tokens].sort();
|
|
132
|
+
const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
|
|
133
|
+
if (authError) return { status: 401, body: { error: authError } };
|
|
134
|
+
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
135
|
+
const replayKey = `send.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
136
|
+
if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
|
|
137
|
+
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
138
|
+
if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
139
|
+
const tokens = liveStore();
|
|
140
|
+
const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
|
|
141
|
+
const binding = tokens.get(token);
|
|
142
|
+
if (!binding || binding.serverId !== serverId) return { token, ok: false };
|
|
143
|
+
const acquired = await inFlight.acquire();
|
|
144
|
+
if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
|
|
145
|
+
try {
|
|
146
|
+
const outcome = await apns.send({ token, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
|
|
147
|
+
if (outcome?.drop === true) {
|
|
148
|
+
tokens.delete(token);
|
|
149
|
+
return { token, ok: false, drop: true };
|
|
150
|
+
}
|
|
151
|
+
return { token, ok: outcome?.ok === true };
|
|
152
|
+
} catch {
|
|
153
|
+
return { token, ok: false };
|
|
154
|
+
} finally {
|
|
155
|
+
inFlight.release(acquired);
|
|
156
|
+
}
|
|
157
|
+
}));
|
|
158
|
+
return { status: 200, body: { results } };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handleLiveActivity = async (parsed) => {
|
|
162
|
+
const sorted = [...parsed.tokens].sort();
|
|
163
|
+
const contentState = parsed.contentState;
|
|
164
|
+
const message = `${parsed.ts}.${sorted.join(',')}.${parsed.event}.${contentState.status}.${contentState.eventVersion}.${contentState.updatedAt}.${contentState.endedAt ?? ''}.${parsed.dismissalDate ?? ''}.${parsed.staleDate ?? ''}`;
|
|
165
|
+
const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
|
|
166
|
+
if (authError) return { status: 401, body: { error: authError } };
|
|
167
|
+
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
168
|
+
const replayKey = `live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
169
|
+
if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
|
|
170
|
+
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
171
|
+
if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
172
|
+
const payload = buildLiveActivityPayload({
|
|
173
|
+
event: parsed.event,
|
|
174
|
+
contentState,
|
|
175
|
+
dismissalDate: parsed.dismissalDate,
|
|
176
|
+
staleDate: parsed.staleDate,
|
|
177
|
+
timestamp: Math.floor(clock.now() / 1000),
|
|
178
|
+
});
|
|
179
|
+
const tokens = liveStore();
|
|
180
|
+
const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
|
|
181
|
+
const binding = tokens.get(token);
|
|
182
|
+
if (!binding || binding.serverId !== serverId) return { token, ok: false };
|
|
183
|
+
const acquired = await inFlight.acquire();
|
|
184
|
+
if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
|
|
185
|
+
try {
|
|
186
|
+
const outcome = await apns.send({ token, env: parsed.env, payload, pushType: 'liveactivity' });
|
|
187
|
+
if (outcome?.drop === true) {
|
|
188
|
+
tokens.delete(token);
|
|
189
|
+
return { token, ok: false, drop: true };
|
|
190
|
+
}
|
|
191
|
+
if (outcome?.ok === true && parsed.event === 'end') tokens.delete(token);
|
|
192
|
+
return { token, ok: outcome?.ok === true };
|
|
193
|
+
} catch {
|
|
194
|
+
return { token, ok: false };
|
|
195
|
+
} finally {
|
|
196
|
+
inFlight.release(acquired);
|
|
197
|
+
}
|
|
198
|
+
}));
|
|
199
|
+
return { status: 200, body: { results } };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const handleRequest = (request, response) => {
|
|
203
|
+
let pathname;
|
|
204
|
+
try { pathname = new URL(request.url ?? '/', 'http://push-relay').pathname; } catch {
|
|
205
|
+
if (!claimHealthEndpoints) return false;
|
|
206
|
+
response.writeHead(404); response.end(); return true;
|
|
207
|
+
}
|
|
208
|
+
if (!claimHealthEndpoints && !pathname.startsWith('/v1/push/')) return false;
|
|
209
|
+
const ready = pathname === '/readyz' && state === 'running';
|
|
210
|
+
const healthy = pathname === '/healthz';
|
|
211
|
+
if (claimHealthEndpoints && (healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
212
|
+
sendJson(response, 200, { status: 'ok' }, request.method);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
const isRegister = pathname === '/v1/push/register-token';
|
|
216
|
+
const isRegisterLive = pathname === '/v1/push/register-live-activity-token';
|
|
217
|
+
const isUnregisterLive = pathname === '/v1/push/unregister-live-activity-token';
|
|
218
|
+
const isSend = pathname === '/v1/push/send';
|
|
219
|
+
const isLiveActivity = pathname === '/v1/push/live-activity';
|
|
220
|
+
if (request.method !== 'POST' || (!isRegister && !isRegisterLive && !isUnregisterLive && !isSend && !isLiveActivity) || state !== 'running') { response.writeHead(404); response.end(); return true; }
|
|
221
|
+
const ip = resolveClientIp(request);
|
|
222
|
+
const limiter = (isRegister || isRegisterLive || isUnregisterLive) ? registerIpLimit : sendIpLimit;
|
|
223
|
+
if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return true; }
|
|
224
|
+
const endHttp = httpWork.begin();
|
|
225
|
+
readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
|
|
226
|
+
let body;
|
|
227
|
+
try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
|
|
228
|
+
const parsed = isRegister ? validateRegisterBody(body)
|
|
229
|
+
: (isRegisterLive || isUnregisterLive) ? validateLiveActivityRegisterBody(body)
|
|
230
|
+
: isLiveActivity ? validateLiveActivityBody(body)
|
|
231
|
+
: validateSendBody(body);
|
|
232
|
+
if (parsed.error) {
|
|
233
|
+
reasons.policyRejected += 1;
|
|
234
|
+
sendJson(response, 400, { error: parsed.error });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const result = (isRegister || isRegisterLive) ? handleRegister(parsed.value)
|
|
238
|
+
: isUnregisterLive ? handleUnregisterLiveActivity(parsed.value)
|
|
239
|
+
: isLiveActivity ? await handleLiveActivity(parsed.value)
|
|
240
|
+
: await handleSend(parsed.value);
|
|
241
|
+
sendJson(response, result.status, result.body);
|
|
242
|
+
}).catch((error) => {
|
|
243
|
+
if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
|
|
244
|
+
sendJson(response, 500, { error: 'internal' });
|
|
245
|
+
}).finally(endHttp);
|
|
246
|
+
return true;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const activate = () => {
|
|
250
|
+
if (state === 'running') return;
|
|
251
|
+
liveStore();
|
|
252
|
+
inFlight.reset();
|
|
253
|
+
httpWork.reset();
|
|
254
|
+
if (ownedApns && state === 'stopped') apns = openApns();
|
|
255
|
+
state = 'running';
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const deactivate = () => {
|
|
259
|
+
if (stopPromise) return stopPromise;
|
|
260
|
+
if (state === 'idle' || state === 'stopped') { state = 'stopped'; return Promise.resolve(); }
|
|
261
|
+
state = 'stopping';
|
|
262
|
+
stopPromise = Promise.resolve().then(async () => {
|
|
263
|
+
inFlight.rejectWaiters();
|
|
264
|
+
replay.clear();
|
|
265
|
+
registerIpLimit.clear();
|
|
266
|
+
sendIpLimit.clear();
|
|
267
|
+
sendServerLimit.clear();
|
|
268
|
+
let deadlineTimer = null;
|
|
269
|
+
try {
|
|
270
|
+
const graceful = Promise.all([inFlight.whenIdle(), httpWork.whenIdle()]);
|
|
271
|
+
const deadline = new Promise((resolve) => {
|
|
272
|
+
deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
|
|
273
|
+
});
|
|
274
|
+
await Promise.race([graceful.then(() => 'graceful'), deadline]);
|
|
275
|
+
} finally {
|
|
276
|
+
if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
|
|
277
|
+
}
|
|
278
|
+
try { apns.close?.(); } catch { /* ignore */ }
|
|
279
|
+
if (ownedStore) try { store.close(); } catch { /* ignore */ }
|
|
280
|
+
state = 'stopped';
|
|
281
|
+
stopPromise = null;
|
|
282
|
+
});
|
|
283
|
+
return stopPromise;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
handleRequest,
|
|
288
|
+
activate,
|
|
289
|
+
deactivate,
|
|
290
|
+
getSnapshot: snapshot,
|
|
291
|
+
};
|
|
292
|
+
};
|
package/src/push/server.js
CHANGED
|
@@ -1,258 +1,25 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { deriveServerId, verifyP1363 } from './crypto.js';
|
|
6
|
-
import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter, createWorkTracker } from './guard.js';
|
|
7
|
-
import { JSON_BODY_BYTES, buildLiveActivityPayload, validateLiveActivityBody, validateLiveActivityRegisterBody, validateRegisterBody, validateSendBody } from './schema.js';
|
|
8
|
-
import { createTokenStore } from './store.js';
|
|
3
|
+
import { normalizePushRelayOptions, formatPushRelayUrl } from './config.js';
|
|
4
|
+
import { createPushRelayHandler } from './handler.js';
|
|
9
5
|
|
|
10
|
-
const WINDOW_MS = 60_000;
|
|
11
6
|
const STOP_DEADLINE_MS = 5_500;
|
|
12
7
|
|
|
13
|
-
const sendJson = (response, status, payload, method = 'GET') => {
|
|
14
|
-
if (response.writableEnded) return;
|
|
15
|
-
response.setHeader('cache-control', 'no-store');
|
|
16
|
-
response.writeHead(status, { 'content-type': 'application/json' });
|
|
17
|
-
response.end(method === 'HEAD' ? undefined : JSON.stringify(payload));
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
|
|
21
|
-
let done = false;
|
|
22
|
-
const fail = (error) => { if (done) return; done = true; reject(error); };
|
|
23
|
-
const succeed = (value) => { if (done) return; done = true; resolve(value); };
|
|
24
|
-
const tooLarge = () => {
|
|
25
|
-
const error = new Error('payload too large');
|
|
26
|
-
error.code = 'PAYLOAD_TOO_LARGE';
|
|
27
|
-
fail(error);
|
|
28
|
-
};
|
|
29
|
-
const drain = () => {
|
|
30
|
-
request.removeListener('data', onData);
|
|
31
|
-
request.resume();
|
|
32
|
-
};
|
|
33
|
-
const declared = Number(request.headers['content-length']);
|
|
34
|
-
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
35
|
-
tooLarge();
|
|
36
|
-
request.resume();
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
const chunks = [];
|
|
40
|
-
let size = 0;
|
|
41
|
-
const onData = (chunk) => {
|
|
42
|
-
size += chunk.length;
|
|
43
|
-
if (size > maxBytes) {
|
|
44
|
-
drain();
|
|
45
|
-
tooLarge();
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
chunks.push(chunk);
|
|
49
|
-
};
|
|
50
|
-
request.on('data', onData);
|
|
51
|
-
request.on('end', () => succeed(Buffer.concat(chunks)));
|
|
52
|
-
request.on('error', fail);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
8
|
export const createPushRelayServer = (options = {}) => {
|
|
56
9
|
const config = normalizePushRelayOptions(options);
|
|
57
|
-
const limits = config.limits;
|
|
58
10
|
const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...config.clock };
|
|
59
|
-
const
|
|
60
|
-
const ownedStore = !config.store;
|
|
61
|
-
const ownedApns = !config.apnsProvider;
|
|
62
|
-
const openStore = () => config.store ?? createTokenStore(config.databasePath);
|
|
63
|
-
const openApns = () => config.apnsProvider ?? createApnsProvider({ ...config.apns, clock, http2: config.http2 });
|
|
64
|
-
let store = openStore();
|
|
65
|
-
let apns;
|
|
66
|
-
try {
|
|
67
|
-
apns = openApns();
|
|
68
|
-
} catch (error) {
|
|
69
|
-
if (ownedStore) try { store.close(); } catch { /* ignore */ }
|
|
70
|
-
throw error;
|
|
71
|
-
}
|
|
72
|
-
const liveStore = () => {
|
|
73
|
-
if (!ownedStore) return store;
|
|
74
|
-
if (state === 'stopping') return store;
|
|
75
|
-
try { store.count(); return store; } catch {
|
|
76
|
-
store = createTokenStore(config.databasePath);
|
|
77
|
-
return store;
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
const replay = createReplayGuard({ replayMs: limits.replayMs, maxReplayEntries: limits.maxReplayEntries, now: () => clock.now() });
|
|
81
|
-
const registerIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.registerLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
82
|
-
const sendIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.sendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
83
|
-
const sendServerLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.serverSendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
|
|
84
|
-
const inFlight = createInFlightGate(limits.maxInFlight);
|
|
85
|
-
const httpWork = createWorkTracker();
|
|
86
|
-
const reasons = { authRejected: 0, policyRejected: 0, limited: 0, replayRejected: 0 };
|
|
11
|
+
const handler = createPushRelayHandler({ ...options, claimHealthEndpoints: true });
|
|
87
12
|
let server = null; let startPromise = null; let stopPromise = null; let abortStart = null; let state = 'idle'; let generation = 0;
|
|
88
|
-
const snapshot = () => {
|
|
89
|
-
let tokenCount = 0;
|
|
90
|
-
try { tokenCount = store.count(); } catch { /* closed after stop */ }
|
|
91
|
-
return { state, tokenCount, inFlight: inFlight.active, replayEntries: replay.size, reasons: { ...reasons } };
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const authenticate = (jwk, message, signature, ts) => {
|
|
95
|
-
if (Math.abs(clock.now() - ts) > limits.timestampSkewMs) { reasons.authRejected += 1; return 'timestamp'; }
|
|
96
|
-
if (!verifyP1363(message, jwk, signature)) { reasons.authRejected += 1; return 'invalid_signature'; }
|
|
97
|
-
return null;
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const handleRegister = (parsed) => {
|
|
101
|
-
const message = parsed.kind
|
|
102
|
-
? `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`
|
|
103
|
-
: `${parsed.ts}.${parsed.token}.${parsed.platform}`;
|
|
104
|
-
const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
|
|
105
|
-
if (authError) return { status: 401, body: { error: authError } };
|
|
106
|
-
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
107
|
-
const replayKey = `${parsed.kind ? 'register-live-activity' : 'register'}.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
108
|
-
if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
|
|
109
|
-
const tokens = liveStore();
|
|
110
|
-
const existing = tokens.get(parsed.token);
|
|
111
|
-
if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
|
|
112
|
-
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
113
|
-
tokens.upsert(parsed.token, serverId, parsed.platform, clock.now());
|
|
114
|
-
return { status: 200, body: { ok: true } };
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const handleUnregisterLiveActivity = (parsed) => {
|
|
118
|
-
const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`, parsed.sig, parsed.ts);
|
|
119
|
-
if (authError) return { status: 401, body: { error: authError } };
|
|
120
|
-
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
121
|
-
const replayKey = `unregister-live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
122
|
-
if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
|
|
123
|
-
const tokens = liveStore();
|
|
124
|
-
const existing = tokens.get(parsed.token);
|
|
125
|
-
if (existing && existing.serverId !== serverId) { reasons.authRejected += 1; return { status: 401, body: { error: 'invalid_signature' } }; }
|
|
126
|
-
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
127
|
-
if (existing) tokens.delete(parsed.token);
|
|
128
|
-
return { status: 200, body: { ok: true } };
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const handleSend = async (parsed) => {
|
|
132
|
-
const sorted = [...parsed.tokens].sort();
|
|
133
|
-
const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
|
|
134
|
-
if (authError) return { status: 401, body: { error: authError } };
|
|
135
|
-
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
136
|
-
const replayKey = `send.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
137
|
-
if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
|
|
138
|
-
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
139
|
-
if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
140
|
-
const tokens = liveStore();
|
|
141
|
-
const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
|
|
142
|
-
const binding = tokens.get(token);
|
|
143
|
-
if (!binding || binding.serverId !== serverId) return { token, ok: false };
|
|
144
|
-
const acquired = await inFlight.acquire();
|
|
145
|
-
if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
|
|
146
|
-
try {
|
|
147
|
-
const outcome = await apns.send({ token, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
|
|
148
|
-
if (outcome?.drop === true) {
|
|
149
|
-
tokens.delete(token);
|
|
150
|
-
return { token, ok: false, drop: true };
|
|
151
|
-
}
|
|
152
|
-
return { token, ok: outcome?.ok === true };
|
|
153
|
-
} catch {
|
|
154
|
-
return { token, ok: false };
|
|
155
|
-
} finally {
|
|
156
|
-
inFlight.release(acquired);
|
|
157
|
-
}
|
|
158
|
-
}));
|
|
159
|
-
return { status: 200, body: { results } };
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const handleLiveActivity = async (parsed) => {
|
|
163
|
-
const sorted = [...parsed.tokens].sort();
|
|
164
|
-
const contentState = parsed.contentState;
|
|
165
|
-
const message = `${parsed.ts}.${sorted.join(',')}.${parsed.event}.${contentState.status}.${contentState.eventVersion}.${contentState.updatedAt}.${contentState.endedAt ?? ''}.${parsed.dismissalDate ?? ''}.${parsed.staleDate ?? ''}`;
|
|
166
|
-
const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
|
|
167
|
-
if (authError) return { status: 401, body: { error: authError } };
|
|
168
|
-
const serverId = deriveServerId(parsed.publicKeyJwk);
|
|
169
|
-
const replayKey = `live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
|
|
170
|
-
if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
|
|
171
|
-
if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
172
|
-
if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
|
|
173
|
-
const payload = buildLiveActivityPayload({
|
|
174
|
-
event: parsed.event,
|
|
175
|
-
contentState,
|
|
176
|
-
dismissalDate: parsed.dismissalDate,
|
|
177
|
-
staleDate: parsed.staleDate,
|
|
178
|
-
timestamp: Math.floor(clock.now() / 1000),
|
|
179
|
-
});
|
|
180
|
-
const tokens = liveStore();
|
|
181
|
-
const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
|
|
182
|
-
const binding = tokens.get(token);
|
|
183
|
-
if (!binding || binding.serverId !== serverId) return { token, ok: false };
|
|
184
|
-
const acquired = await inFlight.acquire();
|
|
185
|
-
if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
|
|
186
|
-
try {
|
|
187
|
-
const outcome = await apns.send({ token, env: parsed.env, payload, pushType: 'liveactivity' });
|
|
188
|
-
if (outcome?.drop === true) {
|
|
189
|
-
tokens.delete(token);
|
|
190
|
-
return { token, ok: false, drop: true };
|
|
191
|
-
}
|
|
192
|
-
if (outcome?.ok === true && parsed.event === 'end') tokens.delete(token);
|
|
193
|
-
return { token, ok: outcome?.ok === true };
|
|
194
|
-
} catch {
|
|
195
|
-
return { token, ok: false };
|
|
196
|
-
} finally {
|
|
197
|
-
inFlight.release(acquired);
|
|
198
|
-
}
|
|
199
|
-
}));
|
|
200
|
-
return { status: 200, body: { results } };
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const onRequest = (request, response) => {
|
|
204
|
-
let pathname;
|
|
205
|
-
try { pathname = new URL(request.url ?? '/', 'http://push-relay').pathname; } catch { response.writeHead(404); response.end(); return; }
|
|
206
|
-
const ready = pathname === '/readyz' && state === 'running';
|
|
207
|
-
const healthy = pathname === '/healthz';
|
|
208
|
-
if ((healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
|
|
209
|
-
sendJson(response, 200, { status: 'ok' }, request.method);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const isRegister = pathname === '/v1/push/register-token';
|
|
213
|
-
const isRegisterLive = pathname === '/v1/push/register-live-activity-token';
|
|
214
|
-
const isUnregisterLive = pathname === '/v1/push/unregister-live-activity-token';
|
|
215
|
-
const isSend = pathname === '/v1/push/send';
|
|
216
|
-
const isLiveActivity = pathname === '/v1/push/live-activity';
|
|
217
|
-
if (request.method !== 'POST' || (!isRegister && !isRegisterLive && !isUnregisterLive && !isSend && !isLiveActivity) || state !== 'running') { response.writeHead(404); response.end(); return; }
|
|
218
|
-
const ip = resolveClientIp(request);
|
|
219
|
-
const limiter = (isRegister || isRegisterLive || isUnregisterLive) ? registerIpLimit : sendIpLimit;
|
|
220
|
-
if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return; }
|
|
221
|
-
const endHttp = httpWork.begin();
|
|
222
|
-
readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
|
|
223
|
-
let body;
|
|
224
|
-
try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
|
|
225
|
-
const parsed = isRegister ? validateRegisterBody(body)
|
|
226
|
-
: (isRegisterLive || isUnregisterLive) ? validateLiveActivityRegisterBody(body)
|
|
227
|
-
: isLiveActivity ? validateLiveActivityBody(body)
|
|
228
|
-
: validateSendBody(body);
|
|
229
|
-
if (parsed.error) {
|
|
230
|
-
reasons.policyRejected += 1;
|
|
231
|
-
sendJson(response, 400, { error: parsed.error });
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
const result = (isRegister || isRegisterLive) ? handleRegister(parsed.value)
|
|
235
|
-
: isUnregisterLive ? handleUnregisterLiveActivity(parsed.value)
|
|
236
|
-
: isLiveActivity ? await handleLiveActivity(parsed.value)
|
|
237
|
-
: await handleSend(parsed.value);
|
|
238
|
-
sendJson(response, result.status, result.body);
|
|
239
|
-
}).catch((error) => {
|
|
240
|
-
if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
|
|
241
|
-
sendJson(response, 500, { error: 'internal' });
|
|
242
|
-
}).finally(endHttp);
|
|
243
|
-
};
|
|
244
13
|
|
|
245
14
|
const start = () => {
|
|
246
15
|
if (state === 'running') return Promise.resolve();
|
|
247
16
|
if (state === 'stopping') return stopPromise.then(() => start());
|
|
248
17
|
if (startPromise) return startPromise;
|
|
249
|
-
liveStore();
|
|
250
|
-
inFlight.reset();
|
|
251
|
-
httpWork.reset();
|
|
252
|
-
if (ownedApns && state === 'stopped') apns = openApns();
|
|
253
18
|
state = 'starting';
|
|
254
19
|
const localGeneration = ++generation;
|
|
255
|
-
const localServer = http.createServer(
|
|
20
|
+
const localServer = http.createServer((request, response) => {
|
|
21
|
+
handler.handleRequest(request, response);
|
|
22
|
+
});
|
|
256
23
|
server = localServer;
|
|
257
24
|
startPromise = new Promise((resolve, rejectStart) => {
|
|
258
25
|
const failStart = (error) => {
|
|
@@ -264,6 +31,7 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
264
31
|
const ready = () => {
|
|
265
32
|
localServer.off('error', failStart);
|
|
266
33
|
if (localGeneration !== generation || state !== 'starting') return;
|
|
34
|
+
handler.activate();
|
|
267
35
|
state = 'running';
|
|
268
36
|
resolve();
|
|
269
37
|
};
|
|
@@ -290,11 +58,6 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
290
58
|
generation += 1;
|
|
291
59
|
const localServer = server;
|
|
292
60
|
stopPromise = Promise.resolve().then(async () => {
|
|
293
|
-
inFlight.rejectWaiters();
|
|
294
|
-
replay.clear();
|
|
295
|
-
registerIpLimit.clear();
|
|
296
|
-
sendIpLimit.clear();
|
|
297
|
-
sendServerLimit.clear();
|
|
298
61
|
let deadlineTimer = null;
|
|
299
62
|
try {
|
|
300
63
|
const closed = new Promise((resolve) => {
|
|
@@ -302,22 +65,19 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
302
65
|
localServer.close(() => resolve());
|
|
303
66
|
try { localServer.closeIdleConnections?.(); } catch { /* ignore */ }
|
|
304
67
|
});
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
});
|
|
308
|
-
const graceful = Promise.all([closed, inFlight.whenIdle(), httpIdle]);
|
|
68
|
+
const deactivated = handler.deactivate();
|
|
69
|
+
const graceful = Promise.all([closed, deactivated]);
|
|
309
70
|
const deadline = new Promise((resolve) => {
|
|
310
71
|
deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
|
|
311
72
|
});
|
|
312
73
|
const winner = await Promise.race([graceful.then(() => 'graceful'), deadline]);
|
|
313
74
|
if (winner === 'deadline') {
|
|
314
75
|
try { localServer?.closeAllConnections?.(); } catch { /* ignore */ }
|
|
76
|
+
await deactivated;
|
|
315
77
|
}
|
|
316
78
|
} finally {
|
|
317
79
|
if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
|
|
318
80
|
}
|
|
319
|
-
try { apns.close?.(); } catch { /* ignore */ }
|
|
320
|
-
if (ownedStore) try { store.close(); } catch { /* ignore */ }
|
|
321
81
|
if (server === localServer) { server = null; state = 'stopped'; }
|
|
322
82
|
stopPromise = null;
|
|
323
83
|
});
|
|
@@ -332,7 +92,7 @@ export const createPushRelayServer = (options = {}) => {
|
|
|
332
92
|
const address = server?.address();
|
|
333
93
|
return address && typeof address === 'object' ? formatPushRelayUrl(config.host, address.port) : null;
|
|
334
94
|
},
|
|
335
|
-
getSnapshot:
|
|
95
|
+
getSnapshot: () => ({ ...handler.getSnapshot(), state }),
|
|
336
96
|
};
|
|
337
97
|
};
|
|
338
98
|
|