@openchambery/relay-server 1.19.3-beta.1 → 1.19.3-beta.10

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 CHANGED
@@ -39,7 +39,39 @@ export OPENCHAMBER_PUSH_RELAY_APNS_P8_PATH=/etc/openchamber/AuthKey.p8
39
39
  openchamber-push-relay --host 127.0.0.1 --port 8788
40
40
  ```
41
41
 
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.
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
+
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
+ ```
43
75
 
44
76
  ### Caddy
45
77
 
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
@@ -261,7 +298,7 @@ These variables belong on the OpenChamber Host, not on the Push process:
261
298
  | `OPENCHAMBER_PUSH_RELAY_URL` | derived from the effective Relay `ws`/`wss` URL | Host override for `https://` or `http://` `…/v1/push/send` |
262
299
  | `OPENCHAMBER_PUSH_RELAY_DISABLED` | unset | Host-only; `true` skips Push Relay and uses direct APNs |
263
300
 
264
- The derived send URL always uses `/v1/push/send` on the same host and port as the Relay URL. `wss` maps to `https`; `ws` maps to `http`. Register is the same origin with `/v1/push/register-token`.
301
+ The derived send URL always uses `/v1/push/send` on the same host and port as the Relay URL. `wss` maps to `https`; `ws` maps to `http`. Register is the same origin with `/v1/push/register-token`. iOS Live Activity tokens use `/v1/push/register-live-activity-token`, `/v1/push/unregister-live-activity-token`, and `/v1/push/live-activity` on that same origin. Live Activity APNs requests use topic `{bundleId}.push-type.liveactivity` and never include session IDs, titles, alerts, or collapse IDs.
265
302
 
266
303
  ### Push process environment
