@yuriai/relay-server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ export const createReplayGuard = ({ replayMs, maxReplayEntries, now }) => {
2
+ const replay = new Map();
3
+ const purge = () => {
4
+ const ts = now();
5
+ for (const [key, expiry] of replay) if (expiry <= ts) replay.delete(key);
6
+ };
7
+ return {
8
+ has(key) {
9
+ purge();
10
+ return replay.has(key);
11
+ },
12
+ remember(key) {
13
+ purge();
14
+ if (replay.size >= maxReplayEntries && !replay.has(key)) return false;
15
+ replay.set(key, now() + replayMs);
16
+ return true;
17
+ },
18
+ forget(key) { replay.delete(key); },
19
+ clear() { replay.clear(); },
20
+ get size() { return replay.size; },
21
+ };
22
+ };
23
+
24
+ export const createSlidingWindowLimiter = ({ windowMs, maxCount, maxEntries, now }) => {
25
+ const entries = new Map();
26
+ const pruneKey = (key, cutoff) => {
27
+ const stamps = entries.get(key);
28
+ if (!stamps) return null;
29
+ let index = 0;
30
+ while (index < stamps.length && stamps[index] <= cutoff) index += 1;
31
+ if (index) stamps.splice(0, index);
32
+ if (stamps.length === 0) { entries.delete(key); return null; }
33
+ return stamps;
34
+ };
35
+ const purge = (cutoff) => {
36
+ for (const key of [...entries.keys()]) pruneKey(key, cutoff);
37
+ };
38
+ return {
39
+ allow(key) {
40
+ const ts = now();
41
+ const cutoff = ts - windowMs;
42
+ let stamps = pruneKey(key, cutoff);
43
+ if (!stamps) {
44
+ if (entries.size >= maxEntries) {
45
+ purge(cutoff);
46
+ if (entries.size >= maxEntries) return false;
47
+ }
48
+ stamps = [];
49
+ entries.set(key, stamps);
50
+ }
51
+ if (stamps.length >= maxCount) return false;
52
+ stamps.push(ts);
53
+ return true;
54
+ },
55
+ clear() { entries.clear(); },
56
+ };
57
+ };
58
+
59
+ export const createInFlightGate = (maxInFlight) => {
60
+ const maxWaiters = maxInFlight;
61
+ let generation = 1;
62
+ let active = 0;
63
+ let accepting = true;
64
+ const waiters = [];
65
+ const idleWaiters = [];
66
+ const notifyIdle = () => {
67
+ if (active !== 0) return;
68
+ while (idleWaiters.length) idleWaiters.shift()();
69
+ };
70
+ return {
71
+ acquire() {
72
+ if (!accepting) return Promise.resolve(false);
73
+ if (active < maxInFlight) {
74
+ active += 1;
75
+ return Promise.resolve(generation);
76
+ }
77
+ if (waiters.length >= maxWaiters) return Promise.resolve(false);
78
+ const gen = generation;
79
+ return new Promise((resolve) => {
80
+ waiters.push(() => {
81
+ if (!accepting || generation !== gen) { resolve(false); return; }
82
+ resolve(generation);
83
+ });
84
+ });
85
+ },
86
+ release(ticket) {
87
+ if (ticket !== generation) return;
88
+ if (accepting) {
89
+ const next = waiters.shift();
90
+ if (next) { next(); return; }
91
+ } else {
92
+ while (waiters.length) waiters.shift()();
93
+ }
94
+ active = Math.max(0, active - 1);
95
+ notifyIdle();
96
+ },
97
+ rejectWaiters() {
98
+ accepting = false;
99
+ while (waiters.length) waiters.shift()();
100
+ },
101
+ reset() {
102
+ generation += 1;
103
+ accepting = true;
104
+ active = 0;
105
+ waiters.length = 0;
106
+ while (idleWaiters.length) idleWaiters.shift()();
107
+ },
108
+ whenIdle() {
109
+ if (active === 0) return Promise.resolve();
110
+ return new Promise((resolve) => { idleWaiters.push(resolve); });
111
+ },
112
+ get active() { return active; },
113
+ get waiting() { return waiters.length; },
114
+ };
115
+ };
116
+
117
+ export const createWorkTracker = () => {
118
+ let generation = 1;
119
+ let active = 0;
120
+ const idleWaiters = [];
121
+ const notifyIdle = () => {
122
+ if (active !== 0) return;
123
+ while (idleWaiters.length) idleWaiters.shift()();
124
+ };
125
+ return {
126
+ begin() {
127
+ active += 1;
128
+ const gen = generation;
129
+ return () => {
130
+ if (gen !== generation) return;
131
+ active = Math.max(0, active - 1);
132
+ notifyIdle();
133
+ };
134
+ },
135
+ whenIdle() {
136
+ if (active === 0) return Promise.resolve();
137
+ return new Promise((resolve) => { idleWaiters.push(resolve); });
138
+ },
139
+ reset() {
140
+ generation += 1;
141
+ active = 0;
142
+ while (idleWaiters.length) idleWaiters.shift()();
143
+ },
144
+ get active() { return active; },
145
+ };
146
+ };
@@ -0,0 +1,314 @@
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 { createCachedTokenStore } from './cached-store.js';
7
+
8
+ const WINDOW_MS = 60_000;
9
+ const STOP_DEADLINE_MS = 5_500;
10
+
11
+ const liveActivitySignMessage = (ts, tokens, event, contentState, dismissalDate, staleDate) => {
12
+ let message = `${ts}.${tokens.join(',')}.${event}.${contentState.status}.${contentState.eventVersion}.${contentState.updatedAt}.${contentState.endedAt ?? ''}.${dismissalDate ?? ''}.${staleDate ?? ''}`;
13
+ if (contentState.title !== undefined || contentState.workingCount !== undefined || contentState.items !== undefined) {
14
+ message += `.${contentState.title ?? ''}.${contentState.workingCount ?? ''}.${JSON.stringify(contentState.items ?? [])}`;
15
+ }
16
+ return message;
17
+ };
18
+
19
+ const sendJson = (response, status, payload, method = 'GET') => {
20
+ if (response.writableEnded) return;
21
+ response.setHeader('cache-control', 'no-store');
22
+ response.writeHead(status, { 'content-type': 'application/json' });
23
+ response.end(method === 'HEAD' ? undefined : JSON.stringify(payload));
24
+ };
25
+
26
+ const readBody = (request, maxBytes) => new Promise((resolve, reject) => {
27
+ let done = false;
28
+ const timer = setTimeout(() => { fail(new Error('body timeout')); request.destroy(); }, 5_000);
29
+ timer.unref?.();
30
+ const fail = (error) => { if (done) return; done = true; clearTimeout(timer); reject(error); };
31
+ const succeed = (value) => { if (done) return; done = true; clearTimeout(timer); resolve(value); };
32
+ const tooLarge = () => {
33
+ const error = new Error('payload too large');
34
+ error.code = 'PAYLOAD_TOO_LARGE';
35
+ fail(error);
36
+ };
37
+ const drain = () => {
38
+ request.removeListener('data', onData);
39
+ request.resume();
40
+ };
41
+ const declared = Number(request.headers['content-length']);
42
+ if (Number.isFinite(declared) && declared > maxBytes) {
43
+ tooLarge();
44
+ request.resume();
45
+ return;
46
+ }
47
+ const chunks = [];
48
+ let size = 0;
49
+ const onData = (chunk) => {
50
+ size += chunk.length;
51
+ if (size > maxBytes) {
52
+ drain();
53
+ tooLarge();
54
+ return;
55
+ }
56
+ chunks.push(chunk);
57
+ };
58
+ request.on('data', onData);
59
+ request.on('end', () => succeed(Buffer.concat(chunks)));
60
+ request.on('error', fail);
61
+ request.once('aborted', () => fail(new Error('body aborted')));
62
+ });
63
+
64
+ export const createPushRelayHandler = (options = {}) => {
65
+ const claimHealthEndpoints = options.claimHealthEndpoints !== false;
66
+ const config = normalizePushRelayOptions(options);
67
+ const limits = config.limits;
68
+ const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...config.clock };
69
+ const resolveClientIp = config.resolveClientIp ?? ((request) => resolvePushRelayClientIp(request, config.trustProxy));
70
+ const ownedStore = !config.store;
71
+ const ownedApns = !config.apnsProvider;
72
+ const openStore = () => config.store ?? createCachedTokenStore(config.databasePath, { maxTokens: limits.maxTokens, maxPending: limits.maxInFlight });
73
+ const openApns = () => config.apnsProvider ?? createApnsProvider({ ...config.apns, clock, http2: config.http2 });
74
+ let store = openStore();
75
+ let apns;
76
+ try {
77
+ apns = openApns();
78
+ } catch (error) {
79
+ if (ownedStore) try { store.close(); } catch { /* ignore */ }
80
+ throw error;
81
+ }
82
+ let mutationTail = Promise.resolve();
83
+ let mutations = 0;
84
+ const mutate = (run) => {
85
+ if (mutations >= limits.maxInFlight) return Promise.resolve({ status: 503, body: { error: 'busy' } });
86
+ mutations += 1;
87
+ const deadline = Date.now() + 2_000;
88
+ const result = mutationTail.then(() => {
89
+ if (state !== 'running' || Date.now() >= deadline) return { status: 503, body: { error: 'busy' } };
90
+ return run();
91
+ }).finally(() => { mutations -= 1; });
92
+ mutationTail = result.catch(() => {});
93
+ return result;
94
+ };
95
+ const replay = createReplayGuard({ replayMs: limits.replayMs, maxReplayEntries: limits.maxReplayEntries, now: () => clock.now() });
96
+ const registerIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.registerLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
97
+ const sendIpLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.sendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
98
+ const sendServerLimit = createSlidingWindowLimiter({ windowMs: WINDOW_MS, maxCount: limits.serverSendLimitPerMinute, maxEntries: limits.maxRateLimitEntries, now: () => clock.now() });
99
+ const inFlight = createInFlightGate(limits.maxInFlight);
100
+ const httpWork = createWorkTracker();
101
+ const reasons = { authRejected: 0, policyRejected: 0, limited: 0, replayRejected: 0 };
102
+ let stopPromise = null; let state = 'idle';
103
+ const snapshot = () => {
104
+ let tokenCount = 0;
105
+ let storeAvailable = false;
106
+ try { tokenCount = store.count(); storeAvailable = true; } catch { /* closed after stop */ }
107
+ return { state, tokenCount, storeAvailable, inFlight: inFlight.active, replayEntries: replay.size, reasons: { ...reasons } };
108
+ };
109
+
110
+ const authenticate = (jwk, message, signature, ts) => {
111
+ if (Math.abs(clock.now() - ts) > limits.timestampSkewMs) { reasons.authRejected += 1; return 'timestamp'; }
112
+ if (!verifyP1363(message, jwk, signature)) { reasons.authRejected += 1; return 'invalid_signature'; }
113
+ return null;
114
+ };
115
+
116
+ const handleRegister = (parsed) => mutate(async () => {
117
+ const message = parsed.kind
118
+ ? `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`
119
+ : `${parsed.ts}.${parsed.token}.${parsed.platform}`;
120
+ const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
121
+ if (authError) return { status: 401, body: { error: authError } };
122
+ const serverId = deriveServerId(parsed.publicKeyJwk);
123
+ const replayKey = `${parsed.kind ? 'register-live-activity' : 'register'}.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
124
+ if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
125
+ const tokens = store;
126
+ const existing = tokens.get(parsed.token);
127
+ if (!existing && tokens.count() >= limits.maxTokens) { reasons.limited += 1; return { status: 429, body: { error: 'token_limit' } }; }
128
+ if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
129
+ try { await tokens.upsert(parsed.token, serverId, parsed.platform, clock.now()); } catch (error) { replay.forget(replayKey); throw error; }
130
+ return { status: 200, body: { ok: true } };
131
+ });
132
+
133
+ const handleUnregisterLiveActivity = (parsed) => mutate(async () => {
134
+ const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${parsed.token}.${parsed.platform}.${parsed.kind}`, parsed.sig, parsed.ts);
135
+ if (authError) return { status: 401, body: { error: authError } };
136
+ const serverId = deriveServerId(parsed.publicKeyJwk);
137
+ const replayKey = `unregister-live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
138
+ if (replay.has(replayKey)) return { status: 200, body: { ok: true } };
139
+ const tokens = store;
140
+ const existing = tokens.get(parsed.token);
141
+ if (existing && existing.serverId !== serverId) { reasons.authRejected += 1; return { status: 401, body: { error: 'invalid_signature' } }; }
142
+ if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
143
+ try { if (existing) await tokens.delete(parsed.token, existing); } catch (error) { replay.forget(replayKey); throw error; }
144
+ return { status: 200, body: { ok: true } };
145
+ });
146
+
147
+ const handleSend = async (parsed) => {
148
+ const sorted = [...parsed.tokens].sort();
149
+ const authError = authenticate(parsed.publicKeyJwk, `${parsed.ts}.${sorted.join(',')}.${parsed.title}`, parsed.sig, parsed.ts);
150
+ if (authError) return { status: 401, body: { error: authError } };
151
+ const serverId = deriveServerId(parsed.publicKeyJwk);
152
+ const replayKey = `send.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
153
+ if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
154
+ if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
155
+ if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
156
+ const tokens = store;
157
+ const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
158
+ const binding = tokens.get(token);
159
+ if (!binding || binding.serverId !== serverId) return { token, ok: false };
160
+ const acquired = await inFlight.acquire();
161
+ if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
162
+ try {
163
+ const outcome = await apns.send({ token, env: parsed.env, payload: parsed.payload, collapseId: parsed.collapseId });
164
+ if (outcome?.drop === true) {
165
+ await tokens.delete(token, binding);
166
+ return { token, ok: false, drop: true };
167
+ }
168
+ return { token, ok: outcome?.ok === true };
169
+ } catch {
170
+ return { token, ok: false };
171
+ } finally {
172
+ inFlight.release(acquired);
173
+ }
174
+ }));
175
+ return { status: 200, body: { results } };
176
+ };
177
+
178
+ const handleLiveActivity = async (parsed) => {
179
+ const sorted = [...parsed.tokens].sort();
180
+ const contentState = parsed.contentState;
181
+ const message = liveActivitySignMessage(parsed.ts, sorted, parsed.event, contentState, parsed.dismissalDate, parsed.staleDate);
182
+ const authError = authenticate(parsed.publicKeyJwk, message, parsed.sig, parsed.ts);
183
+ if (authError) return { status: 401, body: { error: authError } };
184
+ const serverId = deriveServerId(parsed.publicKeyJwk);
185
+ const replayKey = `live-activity.${serverId}.${parsed.ts}.${parsed.sig.toString('base64url')}`;
186
+ if (replay.has(replayKey)) { reasons.replayRejected += 1; return { status: 401, body: { error: 'replay' } }; }
187
+ if (!replay.remember(replayKey)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
188
+ if (!sendServerLimit.allow(serverId)) { reasons.limited += 1; return { status: 429, body: { error: 'rate_limited' } }; }
189
+ const payload = buildLiveActivityPayload({
190
+ event: parsed.event,
191
+ contentState,
192
+ dismissalDate: parsed.dismissalDate,
193
+ staleDate: parsed.staleDate,
194
+ timestamp: Math.floor(clock.now() / 1000),
195
+ });
196
+ const tokens = store;
197
+ const results = await Promise.all(parsed.uniqueTokens.map(async (token) => {
198
+ const binding = tokens.get(token);
199
+ if (!binding || binding.serverId !== serverId) return { token, ok: false };
200
+ const acquired = await inFlight.acquire();
201
+ if (!acquired) { reasons.limited += 1; return { token, ok: false }; }
202
+ try {
203
+ const outcome = await apns.send({ token, env: parsed.env, payload, pushType: 'liveactivity' });
204
+ if (outcome?.drop === true) {
205
+ await tokens.delete(token, binding);
206
+ return { token, ok: false, drop: true };
207
+ }
208
+ if (outcome?.ok === true && parsed.event === 'end') await tokens.delete(token, binding);
209
+ return { token, ok: outcome?.ok === true };
210
+ } catch {
211
+ return { token, ok: false };
212
+ } finally {
213
+ inFlight.release(acquired);
214
+ }
215
+ }));
216
+ return { status: 200, body: { results } };
217
+ };
218
+
219
+ const handleRequest = (request, response) => {
220
+ let pathname;
221
+ try { pathname = new URL(request.url ?? '/', 'http://push-relay').pathname; } catch {
222
+ if (!claimHealthEndpoints) return false;
223
+ response.writeHead(404); response.end(); return true;
224
+ }
225
+ if (!claimHealthEndpoints && !pathname.startsWith('/v1/push/')) return false;
226
+ const ready = pathname === '/readyz' && state === 'running';
227
+ const healthy = pathname === '/healthz';
228
+ if (claimHealthEndpoints && (healthy || ready) && (request.method === 'GET' || request.method === 'HEAD')) {
229
+ sendJson(response, 200, { status: 'ok' }, request.method);
230
+ return true;
231
+ }
232
+ const isRegister = pathname === '/v1/push/register-token';
233
+ const isRegisterLive = pathname === '/v1/push/register-live-activity-token';
234
+ const isUnregisterLive = pathname === '/v1/push/unregister-live-activity-token';
235
+ const isSend = pathname === '/v1/push/send';
236
+ const isLiveActivity = pathname === '/v1/push/live-activity';
237
+ if (request.method !== 'POST' || (!isRegister && !isRegisterLive && !isUnregisterLive && !isSend && !isLiveActivity) || state !== 'running') { response.writeHead(404); response.end(); return true; }
238
+ const ip = resolveClientIp(request);
239
+ const limiter = (isRegister || isRegisterLive || isUnregisterLive) ? registerIpLimit : sendIpLimit;
240
+ if (!limiter.allow(ip)) { reasons.limited += 1; sendJson(response, 429, { error: 'rate_limited' }); return true; }
241
+ if (httpWork.active >= limits.maxInFlight * 2) { reasons.limited += 1; sendJson(response, 503, { error: 'busy' }); request.resume(); return true; }
242
+ const endHttp = httpWork.begin();
243
+ readBody(request, limits.jsonBodyBytes ?? JSON_BODY_BYTES).then(async (buffer) => {
244
+ let body;
245
+ try { body = JSON.parse(buffer.toString('utf8')); } catch { reasons.policyRejected += 1; sendJson(response, 400, { error: 'invalid_request' }); return; }
246
+ const parsed = isRegister ? validateRegisterBody(body)
247
+ : (isRegisterLive || isUnregisterLive) ? validateLiveActivityRegisterBody(body)
248
+ : isLiveActivity ? validateLiveActivityBody(body)
249
+ : validateSendBody(body);
250
+ if (parsed.error) {
251
+ reasons.policyRejected += 1;
252
+ sendJson(response, 400, { error: parsed.error });
253
+ return;
254
+ }
255
+ await store.ready?.();
256
+ if (state !== 'running') { sendJson(response, 503, { error: 'unavailable' }); return; }
257
+ const result = (isRegister || isRegisterLive) ? await handleRegister(parsed.value)
258
+ : isUnregisterLive ? await handleUnregisterLiveActivity(parsed.value)
259
+ : isLiveActivity ? await handleLiveActivity(parsed.value)
260
+ : await handleSend(parsed.value);
261
+ sendJson(response, result.status, result.body);
262
+ }).catch((error) => {
263
+ if (error?.code === 'PAYLOAD_TOO_LARGE') { reasons.policyRejected += 1; sendJson(response, 413, { error: 'payload_too_large' }); return; }
264
+ if (error?.code === 'PUSH_STORE_UNAVAILABLE' || error?.code === 'PUSH_STORE_BUSY') { sendJson(response, 503, { error: 'unavailable' }); return; }
265
+ sendJson(response, 500, { error: 'internal' });
266
+ }).finally(endHttp);
267
+ return true;
268
+ };
269
+
270
+ const activate = async () => {
271
+ if (state === 'running') return;
272
+ if (ownedStore && state === 'stopped') store = openStore();
273
+ await store.ready?.();
274
+ inFlight.reset();
275
+ httpWork.reset();
276
+ if (ownedApns && state === 'stopped') apns = openApns();
277
+ state = 'running';
278
+ };
279
+
280
+ const deactivate = () => {
281
+ if (stopPromise) return stopPromise;
282
+ if (state === 'idle' || state === 'stopped') { state = 'stopped'; return ownedStore ? Promise.resolve(store.close()) : Promise.resolve(); }
283
+ state = 'stopping';
284
+ stopPromise = Promise.resolve().then(async () => {
285
+ inFlight.rejectWaiters();
286
+ replay.clear();
287
+ registerIpLimit.clear();
288
+ sendIpLimit.clear();
289
+ sendServerLimit.clear();
290
+ let deadlineTimer = null;
291
+ try {
292
+ const graceful = Promise.all([inFlight.whenIdle(), httpWork.whenIdle()]);
293
+ const deadline = new Promise((resolve) => {
294
+ deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
295
+ });
296
+ await Promise.race([graceful.then(() => 'graceful'), deadline]);
297
+ } finally {
298
+ if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
299
+ }
300
+ try { apns.close?.(); } catch { /* ignore */ }
301
+ if (ownedStore) try { await store.close(); } catch { /* ignore */ }
302
+ state = 'stopped';
303
+ stopPromise = null;
304
+ });
305
+ return stopPromise;
306
+ };
307
+
308
+ return {
309
+ handleRequest,
310
+ activate,
311
+ deactivate,
312
+ getSnapshot: snapshot,
313
+ };
314
+ };
@@ -0,0 +1,113 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+
4
+ export type PushRelayState = 'idle' | 'starting' | 'running' | 'stopping' | 'stopped';
5
+ export type PushRelayEnv = 'production' | 'sandbox';
6
+
7
+ export interface PushRelayLimits {
8
+ timestampSkewMs: number;
9
+ replayMs: number;
10
+ maxReplayEntries: number;
11
+ registerLimitPerMinute: number;
12
+ sendLimitPerMinute: number;
13
+ serverSendLimitPerMinute: number;
14
+ maxTokens: number;
15
+ maxInFlight: number;
16
+ maxRateLimitEntries: number;
17
+ jsonBodyBytes: number;
18
+ }
19
+
20
+ export interface PushRelayApnsConfig {
21
+ keyId: string;
22
+ teamId: string;
23
+ p8: string;
24
+ bundleId: string;
25
+ }
26
+
27
+ export interface PushRelayClock {
28
+ now: () => number;
29
+ setTimeout: typeof globalThis.setTimeout;
30
+ clearTimeout: typeof globalThis.clearTimeout;
31
+ setInterval: typeof globalThis.setInterval;
32
+ clearInterval: typeof globalThis.clearInterval;
33
+ setImmediate: typeof globalThis.setImmediate;
34
+ }
35
+
36
+ export interface PushApnsSendInput {
37
+ token: string;
38
+ env: PushRelayEnv;
39
+ payload: unknown;
40
+ collapseId?: string;
41
+ pushType?: 'alert' | 'liveactivity';
42
+ }
43
+
44
+ export interface PushApnsSendResult {
45
+ ok: boolean;
46
+ drop?: boolean;
47
+ }
48
+
49
+ export interface PushApnsProvider {
50
+ send(input: PushApnsSendInput): Promise<PushApnsSendResult>;
51
+ close?(): void;
52
+ }
53
+
54
+ export interface PushTokenRecord {
55
+ serverId: string;
56
+ platform: string;
57
+ updatedAt: number;
58
+ }
59
+
60
+ export interface PushTokenStore {
61
+ get(token: string): PushTokenRecord | null;
62
+ upsert(token: string, serverId: string, platform: string, updatedAt: number): void | Promise<void>;
63
+ delete(token: string, expected?: PushTokenRecord): void | Promise<void>;
64
+ count(): number;
65
+ close(): void | Promise<void>;
66
+ ready?(): Promise<void>;
67
+ }
68
+
69
+ export interface PushRelaySnapshotReasons {
70
+ authRejected: number;
71
+ policyRejected: number;
72
+ limited: number;
73
+ replayRejected: number;
74
+ }
75
+
76
+ export interface PushRelaySnapshot {
77
+ state: PushRelayState;
78
+ tokenCount: number;
79
+ storeAvailable: boolean;
80
+ inFlight: number;
81
+ replayEntries: number;
82
+ reasons: PushRelaySnapshotReasons;
83
+ }
84
+
85
+ export interface PushRelayOptions {
86
+ host?: string;
87
+ port?: number;
88
+ trustProxy?: boolean;
89
+ databasePath?: string;
90
+ limits?: Partial<PushRelayLimits>;
91
+ apns?: PushRelayApnsConfig;
92
+ apnsProvider?: PushApnsProvider;
93
+ clock?: Partial<PushRelayClock>;
94
+ logger?: Pick<Console, 'info' | 'warn' | 'error'>;
95
+ resolveClientIp?: (request: IncomingMessage) => string;
96
+ store?: PushTokenStore;
97
+ http2?: { connect: typeof import('node:http2').connect };
98
+ }
99
+
100
+ export interface PushRelayServerInstance {
101
+ start(): Promise<void>;
102
+ stop(): Promise<void>;
103
+ address(): string | AddressInfo | null | undefined;
104
+ readonly url: string | null;
105
+ getSnapshot(): PushRelaySnapshot;
106
+ }
107
+
108
+ export function resolvePushRelayClientIp(request: IncomingMessage, trustProxy?: boolean): string;
109
+ export function formatPushRelayUrl(host: string, port: number): string;
110
+ export function canonicalPublicJwkString(jwk: { crv: string; kty: string; x: string; y: string }): string;
111
+ export function deriveServerId(jwk: { crv: string; kty: string; x: string; y: string }): string;
112
+ export function createPushRelayServer(options?: PushRelayOptions): PushRelayServerInstance;
113
+ export function startPushRelayServer(options?: PushRelayOptions): Promise<PushRelayServerInstance>;
@@ -0,0 +1,3 @@
1
+ export { createPushRelayServer, startPushRelayServer } from './server.js';
2
+ export { resolvePushRelayClientIp, formatPushRelayUrl } from './config.js';
3
+ export { canonicalPublicJwkString, deriveServerId } from './crypto.js';