@openchambery/relay-server 1.18.7-beta.2 → 1.18.7-beta.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.
@@ -0,0 +1,138 @@
1
+ import crypto from 'node:crypto';
2
+ import http2 from 'node:http2';
3
+
4
+ const APNS_HOST = {
5
+ production: 'https://api.push.apple.com',
6
+ sandbox: 'https://api.sandbox.push.apple.com',
7
+ };
8
+ const JWT_TTL_MS = 50 * 60 * 1000;
9
+ const SESSION_TTL_MS = 30 * 60 * 1000;
10
+ const REQUEST_TIMEOUT_MS = 5_000;
11
+ const MAX_RESPONSE_BYTES = 4_096;
12
+ export const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);
13
+
14
+ const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
15
+
16
+ export const createApnsProvider = (options = {}) => {
17
+ const clock = { now: Date.now, setTimeout, clearTimeout, ...options.clock };
18
+ const connect = options.http2?.connect ?? http2.connect;
19
+ const sessionTtlMs = options.sessionTtlMs ?? SESSION_TTL_MS;
20
+ let privateKey;
21
+ try {
22
+ privateKey = crypto.createPrivateKey(normalizePem(options.p8));
23
+ } catch {
24
+ throw new Error('Invalid APNs key');
25
+ }
26
+ const bundleId = options.bundleId;
27
+ const sessions = new Map();
28
+ let cachedJwt = null;
29
+
30
+ const signJwt = () => {
31
+ const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: options.keyId })).toString('base64url');
32
+ const claims = Buffer.from(JSON.stringify({ iss: options.teamId, iat: Math.floor(clock.now() / 1000) })).toString('base64url');
33
+ const signingInput = `${header}.${claims}`;
34
+ const signature = crypto.sign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
35
+ return `${signingInput}.${signature}`;
36
+ };
37
+
38
+ const getJwt = (force) => {
39
+ const now = clock.now();
40
+ if (!force && cachedJwt && now - cachedJwt.issuedAtMs < JWT_TTL_MS) return cachedJwt.token;
41
+ cachedJwt = { token: signJwt(), issuedAtMs: now };
42
+ return cachedJwt.token;
43
+ };
44
+
45
+ const dropSession = (env, client) => {
46
+ const current = sessions.get(env);
47
+ if (current?.client === client) sessions.delete(env);
48
+ try { client.close(); } catch { /* session already gone */ }
49
+ };
50
+
51
+ const getSession = (env) => {
52
+ const existing = sessions.get(env);
53
+ if (existing?.client && !existing.client.closed && existing.client.destroyed !== true) {
54
+ if (clock.now() - existing.createdAt < sessionTtlMs) return existing.client;
55
+ dropSession(env, existing.client);
56
+ }
57
+ const client = connect(APNS_HOST[env]);
58
+ client.on('error', () => dropSession(env, client));
59
+ client.on('close', () => { if (sessions.get(env)?.client === client) sessions.delete(env); });
60
+ sessions.set(env, { client, createdAt: clock.now() });
61
+ return client;
62
+ };
63
+
64
+ const dispatch = (input, forceJwt) => new Promise((resolve) => {
65
+ const jwt = getJwt(forceJwt);
66
+ let client;
67
+ try { client = getSession(input.env); } catch { resolve({ ok: false }); return; }
68
+ const liveActivity = input.pushType === 'liveactivity';
69
+ const headers = {
70
+ ':method': 'POST',
71
+ ':path': `/3/device/${input.token}`,
72
+ authorization: `bearer ${jwt}`,
73
+ 'apns-topic': liveActivity ? `${bundleId}.push-type.liveactivity` : bundleId,
74
+ 'apns-push-type': liveActivity ? 'liveactivity' : 'alert',
75
+ 'apns-priority': '10',
76
+ };
77
+ if (!liveActivity && input.collapseId) headers['apns-collapse-id'] = input.collapseId;
78
+ let req;
79
+ try { req = client.request(headers); } catch {
80
+ dropSession(input.env, client);
81
+ resolve({ ok: false });
82
+ return;
83
+ }
84
+ let status = 0;
85
+ const chunks = [];
86
+ let responseBytes = 0;
87
+ let settled = false;
88
+ const finish = (result) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clock.clearTimeout(timer);
92
+ resolve(result);
93
+ };
94
+ const timer = clock.setTimeout(() => {
95
+ try { req.close(); } catch { /* ignore */ }
96
+ finish({ ok: false });
97
+ }, options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS);
98
+ req.on('response', (responseHeaders) => { status = Number(responseHeaders[':status']) || 0; });
99
+ req.on('data', (chunk) => {
100
+ if (responseBytes >= MAX_RESPONSE_BYTES) return;
101
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
102
+ const take = buf.length > MAX_RESPONSE_BYTES - responseBytes ? buf.subarray(0, MAX_RESPONSE_BYTES - responseBytes) : buf;
103
+ chunks.push(take);
104
+ responseBytes += take.length;
105
+ });
106
+ req.on('end', () => {
107
+ if (status === 200) { finish({ ok: true }); return; }
108
+ let reason = '';
109
+ try { reason = JSON.parse(Buffer.concat(chunks, responseBytes).toString('utf8'))?.reason || ''; } catch { /* non-JSON */ }
110
+ if (reason === 'ExpiredProviderToken') { finish({ ok: false, expired: true }); return; }
111
+ if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) { finish({ ok: false, drop: true }); return; }
112
+ finish({ ok: false });
113
+ });
114
+ req.on('error', () => {
115
+ if (!settled) dropSession(input.env, client);
116
+ finish({ ok: false });
117
+ });
118
+ req.end(JSON.stringify(input.payload));
119
+ });
120
+
121
+ return {
122
+ async send(input) {
123
+ const first = await dispatch(input, false);
124
+ if (first.expired) {
125
+ const retry = await dispatch(input, true);
126
+ return { ok: retry.ok === true, drop: retry.drop === true ? true : undefined };
127
+ }
128
+ return { ok: first.ok === true, drop: first.drop === true ? true : undefined };
129
+ },
130
+ close() {
131
+ for (const [env, entry] of sessions) {
132
+ sessions.delete(env);
133
+ try { entry.client.close(); } catch { /* ignore */ }
134
+ }
135
+ cachedJwt = null;
136
+ },
137
+ };
138
+ };
@@ -0,0 +1,73 @@
1
+ import { startPushRelayServer } from './server.js';
2
+ import { buildPushRelayConfig, fail, formatPushRelayUrl } from './config.js';
3
+
4
+ export { buildPushRelayConfig } from './config.js';
5
+
6
+ export const parsePushRelayArgs = (argv = []) => {
7
+ const parsed = {};
8
+ const values = new Map([['--host', 'host'], ['--port', 'port']]);
9
+ for (let index = 0; index < argv.length; index += 1) {
10
+ const arg = argv[index];
11
+ if (values.has(arg)) {
12
+ const value = argv[++index];
13
+ if (!value || value.startsWith('--')) fail(arg);
14
+ parsed[values.get(arg)] = value;
15
+ continue;
16
+ }
17
+ if (arg === '--trust-proxy') { parsed.trustProxy = true; continue; }
18
+ if (arg === '--no-trust-proxy') { parsed.trustProxy = false; continue; }
19
+ if (arg === '--json') { parsed.json = true; continue; }
20
+ if (arg === '--quiet' || arg === '-q') { parsed.quiet = true; continue; }
21
+ if (arg === '--help' || arg === '-h') { parsed.help = true; continue; }
22
+ if (arg === '--version' || arg === '-v') { parsed.version = true; continue; }
23
+ fail(arg);
24
+ }
25
+ return parsed;
26
+ };
27
+
28
+ const helpText = 'Usage: openchamber-push-relay [--host HOST] [--port PORT] [--trust-proxy] [--json] [--quiet]\nEnable --trust-proxy only when public ingress reaches this relay through a trusted reverse proxy.\n';
29
+ const writeJson = (stdout, payload) => stdout.write(`${JSON.stringify(payload)}\n`);
30
+
31
+ export const runPushRelayCli = async (argv, dependencies = {}) => {
32
+ const processLike = dependencies.process ?? process;
33
+ const stdout = dependencies.stdout ?? process.stdout;
34
+ const stderr = dependencies.stderr ?? process.stderr;
35
+ const version = dependencies.version ?? '0.0.0';
36
+ let parsed;
37
+ try { parsed = parsePushRelayArgs(argv ?? processLike.argv?.slice(2) ?? []); } catch (error) {
38
+ const json = (argv ?? processLike.argv?.slice(2) ?? []).includes('--json');
39
+ if (json) writeJson(stdout, { status: 'error', error: error.message }); else stderr.write(`${error.message}\n`);
40
+ processLike.exitCode = 1; return 1;
41
+ }
42
+ const json = parsed.json;
43
+ const respond = (payload, error = false, essential = false) => {
44
+ if (json) writeJson(stdout, payload);
45
+ else if (payload.message && (essential || !parsed.quiet || error)) (error ? stderr : stdout).write(`${payload.message}\n`);
46
+ };
47
+ if (parsed.help) { respond(json ? { status: 'ok', help: helpText.trim() } : { message: helpText.trim() }, false, true); return 0; }
48
+ if (parsed.version) { respond(json ? { status: 'ok', version } : { message: version }, false, true); return 0; }
49
+ let config;
50
+ try { config = buildPushRelayConfig(parsed, processLike.env ?? {}); } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
51
+ try {
52
+ const relay = await (dependencies.start ?? startPushRelayServer)(config);
53
+ const port = relay.address?.()?.port ?? config.port;
54
+ const url = formatPushRelayUrl(config.host, port);
55
+ respond(json ? { status: 'ok', url, host: config.host, port } : { message: `Push relay listening at ${url}` });
56
+ let stopping = false;
57
+ const stop = async () => {
58
+ if (stopping) return Promise.resolve();
59
+ stopping = true;
60
+ processLike.off?.('SIGINT', stop); processLike.off?.('SIGTERM', stop);
61
+ try {
62
+ await relay.stop();
63
+ processLike.exit?.(0);
64
+ } catch {
65
+ processLike.exitCode = 1;
66
+ if (json) writeJson(stderr, { status: 'error', error: 'Push relay stop failed' }); else stderr.write('Push relay stop failed\n');
67
+ processLike.exit?.(1);
68
+ }
69
+ };
70
+ processLike.on?.('SIGINT', stop); processLike.on?.('SIGTERM', stop);
71
+ return 0;
72
+ } catch (error) { respond({ status: 'error', error: error.message, message: error.message }, true); processLike.exitCode = 1; return 1; }
73
+ };
@@ -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,145 @@
1
+ import fs from 'node:fs';
2
+ import { isIP } from 'node:net';
3
+
4
+ export const DEFAULT_HOST = '127.0.0.1';
5
+ export const DEFAULT_PORT = 8788;
6
+ export const DEFAULT_DATABASE_PATH = './data/push-relay.sqlite';
7
+ export const DEFAULT_BUNDLE_ID = 'com.yee94.openchamber';
8
+ export const ENV_PREFIX = 'OPENCHAMBER_PUSH_RELAY_';
9
+ export const DEFAULT_LIMITS = {
10
+ timestampSkewMs: 300_000,
11
+ replayMs: 600_000,
12
+ maxReplayEntries: 10_000,
13
+ registerLimitPerMinute: 60,
14
+ sendLimitPerMinute: 60,
15
+ serverSendLimitPerMinute: 120,
16
+ maxTokens: 100_000,
17
+ maxInFlight: 64,
18
+ maxRateLimitEntries: 10_000,
19
+ jsonBodyBytes: 16 * 1024,
20
+ };
21
+
22
+ const LIMIT_KEYS = ['timestampSkewMs', 'replayMs', 'maxReplayEntries', 'registerLimitPerMinute', 'sendLimitPerMinute', 'serverSendLimitPerMinute', 'maxTokens', 'maxInFlight'];
23
+
24
+ export const resolvePushRelayClientIp = (request, trustProxy = false) => {
25
+ const remoteAddress = request.socket.remoteAddress ?? 'unknown';
26
+ if (!trustProxy) return remoteAddress;
27
+ const forwarded = request.headers['x-forwarded-for'];
28
+ const candidate = typeof forwarded === 'string' && !forwarded.includes(',') ? forwarded.trim() : '';
29
+ return isIP(candidate) ? candidate : remoteAddress;
30
+ };
31
+
32
+ export const formatPushRelayUrl = (host, port) => `http://${isIP(host) === 6 ? `[${host}]` : host}:${port}`;
33
+
34
+ const upperSnake = (key) => key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
35
+ export const envName = (key) => `${ENV_PREFIX}${upperSnake(key)}`;
36
+ export const fail = (name) => { throw new Error(`Invalid ${name}`); };
37
+
38
+ export const positive = (name, value) => {
39
+ if (!/^[1-9][0-9]*$/.test(String(value))) fail(name);
40
+ const number = Number(value);
41
+ if (!Number.isSafeInteger(number)) fail(name);
42
+ return number;
43
+ };
44
+
45
+ export const bool = (name, value) => {
46
+ if (value === undefined) return false;
47
+ if (value === 'true' || value === '1' || value === true) return true;
48
+ if (value === 'false' || value === '0' || value === false) return false;
49
+ fail(name);
50
+ };
51
+
52
+ export const validHost = (name, value) => {
53
+ if (typeof value !== 'string' || value.trim() !== value || value.length === 0 || value.includes('/') || value.includes('\\') || value.includes('@') || value.includes(':') && !isIP(value) || (!isIP(value) && !/^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/.test(value))) fail(name);
54
+ return value;
55
+ };
56
+
57
+ export const validDatabasePath = (name, value) => {
58
+ if (typeof value !== 'string' || value.trim() !== value || value.length === 0 || value.includes('\0')) fail(name);
59
+ return value;
60
+ };
61
+
62
+ export const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : '');
63
+
64
+ export const validatePushRelayLimits = (limits) => {
65
+ if (limits.replayMs < limits.timestampSkewMs * 2 || limits.maxReplayEntries < 1) throw new RangeError('invalid replay limits');
66
+ if (limits.maxTokens < 1 || limits.maxInFlight < 1 || limits.registerLimitPerMinute < 1 || limits.sendLimitPerMinute < 1 || limits.serverSendLimitPerMinute < 1) {
67
+ throw new RangeError('invalid push limits');
68
+ }
69
+ return limits;
70
+ };
71
+
72
+ export const normalizePushRelayOptions = (options = {}) => {
73
+ const limits = validatePushRelayLimits({ ...DEFAULT_LIMITS, ...options.limits });
74
+ const host = options.host ?? DEFAULT_HOST;
75
+ validHost('host', host);
76
+ const port = options.port ?? DEFAULT_PORT;
77
+ if (!Number.isSafeInteger(port) || port < 0 || port > 65535) fail('port');
78
+ const databasePath = options.databasePath ?? DEFAULT_DATABASE_PATH;
79
+ validDatabasePath('databasePath', databasePath);
80
+ if (!options.apnsProvider) {
81
+ const apns = options.apns ?? {};
82
+ if (!apns.keyId || !apns.teamId || !apns.p8 || !apns.bundleId) throw new Error('Invalid APNs configuration');
83
+ }
84
+ return {
85
+ host,
86
+ port,
87
+ trustProxy: Boolean(options.trustProxy),
88
+ databasePath,
89
+ limits,
90
+ apns: options.apns,
91
+ apnsProvider: options.apnsProvider,
92
+ clock: options.clock,
93
+ logger: options.logger,
94
+ resolveClientIp: options.resolveClientIp,
95
+ store: options.store,
96
+ http2: options.http2,
97
+ };
98
+ };
99
+
100
+ const envValue = (env, key) => {
101
+ const value = env[envName(key)];
102
+ return typeof value === 'string' && value.trim().length === 0 ? undefined : value;
103
+ };
104
+
105
+ const loadP8 = (env) => {
106
+ const inline = envValue(env, 'apnsP8') ?? env[`${ENV_PREFIX}APNS_P8`];
107
+ if (typeof inline === 'string' && inline.trim().length > 0) return normalizePem(inline);
108
+ const p8Path = envValue(env, 'apnsP8Path') ?? env[`${ENV_PREFIX}APNS_P8_PATH`];
109
+ if (typeof p8Path === 'string' && p8Path.trim().length > 0) {
110
+ try { return normalizePem(fs.readFileSync(p8Path.trim(), 'utf8')); } catch { fail(`${ENV_PREFIX}APNS_P8_PATH`); }
111
+ }
112
+ fail(`${ENV_PREFIX}APNS_P8`);
113
+ };
114
+
115
+ export const buildPushRelayConfig = (parsed = {}, env = process.env) => {
116
+ const pick = (key, fallback) => parsed[key] ?? envValue(env, key) ?? fallback;
117
+ const portValue = pick('port', DEFAULT_PORT);
118
+ const port = positive(parsed.port !== undefined ? '--port' : envName('port'), portValue);
119
+ if (port > 65535) fail(parsed.port !== undefined ? '--port' : envName('port'));
120
+ const host = validHost(parsed.host !== undefined ? '--host' : envName('host'), pick('host', DEFAULT_HOST));
121
+ const databasePath = validDatabasePath(envName('databasePath'), pick('databasePath', DEFAULT_DATABASE_PATH));
122
+ const limits = {};
123
+ for (const key of LIMIT_KEYS) {
124
+ const value = pick(key, undefined);
125
+ if (value !== undefined) limits[key] = positive(parsed[key] !== undefined ? `--${upperSnake(key).toLowerCase().replaceAll('_', '-')}` : envName(key), value);
126
+ }
127
+ const merged = { ...DEFAULT_LIMITS, ...limits };
128
+ if (merged.replayMs < merged.timestampSkewMs * 2) fail(parsed.replayMs !== undefined ? '--replay-ms' : envName('replayMs'));
129
+ validatePushRelayLimits(merged);
130
+ const keyId = pick('apnsKeyId', env[`${ENV_PREFIX}APNS_KEY_ID`]);
131
+ const teamId = pick('apnsTeamId', env[`${ENV_PREFIX}APNS_TEAM_ID`]);
132
+ const bundleId = pick('apnsBundleId', env[`${ENV_PREFIX}APNS_BUNDLE_ID`]) || DEFAULT_BUNDLE_ID;
133
+ if (typeof keyId !== 'string' || keyId.trim().length === 0) fail(`${ENV_PREFIX}APNS_KEY_ID`);
134
+ if (typeof teamId !== 'string' || teamId.trim().length === 0) fail(`${ENV_PREFIX}APNS_TEAM_ID`);
135
+ const p8 = loadP8(env);
136
+ if (!p8) fail(`${ENV_PREFIX}APNS_P8`);
137
+ return {
138
+ host,
139
+ port,
140
+ databasePath,
141
+ trustProxy: parsed.trustProxy ?? bool(envName('trustProxy'), envValue(env, 'trustProxy')),
142
+ limits: merged,
143
+ apns: { keyId: keyId.trim(), teamId: teamId.trim(), p8, bundleId: String(bundleId).trim() },
144
+ };
145
+ };
@@ -0,0 +1,34 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ const B64 = /^[A-Za-z0-9_-]+$/;
4
+
5
+ export const canonicalPublicJwkString = (jwk) => JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
6
+
7
+ export const b64urlExact = (value, length) => {
8
+ if (typeof value !== 'string' || !B64.test(value)) return null;
9
+ try {
10
+ const decoded = Buffer.from(value, 'base64url');
11
+ return decoded.length === length && decoded.toString('base64url') === value ? decoded : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ };
16
+
17
+ export const parsePublicJwk = (value) => {
18
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
19
+ const keys = Object.keys(value);
20
+ if (keys.length !== 4 || !keys.includes('crv') || !keys.includes('kty') || !keys.includes('x') || !keys.includes('y')) return null;
21
+ if (value.kty !== 'EC' || value.crv !== 'P-256' || !b64urlExact(value.x, 32) || !b64urlExact(value.y, 32)) return null;
22
+ return { crv: value.crv, kty: value.kty, x: value.x, y: value.y };
23
+ };
24
+
25
+ export const deriveServerId = (jwk) => crypto.createHash('sha256').update(canonicalPublicJwkString(jwk)).digest('base64url');
26
+
27
+ export const verifyP1363 = (message, jwk, signature) => {
28
+ try {
29
+ const key = crypto.createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, format: 'jwk' });
30
+ return crypto.verify('SHA256', Buffer.from(message), { key, dsaEncoding: 'ieee-p1363' }, signature);
31
+ } catch {
32
+ return false;
33
+ }
34
+ };
@@ -0,0 +1,145 @@
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
+ clear() { replay.clear(); },
19
+ get size() { return replay.size; },
20
+ };
21
+ };
22
+
23
+ export const createSlidingWindowLimiter = ({ windowMs, maxCount, maxEntries, now }) => {
24
+ const entries = new Map();
25
+ const pruneKey = (key, cutoff) => {
26
+ const stamps = entries.get(key);
27
+ if (!stamps) return null;
28
+ let index = 0;
29
+ while (index < stamps.length && stamps[index] <= cutoff) index += 1;
30
+ if (index) stamps.splice(0, index);
31
+ if (stamps.length === 0) { entries.delete(key); return null; }
32
+ return stamps;
33
+ };
34
+ const purge = (cutoff) => {
35
+ for (const key of [...entries.keys()]) pruneKey(key, cutoff);
36
+ };
37
+ return {
38
+ allow(key) {
39
+ const ts = now();
40
+ const cutoff = ts - windowMs;
41
+ let stamps = pruneKey(key, cutoff);
42
+ if (!stamps) {
43
+ if (entries.size >= maxEntries) {
44
+ purge(cutoff);
45
+ if (entries.size >= maxEntries) return false;
46
+ }
47
+ stamps = [];
48
+ entries.set(key, stamps);
49
+ }
50
+ if (stamps.length >= maxCount) return false;
51
+ stamps.push(ts);
52
+ return true;
53
+ },
54
+ clear() { entries.clear(); },
55
+ };
56
+ };
57
+
58
+ export const createInFlightGate = (maxInFlight) => {
59
+ const maxWaiters = maxInFlight;
60
+ let generation = 1;
61
+ let active = 0;
62
+ let accepting = true;
63
+ const waiters = [];
64
+ const idleWaiters = [];
65
+ const notifyIdle = () => {
66
+ if (active !== 0) return;
67
+ while (idleWaiters.length) idleWaiters.shift()();
68
+ };
69
+ return {
70
+ acquire() {
71
+ if (!accepting) return Promise.resolve(false);
72
+ if (active < maxInFlight) {
73
+ active += 1;
74
+ return Promise.resolve(generation);
75
+ }
76
+ if (waiters.length >= maxWaiters) return Promise.resolve(false);
77
+ const gen = generation;
78
+ return new Promise((resolve) => {
79
+ waiters.push(() => {
80
+ if (!accepting || generation !== gen) { resolve(false); return; }
81
+ resolve(generation);
82
+ });
83
+ });
84
+ },
85
+ release(ticket) {
86
+ if (ticket !== generation) return;
87
+ if (accepting) {
88
+ const next = waiters.shift();
89
+ if (next) { next(); return; }
90
+ } else {
91
+ while (waiters.length) waiters.shift()();
92
+ }
93
+ active = Math.max(0, active - 1);
94
+ notifyIdle();
95
+ },
96
+ rejectWaiters() {
97
+ accepting = false;
98
+ while (waiters.length) waiters.shift()();
99
+ },
100
+ reset() {
101
+ generation += 1;
102
+ accepting = true;
103
+ active = 0;
104
+ waiters.length = 0;
105
+ while (idleWaiters.length) idleWaiters.shift()();
106
+ },
107
+ whenIdle() {
108
+ if (active === 0) return Promise.resolve();
109
+ return new Promise((resolve) => { idleWaiters.push(resolve); });
110
+ },
111
+ get active() { return active; },
112
+ get waiting() { return waiters.length; },
113
+ };
114
+ };
115
+
116
+ export const createWorkTracker = () => {
117
+ let generation = 1;
118
+ let active = 0;
119
+ const idleWaiters = [];
120
+ const notifyIdle = () => {
121
+ if (active !== 0) return;
122
+ while (idleWaiters.length) idleWaiters.shift()();
123
+ };
124
+ return {
125
+ begin() {
126
+ active += 1;
127
+ const gen = generation;
128
+ return () => {
129
+ if (gen !== generation) return;
130
+ active = Math.max(0, active - 1);
131
+ notifyIdle();
132
+ };
133
+ },
134
+ whenIdle() {
135
+ if (active === 0) return Promise.resolve();
136
+ return new Promise((resolve) => { idleWaiters.push(resolve); });
137
+ },
138
+ reset() {
139
+ generation += 1;
140
+ active = 0;
141
+ while (idleWaiters.length) idleWaiters.shift()();
142
+ },
143
+ get active() { return active; },
144
+ };
145
+ };