267
304
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/relay-server",
3
- "version": "1.19.3-beta.1",
3
+ "version": "1.19.3-beta.10",
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 {
package/src/push/apns.js CHANGED
@@ -65,15 +65,16 @@ export const createApnsProvider = (options = {}) => {
65
65
  const jwt = getJwt(forceJwt);
66
66
  let client;
67
67
  try { client = getSession(input.env); } catch { resolve({ ok: false }); return; }
68
+ const liveActivity = input.pushType === 'liveactivity';
68
69
  const headers = {
69
70
  ':method': 'POST',
70
71
  ':path': `/3/device/${input.token}`,
71
72
  authorization: `bearer ${jwt}`,
72
- 'apns-topic': bundleId,
73
- 'apns-push-type': 'alert',
73
+ 'apns-topic': liveActivity ? `${bundleId}.push-type.liveactivity` : bundleId,
74
+ 'apns-push-type': liveActivity ? 'liveactivity' : 'alert',
74
75
  'apns-priority': '10',
75
76
  };
76
- if (input.collapseId) headers['apns-collapse-id'] = input.collapseId;
77
+ if (!liveActivity && input.collapseId) headers['apns-collapse-id'] = input.collapseId;
77
78
  let req;
78
79
  try { req = client.request(headers); } catch {
79
80
  dropSession(input.env, client);
@@ -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
+ };
@@ -38,6 +38,7 @@ export interface PushApnsSendInput {
38
38
  env: PushRelayEnv;
39
39
  payload: unknown;
40
40
  collapseId?: string;
41
+ pushType?: 'alert' | 'liveactivity';
41
42
  }
42
43
 
43
44
  export interface PushApnsSendResult {
@@ -11,9 +11,14 @@ export const MAX_DATA_ENTRIES = 16;
11
11
  export const MAX_DATA_KEY_BYTES = 64;
12
12
  export const MAX_DATA_VALUE_BYTES = 256;
13
13
  export const MAX_DATA_TOTAL_BYTES = 2048;
14
+ export const LIVE_ACTIVITY_KIND = 'liveactivity';
15
+ export const LIVE_ACTIVITY_EVENTS = new Set(['update', 'end']);
16
+ export const LIVE_ACTIVITY_STATUSES = new Set(['working', 'tool', 'retry', 'input', 'permission', 'stale', 'complete', 'error']);
17
+ export const LIVE_ACTIVITY_CONTENT_KEYS = new Set(['status', 'eventVersion', 'updatedAt', 'endedAt']);
14
18
 
15
19
  const bytes = (value) => Buffer.byteLength(value, 'utf8');
16
20
  const isSafeInt = (value) => typeof value === 'number' && Number.isSafeInteger(value);
21
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
17
22
 
18
23
  export const isIosToken = (value) => typeof value === 'string' && IOS_TOKEN.test(value);
19
24
 
@@ -48,6 +53,37 @@ export const buildApnsPayload = ({ title, body, badge, collapseId, data }) => {
48
53
  return Object.keys(data).length > 0 ? { aps, ...data } : { aps };
49
54
  };
50
55
 
56
+ export const buildLiveActivityPayload = ({ event, contentState, dismissalDate, staleDate, timestamp }) => {
57
+ const aps = { timestamp, event, 'content-state': contentState };
58
+ if (event === 'end' && dismissalDate !== undefined) aps['dismissal-date'] = dismissalDate;
59
+ if (event === 'update' && staleDate !== undefined) aps['stale-date'] = staleDate;
60
+ return { aps };
61
+ };
62
+
63
+ const uniqueTokens = (tokens) => {
64
+ const unique = [];
65
+ const seen = new Set();
66
+ for (const token of tokens) {
67
+ if (seen.has(token)) continue;
68
+ seen.add(token);
69
+ unique.push(token);
70
+ }
71
+ return unique;
72
+ };
73
+
74
+ const parseContentState = (value, event) => {
75
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
76
+ const keys = Object.keys(value);
77
+ if (keys.length === 0 || keys.some((key) => !LIVE_ACTIVITY_CONTENT_KEYS.has(key))) return null;
78
+ if (typeof value.status !== 'string' || !LIVE_ACTIVITY_STATUSES.has(value.status)) return null;
79
+ if (!isSafeInt(value.eventVersion) || !isFiniteNumber(value.updatedAt)) return null;
80
+ if (event === 'end' && !isFiniteNumber(value.endedAt)) return null;
81
+ if (value.endedAt !== undefined && !isFiniteNumber(value.endedAt)) return null;
82
+ const contentState = { status: value.status, eventVersion: value.eventVersion, updatedAt: value.updatedAt };
83
+ if (value.endedAt !== undefined) contentState.endedAt = value.endedAt;
84
+ return contentState;
85
+ };
86
+
51
87
  export const validateRegisterBody = (body) => {
52
88
  if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
53
89
  if (body.platform === 'android') return { error: 'unsupported_platform' };
@@ -60,6 +96,13 @@ export const validateRegisterBody = (body) => {
60
96
  return { value: { token: body.token, platform: 'ios', publicKeyJwk: jwk, ts, sig } };
61
97
  };
62
98
 
99
+ export const validateLiveActivityRegisterBody = (body) => {
100
+ const parsed = validateRegisterBody(body);
101
+ if (parsed.error) return parsed;
102
+ if (body.kind !== LIVE_ACTIVITY_KIND) return { error: 'invalid_request' };
103
+ return { value: { ...parsed.value, kind: LIVE_ACTIVITY_KIND } };
104
+ };
105
+
63
106
  export const validateSendBody = (body) => {
64
107
  if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
65
108
  const tokens = body.tokens;
@@ -75,13 +118,7 @@ export const validateSendBody = (body) => {
75
118
  const sig = parseSig(body.sig);
76
119
  const data = parseData(body.data);
77
120
  if (!jwk || ts === null || !sig || !data) return { error: 'invalid_request' };
78
- const unique = [];
79
- const seen = new Set();
80
- for (const token of tokens) {
81
- if (seen.has(token)) continue;
82
- seen.add(token);
83
- unique.push(token);
84
- }
121
+ const unique = uniqueTokens(tokens);
85
122
  const payload = buildApnsPayload({
86
123
  title: body.title,
87
124
  body: body.body ?? '',
@@ -106,3 +143,43 @@ export const validateSendBody = (body) => {
106
143
  },
107
144
  };
108
145
  };
146
+
147
+ export const validateLiveActivityBody = (body) => {
148
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
149
+ const tokens = body.tokens;
150
+ if (!Array.isArray(tokens) || tokens.length < 1 || tokens.length > MAX_TOKENS_PER_REQUEST) return { error: 'invalid_request' };
151
+ if (tokens.some((token) => !isIosToken(token))) return { error: 'invalid_request' };
152
+ if (typeof body.event !== 'string' || !LIVE_ACTIVITY_EVENTS.has(body.event)) return { error: 'invalid_request' };
153
+ const contentState = parseContentState(body.contentState, body.event);
154
+ if (!contentState) return { error: 'invalid_request' };
155
+ if (body.dismissalDate !== undefined && !isSafeInt(body.dismissalDate)) return { error: 'invalid_request' };
156
+ if (body.staleDate !== undefined && !isSafeInt(body.staleDate)) return { error: 'invalid_request' };
157
+ if (body.env !== undefined && body.env !== 'production' && body.env !== 'sandbox') return { error: 'invalid_request' };
158
+ const jwk = parsePublicJwk(body.publicKeyJwk);
159
+ const ts = parseTs(body.ts);
160
+ const sig = parseSig(body.sig);
161
+ if (!jwk || ts === null || !sig) return { error: 'invalid_request' };
162
+ const payload = buildLiveActivityPayload({
163
+ event: body.event,
164
+ contentState,
165
+ dismissalDate: body.dismissalDate,
166
+ staleDate: body.staleDate,
167
+ timestamp: 1_000_000_000,
168
+ });
169
+ if (bytes(JSON.stringify(payload)) > APNS_PAYLOAD_BYTES) return { error: 'invalid_request' };
170
+ return {
171
+ value: {
172
+ tokens,
173
+ uniqueTokens: uniqueTokens(tokens),
174
+ event: body.event,
175
+ contentState,
176
+ dismissalDate: body.dismissalDate,
177
+ staleDate: body.staleDate,
178
+ env: body.env === 'production' ? 'production' : 'sandbox',
179
+ publicKeyJwk: jwk,
180
+ ts,
181
+ sig,
182
+ payload,
183
+ },
184
+ };
185
+ };
@@ -1,191 +1,25 @@
1
1
  import http from 'node:http';
2
2
 
3
- import { createApnsProvider } from './apns.js';
4
- import { normalizePushRelayOptions, resolvePushRelayClientIp, formatPushRelayUrl } from './config.js';
5
- import { deriveServerId, verifyP1363 } from './crypto.js';
6
- import { createInFlightGate, createReplayGuard, createSlidingWindowLimiter, createWorkTracker } from './guard.js';
7
- import { JSON_BODY_BYTES, 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 resolveClientIp = config.resolveClientIp ?? ((request) => resolvePushRelayClientIp(request, config.trustProxy));
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 authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}`, parsed.sig, parsed.ts);
102
- if (authError) return { status: 401, body: { error: authError } };
103
- const serverId = deriveServerId(parsed.publicKeyJwk);
104
- const replayKey = `register.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
105
- if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
106
- const tokens = liveStore();
107
- const existing = tokens.get(parsed.token);
108
- if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
109
- if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
110
- tokens.upsert(parsed.token, serverId, parsed.platform, clock.now());
111
- return { status: 200, body: { ok: true } };
112
- };
113
-
114
- const handleSend = async (parsed) => {
115
- const sorted = [...parsed.tokens].sort();
116
- const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
117
- if (authError) return { status: 401, body: { error: authError } };
118
- const serverId = deriveServerId(parsed.publicKeyJwk);
119
- const replayKey = `send.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
120
- if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
121
- if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
122
- if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
123
- const tokens = liveStore();
124
- const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
125
- const binding = tokens.get(token);
126
- if (!binding || binding.serverId !== serverId) return { token, ok: false };
127
- const acquired = await inFlight.acquire();
128
- if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
129
- try {
130
- const outcome = await apns.send({ token, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
131
- if (outcome?.drop === true) {
132
- tokens.delete(token);
133
- return { token, ok: false, drop: true };
134
- }
135
- return { token, ok: outcome?.ok === true };
136
- } catch {
137
- return { token, ok: false };
138
- } finally {
139
- inFlight.release(acquired);
140
- }
141
- }));
142
- return { status: 200, body: { results } };
143
- };
144
-
145
- const onRequest = (request, response) => {
146
- let pathname;
147
- try { pathname = new URL(request.url ?? '/', 'http://push-relay').pathname; } catch { response.writeHead(404); response.end(); return; }
148
- const ready = pathname === '/readyz' && state === 'running';
149
- const healthy = pathname === '/healthz';
150
- if ((healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
151
- sendJson(response, 200, { status: 'ok' }, request.method);
152
- return;
153
- }
154
- const isRegister = pathname === '/v1/push/register-token';
155
- const isSend = pathname === '/v1/push/send';
156
- if (request.method !== 'POST' || (!isRegister && !isSend) || state !== 'running') { response.writeHead(404); response.end(); return; }
157
- const ip = resolveClientIp(request);
158
- const limiter = isRegister ? registerIpLimit : sendIpLimit;
159
- if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return; }
160
- const endHttp = httpWork.begin();
161
- readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
162
- let body;
163
- try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
164
- const parsed = isRegister ? validateRegisterBody(body) : validateSendBody(body);
165
- if (parsed.error) {
166
- reasons.policyRejected += 1;
167
- sendJson(response, 400, { error: parsed.error });
168
- return;
169
- }
170
- const result = isRegister ? handleRegister(parsed.value) : await handleSend(parsed.value);
171
- sendJson(response, result.status, result.body);
172
- }).catch((error) => {
173
- if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
174
- sendJson(response, 500, { error: 'internal' });
175
- }).finally(endHttp);
176
- };
177
13
 
178
14
  const start = () => {
179
15
  if (state === 'running') return Promise.resolve();
180
16
  if (state === 'stopping') return stopPromise.then(() => start());
181
17
  if (startPromise) return startPromise;
182
- liveStore();
183
- inFlight.reset();
184
- httpWork.reset();
185
- if (ownedApns && state === 'stopped') apns = openApns();
186
18
  state = 'starting';
187
19
  const localGeneration = ++generation;
188
- const localServer = http.createServer(onRequest);
20
+ const localServer = http.createServer((request, response) => {
21
+ handler.handleRequest(request, response);
22
+ });
189
23
  server = localServer;
190
24
  startPromise = new Promise((resolve, rejectStart) => {
191
25
  const failStart = (error) => {
@@ -197,6 +31,7 @@ export const createPushRelayServer = (options = {}) => {
197
31
  const ready = () => {
198
32
  localServer.off('error', failStart);
199
33
  if (localGeneration !== generation || state !== 'starting') return;
34
+ handler.activate();
200
35
  state = 'running';
201
36
  resolve();
202
37
  };
@@ -223,11 +58,6 @@ export const createPushRelayServer = (options = {}) => {
223
58
  generation += 1;
224
59
  const localServer = server;
225
60
  stopPromise = Promise.resolve().then(async () => {
226
- inFlight.rejectWaiters();
227
- replay.clear();
228
- registerIpLimit.clear();
229
- sendIpLimit.clear();
230
- sendServerLimit.clear();
231
61
  let deadlineTimer = null;
232
62
  try {
233
63
  const closed = new Promise((resolve) => {
@@ -235,22 +65,19 @@ export const createPushRelayServer = (options = {}) => {
235
65
  localServer.close(() => resolve());
236
66
  try { localServer.closeIdleConnections?.(); } catch { /* ignore */ }
237
67
  });
238
- const httpIdle = httpWork.whenIdle().then(() => {
239
- try { localServer?.closeIdleConnections?.(); } catch { /* ignore */ }
240
- });
241
- const graceful = Promise.all([closed, inFlight.whenIdle(), httpIdle]);
68
+ const deactivated = handler.deactivate();
69
+ const graceful = Promise.all([closed, deactivated]);
242
70
  const deadline = new Promise((resolve) => {
243
71
  deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
244
72
  });
245
73
  const winner = await Promise.race([graceful.then(() => 'graceful'), deadline]);
246
74
  if (winner === 'deadline') {
247
75
  try { localServer?.closeAllConnections?.(); } catch { /* ignore */ }
76
+ await deactivated;
248
77
  }
249
78
  } finally {
250
79
  if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
251
80
  }
252
- try { apns.close?.(); } catch { /* ignore */ }
253
- if (ownedStore) try { store.close(); } catch { /* ignore */ }
254
81
  if (server === localServer) { server = null; state = 'stopped'; }
255
82
  stopPromise = null;
256
83
  });
@@ -265,7 +92,7 @@ export const createPushRelayServer = (options = {}) => {
265
92
  const address = server?.address();
266
93
  return address && typeof address === 'object' ? formatPushRelayUrl(config.host, address.port) : null;
267
94
  },
268
- getSnapshot: snapshot,
95
+ getSnapshot: () => ({ ...handler.getSnapshot(), state }),
269
96
  };
270
97
  };
271
98