@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.
- package/DOCUMENTATION.md +348 -0
- package/README.md +460 -2
- package/bin/cli-entry.js +55 -0
- package/bin/yuri-push-relay.js +11 -0
- package/bin/yuri-relay.js +11 -0
- package/package.json +54 -4
- package/src/cli.js +135 -0
- package/src/index.d.ts +95 -0
- package/src/index.js +378 -0
- package/src/push/apns.js +138 -0
- package/src/push/cached-store.js +121 -0
- package/src/push/cli.js +73 -0
- package/src/push/combined.js +34 -0
- package/src/push/config.js +145 -0
- package/src/push/crypto.js +34 -0
- package/src/push/guard.js +146 -0
- package/src/push/handler.js +314 -0
- package/src/push/index.d.ts +113 -0
- package/src/push/index.js +3 -0
- package/src/push/schema.js +230 -0
- package/src/push/server.js +104 -0
- package/src/push/store-worker.js +15 -0
- package/src/push/store.js +60 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { b64urlExact, parsePublicJwk } from './crypto.js';
|
|
2
|
+
|
|
3
|
+
export const IOS_TOKEN = /^[0-9a-fA-F]{64}$/;
|
|
4
|
+
export const JSON_BODY_BYTES = 16 * 1024;
|
|
5
|
+
export const APNS_PAYLOAD_BYTES = 4096;
|
|
6
|
+
export const MAX_TOKENS_PER_REQUEST = 100;
|
|
7
|
+
export const MAX_TITLE_BYTES = 256;
|
|
8
|
+
export const MAX_BODY_BYTES = 1024;
|
|
9
|
+
export const MAX_COLLAPSE_ID_BYTES = 64;
|
|
10
|
+
export const MAX_DATA_ENTRIES = 16;
|
|
11
|
+
export const MAX_DATA_KEY_BYTES = 64;
|
|
12
|
+
export const MAX_DATA_VALUE_BYTES = 256;
|
|
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([
|
|
18
|
+
'status',
|
|
19
|
+
'eventVersion',
|
|
20
|
+
'updatedAt',
|
|
21
|
+
'endedAt',
|
|
22
|
+
'title',
|
|
23
|
+
'workingCount',
|
|
24
|
+
'items',
|
|
25
|
+
]);
|
|
26
|
+
const MAX_LIVE_ACTIVITY_ITEMS = 4;
|
|
27
|
+
const MAX_LIVE_ACTIVITY_ITEM_TITLE = 80;
|
|
28
|
+
const MAX_LIVE_ACTIVITY_ITEM_SESSION = 128;
|
|
29
|
+
|
|
30
|
+
const bytes = (value) => Buffer.byteLength(value, 'utf8');
|
|
31
|
+
const isSafeInt = (value) => typeof value === 'number' && Number.isSafeInteger(value);
|
|
32
|
+
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
33
|
+
|
|
34
|
+
export const isIosToken = (value) => typeof value === 'string' && IOS_TOKEN.test(value);
|
|
35
|
+
|
|
36
|
+
const parseTs = (value) => (isSafeInt(value) ? value : null);
|
|
37
|
+
const parseSig = (value) => b64urlExact(value, 64);
|
|
38
|
+
|
|
39
|
+
const parseData = (value) => {
|
|
40
|
+
if (value === undefined) return {};
|
|
41
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
42
|
+
const entries = Object.entries(value);
|
|
43
|
+
if (entries.length > MAX_DATA_ENTRIES) return null;
|
|
44
|
+
let total = 0;
|
|
45
|
+
const data = {};
|
|
46
|
+
for (const [key, entry] of entries) {
|
|
47
|
+
if (typeof key !== 'string' || key.length === 0 || key === 'aps' || typeof entry !== 'string') return null;
|
|
48
|
+
const keyBytes = bytes(key);
|
|
49
|
+
const valueBytes = bytes(entry);
|
|
50
|
+
if (keyBytes > MAX_DATA_KEY_BYTES || valueBytes > MAX_DATA_VALUE_BYTES) return null;
|
|
51
|
+
total += keyBytes + valueBytes;
|
|
52
|
+
if (total > MAX_DATA_TOTAL_BYTES) return null;
|
|
53
|
+
data[key] = entry;
|
|
54
|
+
}
|
|
55
|
+
return data;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const buildApnsPayload = ({ title, body, badge, collapseId, data }) => {
|
|
59
|
+
const alert = { title };
|
|
60
|
+
if (body) alert.body = body;
|
|
61
|
+
const aps = { alert, sound: 'default', 'mutable-content': 1 };
|
|
62
|
+
if (badge !== undefined) aps.badge = badge;
|
|
63
|
+
if (collapseId) aps['thread-id'] = collapseId;
|
|
64
|
+
return Object.keys(data).length > 0 ? { aps, ...data } : { aps };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const buildLiveActivityPayload = ({ event, contentState, dismissalDate, staleDate, timestamp }) => {
|
|
68
|
+
const aps = { timestamp, event, 'content-state': contentState };
|
|
69
|
+
if (event === 'end' && dismissalDate !== undefined) aps['dismissal-date'] = dismissalDate;
|
|
70
|
+
if (event === 'update' && staleDate !== undefined) aps['stale-date'] = staleDate;
|
|
71
|
+
return { aps };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const uniqueTokens = (tokens) => {
|
|
75
|
+
const unique = [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const token of tokens) {
|
|
78
|
+
if (seen.has(token)) continue;
|
|
79
|
+
seen.add(token);
|
|
80
|
+
unique.push(token);
|
|
81
|
+
}
|
|
82
|
+
return unique;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const parseLiveActivityItems = (value) => {
|
|
86
|
+
if (value === undefined) return undefined;
|
|
87
|
+
if (!Array.isArray(value) || value.length > MAX_LIVE_ACTIVITY_ITEMS) return null;
|
|
88
|
+
const items = [];
|
|
89
|
+
for (const raw of value) {
|
|
90
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
91
|
+
const sessionID = typeof raw.sessionID === 'string'
|
|
92
|
+
? raw.sessionID
|
|
93
|
+
: (typeof raw.sessionId === 'string' ? raw.sessionId : '');
|
|
94
|
+
if (!sessionID || sessionID.length > MAX_LIVE_ACTIVITY_ITEM_SESSION) return null;
|
|
95
|
+
if (typeof raw.status !== 'string' || !LIVE_ACTIVITY_STATUSES.has(raw.status)) return null;
|
|
96
|
+
if (!isFiniteNumber(raw.startedAt)) return null;
|
|
97
|
+
const title = typeof raw.title === 'string' ? raw.title.slice(0, MAX_LIVE_ACTIVITY_ITEM_TITLE) : '';
|
|
98
|
+
const item = { sessionID, title, status: raw.status, startedAt: raw.startedAt };
|
|
99
|
+
if (raw.endedAt !== undefined) {
|
|
100
|
+
if (!isFiniteNumber(raw.endedAt)) return null;
|
|
101
|
+
item.endedAt = raw.endedAt;
|
|
102
|
+
}
|
|
103
|
+
items.push(item);
|
|
104
|
+
}
|
|
105
|
+
return items;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const parseContentState = (value, event) => {
|
|
109
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
110
|
+
const keys = Object.keys(value);
|
|
111
|
+
if (keys.length === 0 || keys.some((key) => !LIVE_ACTIVITY_CONTENT_KEYS.has(key))) return null;
|
|
112
|
+
if (typeof value.status !== 'string' || !LIVE_ACTIVITY_STATUSES.has(value.status)) return null;
|
|
113
|
+
if (!isSafeInt(value.eventVersion) || !isFiniteNumber(value.updatedAt)) return null;
|
|
114
|
+
if (event === 'end' && !isFiniteNumber(value.endedAt)) return null;
|
|
115
|
+
if (value.endedAt !== undefined && !isFiniteNumber(value.endedAt)) return null;
|
|
116
|
+
if (value.title !== undefined && (typeof value.title !== 'string' || value.title.length > MAX_LIVE_ACTIVITY_ITEM_TITLE)) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (value.workingCount !== undefined && (!isSafeInt(value.workingCount) || value.workingCount < 0)) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const items = parseLiveActivityItems(value.items);
|
|
123
|
+
if (items === null) return null;
|
|
124
|
+
const contentState = { status: value.status, eventVersion: value.eventVersion, updatedAt: value.updatedAt };
|
|
125
|
+
if (value.endedAt !== undefined) contentState.endedAt = value.endedAt;
|
|
126
|
+
if (value.title !== undefined) contentState.title = value.title;
|
|
127
|
+
if (value.workingCount !== undefined) contentState.workingCount = value.workingCount;
|
|
128
|
+
if (items !== undefined) contentState.items = items;
|
|
129
|
+
return contentState;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const validateRegisterBody = (body) => {
|
|
133
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
134
|
+
if (body.platform === 'android') return { error: 'unsupported_platform' };
|
|
135
|
+
if (body.platform !== 'ios') return { error: 'invalid_request' };
|
|
136
|
+
if (!isIosToken(body.token)) return { error: 'invalid_request' };
|
|
137
|
+
const jwk = parsePublicJwk(body.publicKeyJwk);
|
|
138
|
+
const ts = parseTs(body.ts);
|
|
139
|
+
const sig = parseSig(body.sig);
|
|
140
|
+
if (!jwk || ts === null || !sig) return { error: 'invalid_request' };
|
|
141
|
+
return { value: { token: body.token, platform: 'ios', publicKeyJwk: jwk, ts, sig } };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const validateLiveActivityRegisterBody = (body) => {
|
|
145
|
+
const parsed = validateRegisterBody(body);
|
|
146
|
+
if (parsed.error) return parsed;
|
|
147
|
+
if (body.kind !== LIVE_ACTIVITY_KIND) return { error: 'invalid_request' };
|
|
148
|
+
return { value: { ...parsed.value, kind: LIVE_ACTIVITY_KIND } };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const validateSendBody = (body) => {
|
|
152
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
153
|
+
const tokens = body.tokens;
|
|
154
|
+
if (!Array.isArray(tokens) || tokens.length < 1 || tokens.length > MAX_TOKENS_PER_REQUEST) return { error: 'invalid_request' };
|
|
155
|
+
if (tokens.some((token) => !isIosToken(token))) return { error: 'invalid_request' };
|
|
156
|
+
if (typeof body.title !== 'string' || body.title.length === 0 || bytes(body.title) > MAX_TITLE_BYTES) return { error: 'invalid_request' };
|
|
157
|
+
if (body.body !== undefined && (typeof body.body !== 'string' || bytes(body.body) > MAX_BODY_BYTES)) return { error: 'invalid_request' };
|
|
158
|
+
if (body.badge !== undefined && (!isSafeInt(body.badge) || body.badge < 0)) return { error: 'invalid_request' };
|
|
159
|
+
if (body.collapseId !== undefined && (typeof body.collapseId !== 'string' || bytes(body.collapseId) > MAX_COLLAPSE_ID_BYTES)) return { error: 'invalid_request' };
|
|
160
|
+
if (body.env !== undefined && body.env !== 'production' && body.env !== 'sandbox') return { error: 'invalid_request' };
|
|
161
|
+
const jwk = parsePublicJwk(body.publicKeyJwk);
|
|
162
|
+
const ts = parseTs(body.ts);
|
|
163
|
+
const sig = parseSig(body.sig);
|
|
164
|
+
const data = parseData(body.data);
|
|
165
|
+
if (!jwk || ts === null || !sig || !data) return { error: 'invalid_request' };
|
|
166
|
+
const unique = uniqueTokens(tokens);
|
|
167
|
+
const payload = buildApnsPayload({
|
|
168
|
+
title: body.title,
|
|
169
|
+
body: body.body ?? '',
|
|
170
|
+
badge: body.badge,
|
|
171
|
+
collapseId: body.collapseId,
|
|
172
|
+
data,
|
|
173
|
+
});
|
|
174
|
+
if (bytes(JSON.stringify(payload)) > APNS_PAYLOAD_BYTES) return { error: 'invalid_request' };
|
|
175
|
+
return {
|
|
176
|
+
value: {
|
|
177
|
+
tokens,
|
|
178
|
+
uniqueTokens: unique,
|
|
179
|
+
title: body.title,
|
|
180
|
+
body: typeof body.body === 'string' ? body.body : '',
|
|
181
|
+
badge: body.badge,
|
|
182
|
+
collapseId: body.collapseId || undefined,
|
|
183
|
+
env: body.env === 'production' ? 'production' : 'sandbox',
|
|
184
|
+
publicKeyJwk: jwk,
|
|
185
|
+
ts,
|
|
186
|
+
sig,
|
|
187
|
+
payload,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const validateLiveActivityBody = (body) => {
|
|
193
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'invalid_request' };
|
|
194
|
+
const tokens = body.tokens;
|
|
195
|
+
if (!Array.isArray(tokens) || tokens.length < 1 || tokens.length > MAX_TOKENS_PER_REQUEST) return { error: 'invalid_request' };
|
|
196
|
+
if (tokens.some((token) => !isIosToken(token))) return { error: 'invalid_request' };
|
|
197
|
+
if (typeof body.event !== 'string' || !LIVE_ACTIVITY_EVENTS.has(body.event)) return { error: 'invalid_request' };
|
|
198
|
+
const contentState = parseContentState(body.contentState, body.event);
|
|
199
|
+
if (!contentState) return { error: 'invalid_request' };
|
|
200
|
+
if (body.dismissalDate !== undefined && !isSafeInt(body.dismissalDate)) return { error: 'invalid_request' };
|
|
201
|
+
if (body.staleDate !== undefined && !isSafeInt(body.staleDate)) return { error: 'invalid_request' };
|
|
202
|
+
if (body.env !== undefined && body.env !== 'production' && body.env !== 'sandbox') return { error: 'invalid_request' };
|
|
203
|
+
const jwk = parsePublicJwk(body.publicKeyJwk);
|
|
204
|
+
const ts = parseTs(body.ts);
|
|
205
|
+
const sig = parseSig(body.sig);
|
|
206
|
+
if (!jwk || ts === null || !sig) return { error: 'invalid_request' };
|
|
207
|
+
const payload = buildLiveActivityPayload({
|
|
208
|
+
event: body.event,
|
|
209
|
+
contentState,
|
|
210
|
+
dismissalDate: body.dismissalDate,
|
|
211
|
+
staleDate: body.staleDate,
|
|
212
|
+
timestamp: 1_000_000_000,
|
|
213
|
+
});
|
|
214
|
+
if (bytes(JSON.stringify(payload)) > APNS_PAYLOAD_BYTES) return { error: 'invalid_request' };
|
|
215
|
+
return {
|
|
216
|
+
value: {
|
|
217
|
+
tokens,
|
|
218
|
+
uniqueTokens: uniqueTokens(tokens),
|
|
219
|
+
event: body.event,
|
|
220
|
+
contentState,
|
|
221
|
+
dismissalDate: body.dismissalDate,
|
|
222
|
+
staleDate: body.staleDate,
|
|
223
|
+
env: body.env === 'production' ? 'production' : 'sandbox',
|
|
224
|
+
publicKeyJwk: jwk,
|
|
225
|
+
ts,
|
|
226
|
+
sig,
|
|
227
|
+
payload,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
|
|
3
|
+
import { normalizePushRelayOptions, formatPushRelayUrl } from './config.js';
|
|
4
|
+
import { createPushRelayHandler } from './handler.js';
|
|
5
|
+
|
|
6
|
+
const STOP_DEADLINE_MS = 5_500;
|
|
7
|
+
|
|
8
|
+
export const createPushRelayServer = (options = {}) => {
|
|
9
|
+
const config = normalizePushRelayOptions(options);
|
|
10
|
+
const clock = { now: Date.now, setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, ...config.clock };
|
|
11
|
+
const handler = createPushRelayHandler({ ...options, claimHealthEndpoints: true });
|
|
12
|
+
let server = null; let startPromise = null; let stopPromise = null; let abortStart = null; let state = 'idle'; let generation = 0;
|
|
13
|
+
|
|
14
|
+
const start = () => {
|
|
15
|
+
if (state === 'running') return Promise.resolve();
|
|
16
|
+
if (state === 'stopping') return stopPromise.then(() => start());
|
|
17
|
+
if (startPromise) return startPromise;
|
|
18
|
+
state = 'starting';
|
|
19
|
+
const localGeneration = ++generation;
|
|
20
|
+
const localServer = http.createServer((request, response) => {
|
|
21
|
+
handler.handleRequest(request, response);
|
|
22
|
+
});
|
|
23
|
+
server = localServer;
|
|
24
|
+
startPromise = new Promise((resolve, rejectStart) => {
|
|
25
|
+
const failStart = (error) => {
|
|
26
|
+
if (localGeneration !== generation) return;
|
|
27
|
+
localServer.off('listening', ready);
|
|
28
|
+
cleanupStart();
|
|
29
|
+
rejectStart(error);
|
|
30
|
+
};
|
|
31
|
+
const ready = async () => {
|
|
32
|
+
localServer.off('error', failStart);
|
|
33
|
+
if (localGeneration !== generation || state !== 'starting') return;
|
|
34
|
+
try { await handler.activate(); } catch (error) { failStart(error); await handler.deactivate(); return; }
|
|
35
|
+
if (localGeneration !== generation || state !== 'starting') { await handler.deactivate(); return; }
|
|
36
|
+
state = 'running';
|
|
37
|
+
resolve();
|
|
38
|
+
};
|
|
39
|
+
const cleanupStart = () => {
|
|
40
|
+
localServer.close();
|
|
41
|
+
if (server === localServer) server = null;
|
|
42
|
+
state = 'stopped';
|
|
43
|
+
};
|
|
44
|
+
abortStart = () => {
|
|
45
|
+
if (state === 'starting') { cleanupStart(); rejectStart(new Error('push relay stopped during start')); }
|
|
46
|
+
};
|
|
47
|
+
localServer.once('error', failStart);
|
|
48
|
+
localServer.once('listening', ready);
|
|
49
|
+
localServer.listen(config.port, config.host);
|
|
50
|
+
}).finally(() => { startPromise = null; abortStart = null; });
|
|
51
|
+
return startPromise;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const stop = () => {
|
|
55
|
+
if (stopPromise) return stopPromise;
|
|
56
|
+
if (state === 'idle' || state === 'stopped') { state = 'stopped'; return Promise.resolve(); }
|
|
57
|
+
if (state === 'starting') abortStart?.();
|
|
58
|
+
state = 'stopping';
|
|
59
|
+
generation += 1;
|
|
60
|
+
const localServer = server;
|
|
61
|
+
stopPromise = Promise.resolve().then(async () => {
|
|
62
|
+
let deadlineTimer = null;
|
|
63
|
+
try {
|
|
64
|
+
const closed = new Promise((resolve) => {
|
|
65
|
+
if (!localServer) { resolve(); return; }
|
|
66
|
+
localServer.close(() => resolve());
|
|
67
|
+
try { localServer.closeIdleConnections?.(); } catch { /* ignore */ }
|
|
68
|
+
});
|
|
69
|
+
const deactivated = handler.deactivate();
|
|
70
|
+
const graceful = Promise.all([closed, deactivated]);
|
|
71
|
+
const deadline = new Promise((resolve) => {
|
|
72
|
+
deadlineTimer = clock.setTimeout(() => resolve('deadline'), STOP_DEADLINE_MS);
|
|
73
|
+
});
|
|
74
|
+
const winner = await Promise.race([graceful.then(() => 'graceful'), deadline]);
|
|
75
|
+
if (winner === 'deadline') {
|
|
76
|
+
try { localServer?.closeAllConnections?.(); } catch { /* ignore */ }
|
|
77
|
+
await deactivated;
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
if (deadlineTimer !== null) try { clock.clearTimeout(deadlineTimer); } catch { /* ignore */ }
|
|
81
|
+
}
|
|
82
|
+
if (server === localServer) { server = null; state = 'stopped'; }
|
|
83
|
+
stopPromise = null;
|
|
84
|
+
});
|
|
85
|
+
return stopPromise;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
start,
|
|
90
|
+
stop,
|
|
91
|
+
address: () => server?.address(),
|
|
92
|
+
get url() {
|
|
93
|
+
const address = server?.address();
|
|
94
|
+
return address && typeof address === 'object' ? formatPushRelayUrl(config.host, address.port) : null;
|
|
95
|
+
},
|
|
96
|
+
getSnapshot: () => ({ ...handler.getSnapshot(), state }),
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const startPushRelayServer = async (options) => {
|
|
101
|
+
const server = createPushRelayServer(options);
|
|
102
|
+
await server.start();
|
|
103
|
+
return server;
|
|
104
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { createTokenStore } from './store.js';
|
|
3
|
+
|
|
4
|
+
const store = createTokenStore(workerData.databasePath, { busyTimeoutMs: 250 });
|
|
5
|
+
parentPort.postMessage({ rows: store.entries(workerData.maxTokens) });
|
|
6
|
+
parentPort.on('message', ({ id, operation, args }) => {
|
|
7
|
+
try {
|
|
8
|
+
if (operation === 'upsert') store.upsert(...args);
|
|
9
|
+
else if (operation === 'delete') store.delete(...args);
|
|
10
|
+
else throw new Error('invalid operation');
|
|
11
|
+
parentPort.postMessage({ id, ok: true });
|
|
12
|
+
} catch {
|
|
13
|
+
parentPort.postMessage({ id, ok: false });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
|
|
5
|
+
const SCHEMA = `CREATE TABLE IF NOT EXISTS push_tokens (
|
|
6
|
+
token TEXT PRIMARY KEY,
|
|
7
|
+
server_id TEXT NOT NULL,
|
|
8
|
+
platform TEXT NOT NULL,
|
|
9
|
+
updated_at INTEGER NOT NULL
|
|
10
|
+
) STRICT`;
|
|
11
|
+
|
|
12
|
+
export const createTokenStore = (databasePath, { busyTimeoutMs = 5_000 } = {}) => {
|
|
13
|
+
if (typeof databasePath !== 'string' || databasePath.length === 0 || databasePath.includes('\0')) {
|
|
14
|
+
throw new RangeError('invalid database path');
|
|
15
|
+
}
|
|
16
|
+
if (databasePath !== ':memory:') {
|
|
17
|
+
fs.mkdirSync(path.dirname(path.resolve(databasePath)), { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
const db = new DatabaseSync(databasePath, { timeout: busyTimeoutMs });
|
|
20
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
21
|
+
db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
22
|
+
db.exec(SCHEMA);
|
|
23
|
+
const getStmt = db.prepare('SELECT server_id AS serverId, platform, updated_at AS updatedAt FROM push_tokens WHERE token = ?');
|
|
24
|
+
const upsertStmt = db.prepare(`INSERT INTO push_tokens (token, server_id, platform, updated_at) VALUES (?, ?, ?, ?)
|
|
25
|
+
ON CONFLICT(token) DO UPDATE SET server_id = excluded.server_id, platform = excluded.platform, updated_at = excluded.updated_at`);
|
|
26
|
+
const deleteStmt = db.prepare('DELETE FROM push_tokens WHERE token = ?');
|
|
27
|
+
const countStmt = db.prepare('SELECT COUNT(*) AS n FROM push_tokens');
|
|
28
|
+
let closed = false;
|
|
29
|
+
const assertOpen = () => { if (closed) throw new Error('token store closed'); };
|
|
30
|
+
return {
|
|
31
|
+
entries(maxTokens) {
|
|
32
|
+
assertOpen();
|
|
33
|
+
const rows = db.prepare('SELECT token, server_id AS serverId, platform, updated_at AS updatedAt FROM push_tokens LIMIT ?').all(maxTokens + 1);
|
|
34
|
+
if (rows.length > maxTokens) throw new Error('token capacity exceeded');
|
|
35
|
+
return rows;
|
|
36
|
+
},
|
|
37
|
+
get(token) {
|
|
38
|
+
assertOpen();
|
|
39
|
+
const row = getStmt.get(token);
|
|
40
|
+
return row ? { serverId: row.serverId, platform: row.platform, updatedAt: row.updatedAt } : null;
|
|
41
|
+
},
|
|
42
|
+
upsert(token, serverId, platform, updatedAt) {
|
|
43
|
+
assertOpen();
|
|
44
|
+
upsertStmt.run(token, serverId, platform, updatedAt);
|
|
45
|
+
},
|
|
46
|
+
delete(token) {
|
|
47
|
+
assertOpen();
|
|
48
|
+
deleteStmt.run(token);
|
|
49
|
+
},
|
|
50
|
+
count() {
|
|
51
|
+
assertOpen();
|
|
52
|
+
return Number(countStmt.get().n);
|
|
53
|
+
},
|
|
54
|
+
close() {
|
|
55
|
+
if (closed) return;
|
|
56
|
+
closed = true;
|
|
57
|
+
try { db.close(); } catch { /* already closed */ }
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|