easy-ping 0.7.0 → 0.8.1
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/README.md +4 -0
- package/dist/{adapter-BJ8Iejvs.d.cts → adapter-CV5Yt2c6.d.cts} +1 -1
- package/dist/{adapter-BJ8Iejvs.d.ts → adapter-CV5Yt2c6.d.ts} +1 -1
- package/dist/adapters/drizzle.d.cts +1 -1
- package/dist/adapters/drizzle.d.ts +1 -1
- package/dist/adapters/mongodb.d.cts +2 -2
- package/dist/adapters/mongodb.d.ts +2 -2
- package/dist/adapters/mysql.d.cts +2 -2
- package/dist/adapters/mysql.d.ts +2 -2
- package/dist/adapters/postgres.d.cts +2 -2
- package/dist/adapters/postgres.d.ts +2 -2
- package/dist/adapters/sqlite.d.cts +2 -2
- package/dist/adapters/sqlite.d.ts +2 -2
- package/dist/client.cjs +7 -2
- package/dist/client.cjs.map +1 -1
- package/dist/client.js +7 -2
- package/dist/client.js.map +1 -1
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/{plugin-Wwp1nvo0.d.cts → plugin-6S8tpH0n.d.cts} +1 -1
- package/dist/{plugin-CSLKKgXT.d.ts → plugin-C4fikLl7.d.ts} +1 -1
- package/dist/plugins/digests.d.cts +2 -2
- package/dist/plugins/digests.d.ts +2 -2
- package/dist/plugins/mobile-push.cjs +307 -0
- package/dist/plugins/mobile-push.cjs.map +1 -0
- package/dist/plugins/mobile-push.d.cts +117 -0
- package/dist/plugins/mobile-push.d.ts +117 -0
- package/dist/plugins/mobile-push.js +261 -0
- package/dist/plugins/mobile-push.js.map +1 -0
- package/dist/plugins/preferences-client.d.cts +2 -2
- package/dist/plugins/preferences-client.d.ts +2 -2
- package/dist/plugins/preferences-react.d.cts +1 -1
- package/dist/plugins/preferences-react.d.ts +1 -1
- package/dist/plugins/preferences.cjs +9 -1
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +2 -2
- package/dist/plugins/preferences.d.ts +2 -2
- package/dist/plugins/preferences.js +9 -1
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.d.cts +2 -2
- package/dist/plugins/push.d.ts +2 -2
- package/dist/plugins/telegram.d.cts +2 -2
- package/dist/plugins/telegram.d.ts +2 -2
- package/dist/providers/expo-push.cjs +114 -0
- package/dist/providers/expo-push.cjs.map +1 -0
- package/dist/providers/expo-push.d.cts +61 -0
- package/dist/providers/expo-push.d.ts +61 -0
- package/dist/providers/expo-push.js +109 -0
- package/dist/providers/expo-push.js.map +1 -0
- package/dist/providers/web-push.d.cts +2 -2
- package/dist/providers/web-push.d.ts +2 -2
- package/dist/react-native.cjs +637 -0
- package/dist/react-native.cjs.map +1 -0
- package/dist/react-native.d.cts +145 -0
- package/dist/react-native.d.ts +145 -0
- package/dist/react-native.js +609 -0
- package/dist/react-native.js.map +1 -0
- package/dist/react.cjs +7 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +7 -2
- package/dist/react.js.map +1 -1
- package/dist/schema.d.cts +2 -2
- package/dist/schema.d.ts +2 -2
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/package.json +26 -3
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/core/handler.ts
|
|
4
|
+
var MAX_BODY_BYTES = 64 * 1024;
|
|
5
|
+
var PRIVATE_HEADERS = {
|
|
6
|
+
"cache-control": "private, no-store",
|
|
7
|
+
"x-content-type-options": "nosniff"
|
|
8
|
+
};
|
|
9
|
+
var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
10
|
+
status,
|
|
11
|
+
headers: { "content-type": "application/json", ...PRIVATE_HEADERS, ...headers }
|
|
12
|
+
});
|
|
13
|
+
new TextEncoder();
|
|
14
|
+
async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
|
|
15
|
+
const tooLarge = () => ({ error: json({ error: `body exceeds ${maxBytes} bytes` }, 413) });
|
|
16
|
+
const declared = Number(request.headers.get("content-length"));
|
|
17
|
+
if (Number.isFinite(declared) && declared > maxBytes) return tooLarge();
|
|
18
|
+
if (!request.body) return { body: {} };
|
|
19
|
+
const reader = request.body.getReader();
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let total = 0;
|
|
22
|
+
for (; ; ) {
|
|
23
|
+
const { done, value } = await reader.read();
|
|
24
|
+
if (done) break;
|
|
25
|
+
total += value.byteLength;
|
|
26
|
+
if (total > maxBytes) {
|
|
27
|
+
await reader.cancel();
|
|
28
|
+
return tooLarge();
|
|
29
|
+
}
|
|
30
|
+
chunks.push(value);
|
|
31
|
+
}
|
|
32
|
+
const bytes = new Uint8Array(total);
|
|
33
|
+
let offset = 0;
|
|
34
|
+
for (const chunk of chunks) {
|
|
35
|
+
bytes.set(chunk, offset);
|
|
36
|
+
offset += chunk.byteLength;
|
|
37
|
+
}
|
|
38
|
+
const text = new TextDecoder().decode(bytes);
|
|
39
|
+
if (text.trim() === "") return { body: {} };
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(text);
|
|
42
|
+
return {
|
|
43
|
+
body: parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
return { error: json({ error: "body must be a JSON object" }, 400) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/plugins/mobile-push/index.ts
|
|
51
|
+
var DEVICE = "notification_mobile_push_device";
|
|
52
|
+
var TICKET = "notification_mobile_push_ticket";
|
|
53
|
+
var PLATFORMS = ["ios", "android", "web"];
|
|
54
|
+
var mobilePushSchema = {
|
|
55
|
+
mobilePushDevice: {
|
|
56
|
+
tableName: DEVICE,
|
|
57
|
+
fields: {
|
|
58
|
+
id: { type: "string", required: true },
|
|
59
|
+
userId: { type: "string", required: true },
|
|
60
|
+
token: { type: "string", required: true },
|
|
61
|
+
platform: { type: "string", required: true },
|
|
62
|
+
deviceName: { type: "string" },
|
|
63
|
+
createdAt: { type: "date", required: true, defaultNow: true },
|
|
64
|
+
lastSeenAt: { type: "date", required: true, defaultNow: true }
|
|
65
|
+
},
|
|
66
|
+
primaryKey: ["id"],
|
|
67
|
+
indexes: [
|
|
68
|
+
{ on: ["userId"], name: "mobile_push_device_user_idx" },
|
|
69
|
+
// One row per token: re-registering updates instead of duplicating.
|
|
70
|
+
{ on: ["token"], unique: true, name: "mobile_push_device_token_idx" }
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
mobilePushTicket: {
|
|
74
|
+
tableName: TICKET,
|
|
75
|
+
fields: {
|
|
76
|
+
id: { type: "string", required: true },
|
|
77
|
+
deviceId: { type: "string", required: true },
|
|
78
|
+
createdAt: { type: "date", required: true, defaultNow: true }
|
|
79
|
+
},
|
|
80
|
+
primaryKey: ["id"],
|
|
81
|
+
indexes: [{ on: ["createdAt"], name: "mobile_push_ticket_created_idx" }]
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var isDuplicate = (error) => {
|
|
85
|
+
const code = error?.code;
|
|
86
|
+
if (code === "23505" || code === 11e3) return true;
|
|
87
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
88
|
+
return /duplicate|unique/i.test(text);
|
|
89
|
+
};
|
|
90
|
+
function mobilePush(options) {
|
|
91
|
+
let ctx;
|
|
92
|
+
const staleAfterDays = options.staleAfterDays ?? 180;
|
|
93
|
+
const maxDevices = Math.max(1, options.maxDevicesPerUser ?? 20);
|
|
94
|
+
async function checkReceipts() {
|
|
95
|
+
if (!options.provider.receipts) return { checked: 0, pruned: 0 };
|
|
96
|
+
const tickets = await ctx.store.find(
|
|
97
|
+
TICKET,
|
|
98
|
+
{},
|
|
99
|
+
{ orderBy: { field: "createdAt", direction: "asc" }, limit: 1e3 }
|
|
100
|
+
);
|
|
101
|
+
if (tickets.length === 0) return { checked: 0, pruned: 0 };
|
|
102
|
+
const receipts = await options.provider.receipts(tickets.map((ticket) => ticket.id));
|
|
103
|
+
const byTicket = new Map(tickets.map((ticket) => [ticket.id, ticket.deviceId]));
|
|
104
|
+
const gone = receipts.flatMap(
|
|
105
|
+
(receipt) => !receipt.ok && receipt.gone ? [byTicket.get(receipt.ticketId) ?? ""] : []
|
|
106
|
+
);
|
|
107
|
+
const settled = receipts.map((receipt) => receipt.ticketId);
|
|
108
|
+
if (gone.length > 0) await ctx.store.remove(DEVICE, { id: { in: gone.filter(Boolean) } });
|
|
109
|
+
if (settled.length > 0) await ctx.store.remove(TICKET, { id: { in: settled } });
|
|
110
|
+
const cutoff = new Date(Date.now() - 24 * 36e5);
|
|
111
|
+
await ctx.store.remove(TICKET, { createdAt: { lt: cutoff } });
|
|
112
|
+
return { checked: settled.length, pruned: gone.length };
|
|
113
|
+
}
|
|
114
|
+
const routes = [
|
|
115
|
+
{
|
|
116
|
+
path: "/mobile-push/devices",
|
|
117
|
+
method: "POST",
|
|
118
|
+
scope: { type: "user" },
|
|
119
|
+
handler: async ({ request, userId }) => {
|
|
120
|
+
const parsed = await readJsonBody(request);
|
|
121
|
+
if ("error" in parsed) return parsed.error;
|
|
122
|
+
const { token, platform, deviceName } = parsed.body;
|
|
123
|
+
if (typeof token !== "string" || !options.provider.isValidToken(token)) {
|
|
124
|
+
return Response.json(
|
|
125
|
+
{ error: `token is not a valid ${options.provider.name} push token` },
|
|
126
|
+
{ status: 400 }
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (typeof platform !== "string" || !PLATFORMS.includes(platform)) {
|
|
130
|
+
return Response.json(
|
|
131
|
+
{ error: `platform must be one of ${PLATFORMS.join(", ")}` },
|
|
132
|
+
{ status: 400 }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const name = typeof deviceName === "string" ? deviceName.slice(0, 120) : null;
|
|
136
|
+
const owner = userId ?? "";
|
|
137
|
+
const now = /* @__PURE__ */ new Date();
|
|
138
|
+
const [existing] = await ctx.store.find(DEVICE, { token }, { limit: 1 });
|
|
139
|
+
if (existing && existing.userId !== owner) {
|
|
140
|
+
return Response.json(
|
|
141
|
+
{ error: "token is registered to another account" },
|
|
142
|
+
{ status: 409 }
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (existing) {
|
|
146
|
+
await ctx.store.update(
|
|
147
|
+
DEVICE,
|
|
148
|
+
{ id: existing.id },
|
|
149
|
+
{ platform, deviceName: name, lastSeenAt: now }
|
|
150
|
+
);
|
|
151
|
+
return Response.json({ ok: true });
|
|
152
|
+
}
|
|
153
|
+
const devices = await ctx.store.find(
|
|
154
|
+
DEVICE,
|
|
155
|
+
{ userId: owner },
|
|
156
|
+
{ orderBy: { field: "lastSeenAt", direction: "asc" } }
|
|
157
|
+
);
|
|
158
|
+
const excess = devices.length - maxDevices + 1;
|
|
159
|
+
if (excess > 0) {
|
|
160
|
+
await ctx.store.remove(DEVICE, {
|
|
161
|
+
id: { in: devices.slice(0, excess).map((device) => device.id) }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
await ctx.store.insert(DEVICE, [
|
|
166
|
+
{
|
|
167
|
+
id: crypto.randomUUID(),
|
|
168
|
+
userId: owner,
|
|
169
|
+
token,
|
|
170
|
+
platform,
|
|
171
|
+
deviceName: name,
|
|
172
|
+
createdAt: now,
|
|
173
|
+
lastSeenAt: now
|
|
174
|
+
}
|
|
175
|
+
]);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (isDuplicate(error)) {
|
|
178
|
+
return Response.json(
|
|
179
|
+
{ error: "token is registered to another account" },
|
|
180
|
+
{ status: 409 }
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
return Response.json({ ok: true });
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
path: "/mobile-push/devices",
|
|
190
|
+
method: "GET",
|
|
191
|
+
scope: { type: "user" },
|
|
192
|
+
handler: async ({ userId }) => {
|
|
193
|
+
const devices = await ctx.store.find(
|
|
194
|
+
DEVICE,
|
|
195
|
+
{ userId: userId ?? "" },
|
|
196
|
+
{ orderBy: { field: "lastSeenAt", direction: "desc" } }
|
|
197
|
+
);
|
|
198
|
+
return Response.json({
|
|
199
|
+
devices: devices.map((device) => ({
|
|
200
|
+
platform: device.platform,
|
|
201
|
+
deviceName: device.deviceName,
|
|
202
|
+
// The token itself is not returned: it is a capability, not a label.
|
|
203
|
+
tokenSuffix: device.token.slice(-6),
|
|
204
|
+
lastSeenAt: device.lastSeenAt.toISOString()
|
|
205
|
+
}))
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
path: "/mobile-push/devices/remove",
|
|
211
|
+
method: "POST",
|
|
212
|
+
scope: { type: "user" },
|
|
213
|
+
handler: async ({ request, userId }) => {
|
|
214
|
+
const parsed = await readJsonBody(request);
|
|
215
|
+
if ("error" in parsed) return parsed.error;
|
|
216
|
+
const { token } = parsed.body;
|
|
217
|
+
if (typeof token !== "string" || token === "") {
|
|
218
|
+
return Response.json({ error: "token is required" }, { status: 400 });
|
|
219
|
+
}
|
|
220
|
+
const removed = await ctx.store.remove(DEVICE, { userId: userId ?? "", token });
|
|
221
|
+
return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
path: "/mobile-push/receipts",
|
|
226
|
+
method: "POST",
|
|
227
|
+
scope: { type: "machine" },
|
|
228
|
+
handler: async () => Response.json(await checkReceipts())
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
path: "/mobile-push/prune",
|
|
232
|
+
method: "POST",
|
|
233
|
+
scope: { type: "machine" },
|
|
234
|
+
handler: async () => {
|
|
235
|
+
const cutoff = new Date(Date.now() - staleAfterDays * 864e5);
|
|
236
|
+
const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });
|
|
237
|
+
return Response.json({ removed });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
];
|
|
241
|
+
return {
|
|
242
|
+
id: "mobilePush",
|
|
243
|
+
channels: ["mobilePush"],
|
|
244
|
+
schema: mobilePushSchema,
|
|
245
|
+
init: (context) => {
|
|
246
|
+
ctx = context;
|
|
247
|
+
},
|
|
248
|
+
hooks: {
|
|
249
|
+
deliver: async (context) => {
|
|
250
|
+
const devices = await ctx.store.find(DEVICE, {
|
|
251
|
+
userId: context.notification.userId
|
|
252
|
+
});
|
|
253
|
+
if (devices.length === 0) return { result: "skipped", reason: "no registered devices" };
|
|
254
|
+
const rendered = options.render({
|
|
255
|
+
type: context.notification.type,
|
|
256
|
+
payload: context.notification.payload
|
|
257
|
+
});
|
|
258
|
+
const data = {
|
|
259
|
+
...rendered.data,
|
|
260
|
+
notificationId: context.notification.id,
|
|
261
|
+
type: context.notification.type
|
|
262
|
+
};
|
|
263
|
+
const tickets = await options.provider.send(
|
|
264
|
+
devices.map((device) => ({ ...rendered, data, token: device.token })),
|
|
265
|
+
context.signal
|
|
266
|
+
);
|
|
267
|
+
const prune = [];
|
|
268
|
+
const accepted = [];
|
|
269
|
+
let delivered = 0;
|
|
270
|
+
let retryable = false;
|
|
271
|
+
let lastError = "";
|
|
272
|
+
const now = /* @__PURE__ */ new Date();
|
|
273
|
+
tickets.forEach((ticket, index) => {
|
|
274
|
+
const device = devices[index];
|
|
275
|
+
if (!device) return;
|
|
276
|
+
if (ticket.ok) {
|
|
277
|
+
delivered += 1;
|
|
278
|
+
if (ticket.ticketId)
|
|
279
|
+
accepted.push({ id: ticket.ticketId, deviceId: device.id, createdAt: now });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
lastError = ticket.error;
|
|
283
|
+
if (ticket.gone) prune.push(device.id);
|
|
284
|
+
else if (ticket.retryable) retryable = true;
|
|
285
|
+
});
|
|
286
|
+
if (prune.length > 0) await ctx.store.remove(DEVICE, { id: { in: prune } });
|
|
287
|
+
if (accepted.length > 0 && options.provider.receipts) {
|
|
288
|
+
await ctx.store.insert(TICKET, accepted).catch(
|
|
289
|
+
(error) => ctx.logger.warn(`mobile push: could not record tickets: ${String(error)}`)
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (delivered > 0) return { result: "sent" };
|
|
293
|
+
if (prune.length === devices.length) {
|
|
294
|
+
return { result: "failed", error: "every device token is gone", retryable: false };
|
|
295
|
+
}
|
|
296
|
+
return { result: "failed", error: lastError || "no device accepted the push", retryable };
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
routes,
|
|
300
|
+
checkReceipts
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
exports.mobilePush = mobilePush;
|
|
305
|
+
exports.mobilePushSchema = mobilePushSchema;
|
|
306
|
+
//# sourceMappingURL=mobile-push.cjs.map
|
|
307
|
+
//# sourceMappingURL=mobile-push.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/handler.ts","../../src/plugins/mobile-push/index.ts"],"names":[],"mappings":"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","file":"mobile-push.cjs","sourcesContent":["import type { DatabaseAdapter } from \"./adapter\";\nimport type { SessionConfig } from \"./config\";\nimport type { Logger } from \"./errors\";\nimport type { Promisable, RouteDefinition } from \"./plugin\";\nimport type { Runner } from \"./runner\";\nimport { inboxChannel, type Signals } from \"./signals\";\nimport { verifyToken } from \"./tokens\";\n\nexport type EventsSettings = {\n heartbeatMs: number;\n probeIntervalMs: number;\n maxDurationMs: number;\n maxStreamsPerUser: number;\n maxStreams: number;\n};\n\n/** Unread chunks a stream may hold before it is closed on a consumer that stopped reading. */\nconst STREAM_HIGH_WATER_MARK = 256;\n\nexport type HandlerDeps = {\n adapter: DatabaseAdapter;\n session: SessionConfig;\n runner: Runner;\n logger: Logger;\n basePath: string;\n secret: string;\n signals: Signals;\n /** `false` unmounts GET /events. */\n events: EventsSettings | false;\n /** Fire-and-forget delivery pass after a request. Rate-limited by the instance. */\n sweepOnRequest?: (() => void) | undefined;\n cronSecret?: string | undefined;\n /** Guards plugin machine routes. The instance defaults it to cronSecret. */\n machineSecret?: string | undefined;\n cronMaxSweeps?: number | undefined;\n pluginRoutes?: readonly RouteDefinition[] | undefined;\n /** Returns a 429 to send, or null to proceed. Runs after onRequest, before routing. */\n rateLimit?: ((request: Request) => Response | null) | undefined;\n /** Origins other than the request's own host that may POST. `*.example.com` allowed. */\n trustedOrigins?: readonly string[] | undefined;\n /** Runs before routing. Returning a Response short-circuits; use it for rate limiting. */\n // biome-ignore lint/suspicious/noConfusingVoidType: a hook that returns nothing is the common case\n onRequest?: ((request: Request) => Promisable<Response | undefined | void>) | undefined;\n maxBodyBytes?: number | undefined;\n};\n\n/** Every JSON body is capped here. Nothing legitimate the routes accept is bigger. */\nexport const MAX_BODY_BYTES = 64 * 1024;\n\nconst MAX_READ_IDS = 200;\n\n// Per-user data: no shared caching, no sniffing.\nconst PRIVATE_HEADERS: Record<string, string> = {\n \"cache-control\": \"private, no-store\",\n \"x-content-type-options\": \"nosniff\",\n};\n\nconst json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json\", ...PRIVATE_HEADERS, ...headers },\n });\n\nconst empty = (status: number) => new Response(null, { status, headers: PRIVATE_HEADERS });\n\n/** Plugin responses get the same headers, without touching their body. */\nfunction harden(response: Response): Response {\n const headers = new Headers(response.headers);\n for (const [name, value] of Object.entries(PRIVATE_HEADERS)) {\n if (!headers.has(name)) headers.set(name, value);\n }\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n\nconst encoder = new TextEncoder();\n\n/** Constant time over SHA-256 digests, so neither content nor length leaks. Works on edge runtimes. */\nasync function timingSafeEqual(a: string, b: string): Promise<boolean> {\n const [left, right] = await Promise.all(\n [a, b].map(\n async (value) => new Uint8Array(await crypto.subtle.digest(\"SHA-256\", encoder.encode(value))),\n ),\n );\n let diff = 0;\n for (let i = 0; i < 32; i += 1) diff |= (left?.[i] ?? 0) ^ (right?.[i] ?? 0);\n return diff === 0;\n}\n\n/** Anchored at a segment boundary: `/foo/api/notifications/cron` is not a cron request. */\nfunction routePath(url: URL, basePath: string): string {\n const { pathname } = url;\n if (pathname === basePath) return \"/\";\n if (pathname.startsWith(`${basePath}/`)) return pathname.slice(basePath.length);\n return pathname;\n}\n\nexport type JsonBody = { body: Record<string, unknown> } | { error: Response };\n\n/** Reads a JSON object body with a streaming byte cap. Malformed JSON is 400; an empty body is `{}`. */\nexport async function readJsonBody(\n request: Request,\n maxBytes: number = MAX_BODY_BYTES,\n): Promise<JsonBody> {\n const tooLarge = () => ({ error: json({ error: `body exceeds ${maxBytes} bytes` }, 413) });\n\n const declared = Number(request.headers.get(\"content-length\"));\n if (Number.isFinite(declared) && declared > maxBytes) return tooLarge();\n if (!request.body) return { body: {} };\n\n const reader = request.body.getReader();\n const chunks: Uint8Array[] = [];\n let total = 0;\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > maxBytes) {\n await reader.cancel();\n return tooLarge();\n }\n chunks.push(value);\n }\n\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n const text = new TextDecoder().decode(bytes);\n if (text.trim() === \"\") return { body: {} };\n\n try {\n const parsed: unknown = JSON.parse(text);\n return {\n body:\n parsed && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {},\n };\n } catch {\n return { error: json({ error: \"body must be a JSON object\" }, 400) };\n }\n}\n\nconst isJsonContentType = (request: Request) =>\n /^application\\/json\\s*(;|$)/i.test((request.headers.get(\"content-type\") ?? \"\").trim());\n\nfunction originAllowed(origin: string, url: URL, request: Request, trusted: readonly string[]) {\n let host: string;\n try {\n host = new URL(origin).host;\n } catch {\n return false;\n }\n\n if (host === url.host) return true;\n // Behind a proxy that rewrites Host, the public host arrives here instead.\n const forwarded = request.headers.get(\"x-forwarded-host\")?.split(\",\")[0]?.trim();\n if (forwarded && host === forwarded) return true;\n\n return trusted.some((entry) =>\n entry.startsWith(\"*.\") ? host.endsWith(entry.slice(1)) : entry === origin,\n );\n}\n\n/** CSRF: a cross-site form can neither send `application/json` without a preflight nor forge `Origin`. */\nfunction csrfCheck(request: Request, url: URL, trusted: readonly string[]): Response | null {\n if (!isJsonContentType(request)) {\n return json({ error: \"content-type must be application/json\" }, 415);\n }\n\n const origin = request.headers.get(\"origin\");\n if (origin === null) return null;\n if (origin === \"null\" || !originAllowed(origin, url, request, trusted)) return empty(403);\n\n return null;\n}\n\nexport function createHandler(deps: HandlerDeps) {\n const trustedOrigins = deps.trustedOrigins ?? [];\n const maxBodyBytes = deps.maxBodyBytes ?? MAX_BODY_BYTES;\n\n async function resolveUserId(request: Request): Promise<string | null | \"error\"> {\n try {\n return await deps.session.getUserId(request);\n } catch (error) {\n // A broken session lookup is a 500, never a 401. RFC 0002 §1.\n deps.logger.error(\"session.getUserId threw\", { error });\n return \"error\";\n }\n }\n\n async function bearerMatches(request: Request, secret: string | undefined): Promise<boolean> {\n if (!secret) return false;\n const header = request.headers.get(\"authorization\") ?? \"\";\n const presented = header.startsWith(\"Bearer \") ? header.slice(7) : header;\n return timingSafeEqual(presented, secret);\n }\n\n async function handleCron(request: Request): Promise<Response> {\n if (!deps.cronSecret) return empty(404);\n if (!(await bearerMatches(request, deps.cronSecret))) return empty(401);\n\n // Bounded so an overlapping scheduler does not hold a request open for the whole backlog.\n const result = await deps.runner.drain(\n deps.cronMaxSweeps === undefined ? {} : { maxSweeps: deps.cronMaxSweeps },\n );\n return json(result);\n }\n\n /** Plugin routes first; auth is enforced here so a plugin cannot forget it. */\n async function handlePluginRoute(\n route: RouteDefinition,\n request: Request,\n url: URL,\n ): Promise<Response> {\n if (route.scope.type === \"user\") {\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === \"error\") return empty(500);\n if (userId === null) return empty(401);\n return harden(await route.handler({ request, userId, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"machine\") {\n if (!deps.machineSecret) return empty(404);\n if (!(await bearerMatches(request, deps.machineSecret))) return empty(401);\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n if (route.scope.type === \"signed\") {\n const token =\n url.searchParams.get(\"token\") ??\n (request.headers.get(\"authorization\") ?? \"\").replace(/^Bearer /, \"\");\n\n const claims = await verifyToken(deps.secret, token, route.scope.purpose);\n // One response for every failure mode. RFC 0002 §5.\n if (!claims) return empty(400);\n\n return harden(await route.handler({ request, userId: claims.uid, claims, params: {} }));\n }\n\n return harden(await route.handler({ request, userId: null, claims: null, params: {} }));\n }\n\n async function route(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const path = routePath(url, deps.basePath);\n\n if (deps.onRequest) {\n const early = await deps.onRequest(request);\n if (early instanceof Response) return early;\n }\n\n if (deps.rateLimit) {\n const limited = deps.rateLimit(request);\n if (limited) return limited;\n }\n\n if (path === \"/cron\") {\n if (request.method !== \"POST\") return empty(405);\n return handleCron(request);\n }\n\n const pluginRoute = deps.pluginRoutes?.find(\n (candidate) => candidate.path === path && candidate.method === request.method,\n );\n if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);\n\n if (request.method === \"POST\") {\n const rejected = csrfCheck(request, url, trustedOrigins);\n if (rejected) return rejected;\n }\n\n const userId = await resolveUserId(request);\n if (userId === \"error\") return empty(500);\n if (userId === null) return empty(401);\n\n if (request.method === \"GET\" && path === \"/events\") {\n if (!deps.events) return empty(404);\n // A session can open streams for free; without a cap one account can hold every socket.\n if ((openStreams.get(userId) ?? 0) >= deps.events.maxStreamsPerUser) {\n return json({ error: \"too many open event streams\" }, 429, { \"retry-after\": \"30\" });\n }\n if (totalStreams >= deps.events.maxStreams) {\n return json({ error: \"event streams unavailable\" }, 503, { \"retry-after\": \"30\" });\n }\n return events(request, userId, deps.events);\n }\n\n if (request.method === \"GET\" && path === \"/\") {\n const limitParam = Number(url.searchParams.get(\"limit\") ?? 20);\n const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;\n const cursor = url.searchParams.get(\"cursor\");\n\n // unseenCount rides on the first page only: a cursor request is scrollback, not a poll.\n const [page, unseenCount] = await Promise.all([\n deps.adapter.listNotifications({\n userId,\n limit,\n ...(cursor ? { cursor } : {}),\n ...(url.searchParams.get(\"unreadOnly\") === \"true\" ? { unreadOnly: true } : {}),\n }),\n cursor ? Promise.resolve(null) : deps.adapter.countUnseen(userId),\n ]);\n\n return json(unseenCount === null ? page : { ...page, unseenCount });\n }\n\n if (request.method === \"GET\" && path === \"/count\") {\n return json({ unseen: await deps.adapter.countUnseen(userId) });\n }\n\n if (request.method === \"POST\" && path === \"/seen\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const before = typeof parsed.body.before === \"string\" ? new Date(parsed.body.before) : null;\n\n // +1ms: Postgres keeps microseconds, an ISO string milliseconds; without it markSeen misses the newest row.\n const cutoff =\n before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : new Date();\n\n await deps.adapter.markSeen(userId, cutoff);\n deps.signals.publish(inboxChannel(userId));\n return json({ ok: true });\n }\n\n if (request.method === \"POST\" && path === \"/read\") {\n const parsed = await readJsonBody(request, maxBodyBytes);\n if (\"error\" in parsed) return parsed.error;\n const ids = Array.isArray(parsed.body.ids)\n ? parsed.body.ids.filter((id): id is string => typeof id === \"string\")\n : [];\n if (ids.length === 0) return json({ error: \"ids must be a non-empty string array\" }, 400);\n\n if (ids.length > MAX_READ_IDS) {\n return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);\n }\n\n // 404, not 403: a 403 confirms the row exists. RFC 0002 §2.\n const updated = await deps.adapter.markRead(userId, ids);\n if (updated > 0) deps.signals.publish(inboxChannel(userId));\n return updated === 0 ? empty(404) : json({ updated });\n }\n\n if (request.method === \"POST\" && path === \"/read-all\") {\n const updated = await deps.adapter.markAllRead(userId);\n if (updated > 0) deps.signals.publish(inboxChannel(userId));\n return json({ updated });\n }\n\n return empty(404);\n }\n\n const openStreams = new Map<string, number>();\n let totalStreams = 0;\n\n const trackStream = (userId: string) => {\n openStreams.set(userId, (openStreams.get(userId) ?? 0) + 1);\n totalStreams += 1;\n return () => {\n const remaining = (openStreams.get(userId) ?? 1) - 1;\n if (remaining > 0) openStreams.set(userId, remaining);\n else openStreams.delete(userId);\n totalStreams -= 1;\n };\n };\n\n /** A change fingerprint cheap enough to take every probe interval. */\n async function fingerprint(userId: string): Promise<string> {\n const [page, unseen] = await Promise.all([\n deps.adapter.listNotifications({ userId, limit: 1 }),\n deps.adapter.countUnseen(userId),\n ]);\n const newest = page.notifications[0];\n return `${newest?.id ?? \"\"}|${newest?.readAt ? 1 : 0}|${unseen}`;\n }\n\n // One probe per user, however many streams that user holds: N tabs must not mean N queries.\n type Probe = { listeners: Set<() => void>; timer: ReturnType<typeof setInterval>; last?: string };\n const probes = new Map<string, Probe>();\n\n function subscribeProbe(userId: string, onChanged: () => void, intervalMs: number) {\n let probe = probes.get(userId);\n if (!probe) {\n const created: Probe = {\n listeners: new Set(),\n timer: setInterval(() => {\n fingerprint(userId)\n .then((value) => {\n if (created.last !== undefined && value !== created.last) {\n for (const listener of created.listeners) listener();\n }\n created.last = value;\n })\n .catch((error) => deps.logger.error(\"events probe failed\", { error }));\n }, intervalMs),\n };\n probe = created;\n probes.set(userId, created);\n fingerprint(userId)\n .then((value) => {\n if (created.last === undefined) created.last = value;\n })\n .catch(() => {});\n }\n probe.listeners.add(onChanged);\n return () => {\n const current = probes.get(userId);\n if (!current) return;\n current.listeners.delete(onChanged);\n if (current.listeners.size === 0) {\n clearInterval(current.timer);\n probes.delete(userId);\n }\n };\n }\n\n /**\n * Server-Sent Events: `ready` once, `changed` whenever this user's inbox\n * moves, comments as keepalive. No payload ever travels on the stream; the\n * client fetches the feed on `changed`. RFC 0006 §4D.\n */\n function events(request: Request, userId: string, settings: EventsSettings): Response {\n const encoder = new TextEncoder();\n const timers: ReturnType<typeof setInterval>[] = [];\n const unsubscribes: (() => void)[] = [];\n let closed = false;\n let cleanup = () => {};\n\n const stream = new ReadableStream<Uint8Array>(\n {\n start(controller) {\n const write = (text: string) => {\n if (closed) return;\n // A consumer that stopped reading would make this queue grow without bound.\n if (controller.desiredSize !== null && controller.desiredSize <= 0) {\n cleanup();\n return;\n }\n try {\n controller.enqueue(encoder.encode(text));\n } catch {\n cleanup();\n }\n };\n cleanup = () => {\n if (closed) return;\n closed = true;\n for (const unsubscribe of unsubscribes) unsubscribe();\n for (const timer of timers) clearInterval(timer);\n try {\n controller.close();\n } catch {\n // already closed by the consumer\n }\n };\n const changed = () => write(\"event: changed\\ndata: {}\\n\\n\");\n\n unsubscribes.push(trackStream(userId));\n write(\"retry: 3000\\n\\nevent: ready\\ndata: {}\\n\\n\");\n unsubscribes.push(deps.signals.subscribe(inboxChannel(userId), changed));\n timers.push(setInterval(() => write(\": keepalive\\n\\n\"), settings.heartbeatMs));\n\n if (!deps.signals.crossProcess && settings.probeIntervalMs > 0) {\n unsubscribes.push(subscribeProbe(userId, changed, settings.probeIntervalMs));\n }\n\n if (settings.maxDurationMs > 0) {\n timers.push(\n setTimeout(cleanup, settings.maxDurationMs) as ReturnType<typeof setInterval>,\n );\n }\n request.signal?.addEventListener(\"abort\", cleanup, { once: true });\n },\n cancel() {\n cleanup();\n },\n },\n { highWaterMark: STREAM_HIGH_WATER_MARK },\n );\n\n return new Response(stream, {\n headers: {\n \"content-type\": \"text/event-stream; charset=utf-8\",\n \"cache-control\": \"no-cache, no-store\",\n connection: \"keep-alive\",\n \"x-accel-buffering\": \"no\",\n \"x-content-type-options\": \"nosniff\",\n },\n });\n }\n\n async function handle(request: Request): Promise<Response> {\n let response: Response | undefined;\n try {\n response = await route(request);\n return response;\n } catch (error) {\n // A thrown driver error must not escape: unhandled rejection on Node, stack trace elsewhere.\n deps.logger.error(\"request failed\", { url: request.url, error });\n return empty(500);\n } finally {\n // Only a served request earns a sweep; anonymous 401s must not drive database work.\n if (response && response.status < 400) deps.sweepOnRequest?.();\n }\n }\n\n return {\n handle,\n GET: handle,\n POST: handle,\n };\n}\n","import type { DeliveryOutcome } from \"../../core/adapter\";\nimport { readJsonBody } from \"../../core/handler\";\nimport type {\n DeliverContext,\n EasyPingPlugin,\n PluginInitContext,\n RouteDefinition,\n SchemaDeclaration,\n} from \"../../core/plugin\";\nimport type { MobilePushMessage, MobilePushProvider } from \"../../providers/expo-push\";\n\nconst DEVICE = \"notification_mobile_push_device\";\nconst TICKET = \"notification_mobile_push_ticket\";\n\nexport type MobilePushPlatform = \"ios\" | \"android\" | \"web\";\n\nexport type MobilePushDevice = {\n id: string;\n userId: string;\n token: string;\n platform: MobilePushPlatform;\n deviceName: string | null;\n createdAt: Date;\n lastSeenAt: Date;\n};\n\nexport type MobilePushTicketRow = {\n id: string;\n deviceId: string;\n createdAt: Date;\n};\n\nexport type MobilePushRendered = Omit<MobilePushMessage, \"token\">;\n\nexport type MobilePushOptions = {\n provider: MobilePushProvider;\n /** What the OS notification shows. `data` rides along to the app's tap handler. */\n render: (input: { type: string; payload: unknown }) => MobilePushRendered;\n /** Devices untouched for this long are pruned by POST /mobile-push/prune. Default 180 days. */\n staleAfterDays?: number | undefined;\n /** Registering past this evicts the user's least recently seen device. Default 20. */\n maxDevicesPerUser?: number | undefined;\n};\n\nconst PLATFORMS: readonly MobilePushPlatform[] = [\"ios\", \"android\", \"web\"];\n\n/** The tables this plugin owns, exported so DDL needs no plugin instance. */\nexport const mobilePushSchema = {\n mobilePushDevice: {\n tableName: DEVICE,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n token: { type: \"string\", required: true },\n platform: { type: \"string\", required: true },\n deviceName: { type: \"string\" },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n lastSeenAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [\n { on: [\"userId\"], name: \"mobile_push_device_user_idx\" },\n // One row per token: re-registering updates instead of duplicating.\n { on: [\"token\"], unique: true, name: \"mobile_push_device_token_idx\" },\n ],\n },\n mobilePushTicket: {\n tableName: TICKET,\n fields: {\n id: { type: \"string\", required: true },\n deviceId: { type: \"string\", required: true },\n createdAt: { type: \"date\", required: true, defaultNow: true },\n },\n primaryKey: [\"id\"],\n indexes: [{ on: [\"createdAt\"], name: \"mobile_push_ticket_created_idx\" }],\n },\n} as const satisfies SchemaDeclaration;\n\nconst isDuplicate = (error: unknown): boolean => {\n const code = (error as { code?: unknown })?.code;\n if (code === \"23505\" || code === 11000) return true;\n const text = error instanceof Error ? error.message : String(error);\n return /duplicate|unique/i.test(text);\n};\n\nexport type MobilePushPlugin = EasyPingPlugin<\"mobilePush\", typeof mobilePushSchema> & {\n /**\n * Asks the provider for the verdict on accepted messages and prunes devices\n * it reports gone. Expo learns \"device not registered\" from APNs/FCM only\n * after accepting the message, so this is where uninstalls are caught.\n * Also mounted as POST /mobile-push/receipts for the cron.\n */\n checkReceipts(): Promise<{ checked: number; pruned: number }>;\n};\n\n/** Native push through a token registry. Fans out per device; dead tokens are pruned on the spot or by receipt. */\nexport function mobilePush(options: MobilePushOptions): MobilePushPlugin {\n let ctx: PluginInitContext;\n const staleAfterDays = options.staleAfterDays ?? 180;\n const maxDevices = Math.max(1, options.maxDevicesPerUser ?? 20);\n\n async function checkReceipts() {\n if (!options.provider.receipts) return { checked: 0, pruned: 0 };\n const tickets = await ctx.store.find<MobilePushTicketRow>(\n TICKET,\n {},\n { orderBy: { field: \"createdAt\", direction: \"asc\" }, limit: 1000 },\n );\n if (tickets.length === 0) return { checked: 0, pruned: 0 };\n\n const receipts = await options.provider.receipts(tickets.map((ticket) => ticket.id));\n const byTicket = new Map(tickets.map((ticket) => [ticket.id, ticket.deviceId]));\n const gone = receipts.flatMap((receipt) =>\n !receipt.ok && receipt.gone ? [byTicket.get(receipt.ticketId) ?? \"\"] : [],\n );\n const settled = receipts.map((receipt) => receipt.ticketId);\n\n if (gone.length > 0) await ctx.store.remove(DEVICE, { id: { in: gone.filter(Boolean) } });\n if (settled.length > 0) await ctx.store.remove(TICKET, { id: { in: settled } });\n // Receipts the service has already forgotten (older than a day) would sit forever.\n const cutoff = new Date(Date.now() - 24 * 3_600_000);\n await ctx.store.remove(TICKET, { createdAt: { lt: cutoff } });\n\n return { checked: settled.length, pruned: gone.length };\n }\n\n const routes: RouteDefinition[] = [\n {\n path: \"/mobile-push/devices\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n\n const { token, platform, deviceName } = parsed.body as {\n token?: unknown;\n platform?: unknown;\n deviceName?: unknown;\n };\n if (typeof token !== \"string\" || !options.provider.isValidToken(token)) {\n return Response.json(\n { error: `token is not a valid ${options.provider.name} push token` },\n { status: 400 },\n );\n }\n if (typeof platform !== \"string\" || !PLATFORMS.includes(platform as MobilePushPlatform)) {\n return Response.json(\n { error: `platform must be one of ${PLATFORMS.join(\", \")}` },\n { status: 400 },\n );\n }\n const name = typeof deviceName === \"string\" ? deviceName.slice(0, 120) : null;\n const owner = userId ?? \"\";\n const now = new Date();\n\n // A token belongs to one account for life; upserting on token alone would let anyone re-home it.\n const [existing] = await ctx.store.find<MobilePushDevice>(DEVICE, { token }, { limit: 1 });\n if (existing && existing.userId !== owner) {\n return Response.json(\n { error: \"token is registered to another account\" },\n { status: 409 },\n );\n }\n if (existing) {\n await ctx.store.update(\n DEVICE,\n { id: existing.id },\n { platform, deviceName: name, lastSeenAt: now },\n );\n return Response.json({ ok: true });\n }\n\n const devices = await ctx.store.find<MobilePushDevice>(\n DEVICE,\n { userId: owner },\n { orderBy: { field: \"lastSeenAt\", direction: \"asc\" } },\n );\n const excess = devices.length - maxDevices + 1;\n if (excess > 0) {\n await ctx.store.remove(DEVICE, {\n id: { in: devices.slice(0, excess).map((device) => device.id) },\n });\n }\n try {\n await ctx.store.insert(DEVICE, [\n {\n id: crypto.randomUUID(),\n userId: owner,\n token,\n platform,\n deviceName: name,\n createdAt: now,\n lastSeenAt: now,\n },\n ]);\n } catch (error) {\n if (isDuplicate(error)) {\n return Response.json(\n { error: \"token is registered to another account\" },\n { status: 409 },\n );\n }\n throw error;\n }\n return Response.json({ ok: true });\n },\n },\n {\n path: \"/mobile-push/devices\",\n method: \"GET\",\n scope: { type: \"user\" },\n handler: async ({ userId }) => {\n const devices = await ctx.store.find<MobilePushDevice>(\n DEVICE,\n { userId: userId ?? \"\" },\n { orderBy: { field: \"lastSeenAt\", direction: \"desc\" } },\n );\n return Response.json({\n devices: devices.map((device) => ({\n platform: device.platform,\n deviceName: device.deviceName,\n // The token itself is not returned: it is a capability, not a label.\n tokenSuffix: device.token.slice(-6),\n lastSeenAt: device.lastSeenAt.toISOString(),\n })),\n });\n },\n },\n {\n path: \"/mobile-push/devices/remove\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const parsed = await readJsonBody(request);\n if (\"error\" in parsed) return parsed.error;\n const { token } = parsed.body;\n if (typeof token !== \"string\" || token === \"\") {\n return Response.json({ error: \"token is required\" }, { status: 400 });\n }\n const removed = await ctx.store.remove(DEVICE, { userId: userId ?? \"\", token });\n return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });\n },\n },\n {\n path: \"/mobile-push/receipts\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => Response.json(await checkReceipts()),\n },\n {\n path: \"/mobile-push/prune\",\n method: \"POST\",\n scope: { type: \"machine\" },\n handler: async () => {\n const cutoff = new Date(Date.now() - staleAfterDays * 86_400_000);\n const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });\n return Response.json({ removed });\n },\n },\n ];\n\n return {\n id: \"mobilePush\",\n channels: [\"mobilePush\"],\n schema: mobilePushSchema,\n\n init: (context) => {\n ctx = context;\n },\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const devices = await ctx.store.find<MobilePushDevice>(DEVICE, {\n userId: context.notification.userId,\n });\n if (devices.length === 0) return { result: \"skipped\", reason: \"no registered devices\" };\n\n const rendered = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n const data = {\n ...rendered.data,\n notificationId: context.notification.id,\n type: context.notification.type,\n };\n\n const tickets = await options.provider.send(\n devices.map((device) => ({ ...rendered, data, token: device.token })),\n context.signal,\n );\n\n const prune: string[] = [];\n const accepted: { id: string; deviceId: string; createdAt: Date }[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n const now = new Date();\n\n tickets.forEach((ticket, index) => {\n const device = devices[index];\n if (!device) return;\n if (ticket.ok) {\n delivered += 1;\n if (ticket.ticketId)\n accepted.push({ id: ticket.ticketId, deviceId: device.id, createdAt: now });\n return;\n }\n lastError = ticket.error;\n if (ticket.gone) prune.push(device.id);\n else if (ticket.retryable) retryable = true;\n });\n\n if (prune.length > 0) await ctx.store.remove(DEVICE, { id: { in: prune } });\n if (accepted.length > 0 && options.provider.receipts) {\n // Best effort: a failed bookkeeping insert must not fail a delivered push.\n await ctx.store\n .insert(TICKET, accepted)\n .catch((error) =>\n ctx.logger.warn(`mobile push: could not record tickets: ${String(error)}`),\n );\n }\n\n if (delivered > 0) return { result: \"sent\" };\n if (prune.length === devices.length) {\n return { result: \"failed\", error: \"every device token is gone\", retryable: false };\n }\n return { result: \"failed\", error: lastError || \"no device accepted the push\", retryable };\n },\n },\n\n routes,\n checkReceipts,\n };\n}\n"]}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-6S8tpH0n.cjs';
|
|
2
|
+
import { MobilePushProvider, MobilePushMessage } from '../providers/expo-push.cjs';
|
|
3
|
+
import '../adapter-CV5Yt2c6.cjs';
|
|
4
|
+
|
|
5
|
+
type MobilePushPlatform = "ios" | "android" | "web";
|
|
6
|
+
type MobilePushDevice = {
|
|
7
|
+
id: string;
|
|
8
|
+
userId: string;
|
|
9
|
+
token: string;
|
|
10
|
+
platform: MobilePushPlatform;
|
|
11
|
+
deviceName: string | null;
|
|
12
|
+
createdAt: Date;
|
|
13
|
+
lastSeenAt: Date;
|
|
14
|
+
};
|
|
15
|
+
type MobilePushTicketRow = {
|
|
16
|
+
id: string;
|
|
17
|
+
deviceId: string;
|
|
18
|
+
createdAt: Date;
|
|
19
|
+
};
|
|
20
|
+
type MobilePushRendered = Omit<MobilePushMessage, "token">;
|
|
21
|
+
type MobilePushOptions = {
|
|
22
|
+
provider: MobilePushProvider;
|
|
23
|
+
/** What the OS notification shows. `data` rides along to the app's tap handler. */
|
|
24
|
+
render: (input: {
|
|
25
|
+
type: string;
|
|
26
|
+
payload: unknown;
|
|
27
|
+
}) => MobilePushRendered;
|
|
28
|
+
/** Devices untouched for this long are pruned by POST /mobile-push/prune. Default 180 days. */
|
|
29
|
+
staleAfterDays?: number | undefined;
|
|
30
|
+
/** Registering past this evicts the user's least recently seen device. Default 20. */
|
|
31
|
+
maxDevicesPerUser?: number | undefined;
|
|
32
|
+
};
|
|
33
|
+
/** The tables this plugin owns, exported so DDL needs no plugin instance. */
|
|
34
|
+
declare const mobilePushSchema: {
|
|
35
|
+
readonly mobilePushDevice: {
|
|
36
|
+
readonly tableName: "notification_mobile_push_device";
|
|
37
|
+
readonly fields: {
|
|
38
|
+
readonly id: {
|
|
39
|
+
readonly type: "string";
|
|
40
|
+
readonly required: true;
|
|
41
|
+
};
|
|
42
|
+
readonly userId: {
|
|
43
|
+
readonly type: "string";
|
|
44
|
+
readonly required: true;
|
|
45
|
+
};
|
|
46
|
+
readonly token: {
|
|
47
|
+
readonly type: "string";
|
|
48
|
+
readonly required: true;
|
|
49
|
+
};
|
|
50
|
+
readonly platform: {
|
|
51
|
+
readonly type: "string";
|
|
52
|
+
readonly required: true;
|
|
53
|
+
};
|
|
54
|
+
readonly deviceName: {
|
|
55
|
+
readonly type: "string";
|
|
56
|
+
};
|
|
57
|
+
readonly createdAt: {
|
|
58
|
+
readonly type: "date";
|
|
59
|
+
readonly required: true;
|
|
60
|
+
readonly defaultNow: true;
|
|
61
|
+
};
|
|
62
|
+
readonly lastSeenAt: {
|
|
63
|
+
readonly type: "date";
|
|
64
|
+
readonly required: true;
|
|
65
|
+
readonly defaultNow: true;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
readonly primaryKey: readonly ["id"];
|
|
69
|
+
readonly indexes: readonly [{
|
|
70
|
+
readonly on: readonly ["userId"];
|
|
71
|
+
readonly name: "mobile_push_device_user_idx";
|
|
72
|
+
}, {
|
|
73
|
+
readonly on: readonly ["token"];
|
|
74
|
+
readonly unique: true;
|
|
75
|
+
readonly name: "mobile_push_device_token_idx";
|
|
76
|
+
}];
|
|
77
|
+
};
|
|
78
|
+
readonly mobilePushTicket: {
|
|
79
|
+
readonly tableName: "notification_mobile_push_ticket";
|
|
80
|
+
readonly fields: {
|
|
81
|
+
readonly id: {
|
|
82
|
+
readonly type: "string";
|
|
83
|
+
readonly required: true;
|
|
84
|
+
};
|
|
85
|
+
readonly deviceId: {
|
|
86
|
+
readonly type: "string";
|
|
87
|
+
readonly required: true;
|
|
88
|
+
};
|
|
89
|
+
readonly createdAt: {
|
|
90
|
+
readonly type: "date";
|
|
91
|
+
readonly required: true;
|
|
92
|
+
readonly defaultNow: true;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
readonly primaryKey: readonly ["id"];
|
|
96
|
+
readonly indexes: readonly [{
|
|
97
|
+
readonly on: readonly ["createdAt"];
|
|
98
|
+
readonly name: "mobile_push_ticket_created_idx";
|
|
99
|
+
}];
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
type MobilePushPlugin = EasyPingPlugin<"mobilePush", typeof mobilePushSchema> & {
|
|
103
|
+
/**
|
|
104
|
+
* Asks the provider for the verdict on accepted messages and prunes devices
|
|
105
|
+
* it reports gone. Expo learns "device not registered" from APNs/FCM only
|
|
106
|
+
* after accepting the message, so this is where uninstalls are caught.
|
|
107
|
+
* Also mounted as POST /mobile-push/receipts for the cron.
|
|
108
|
+
*/
|
|
109
|
+
checkReceipts(): Promise<{
|
|
110
|
+
checked: number;
|
|
111
|
+
pruned: number;
|
|
112
|
+
}>;
|
|
113
|
+
};
|
|
114
|
+
/** Native push through a token registry. Fans out per device; dead tokens are pruned on the spot or by receipt. */
|
|
115
|
+
declare function mobilePush(options: MobilePushOptions): MobilePushPlugin;
|
|
116
|
+
|
|
117
|
+
export { type MobilePushDevice, type MobilePushOptions, type MobilePushPlatform, type MobilePushPlugin, type MobilePushRendered, type MobilePushTicketRow, mobilePush, mobilePushSchema };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-C4fikLl7.js';
|
|
2
|
+
import { MobilePushProvider, MobilePushMessage } from '../providers/expo-push.js';
|
|
3
|
+
import '../adapter-CV5Yt2c6.js';
|
|
4
|
+
|
|
5
|
+
type MobilePushPlatform = "ios" | "android" | "web";
|
|
6
|
+
type MobilePushDevice = {
|
|
7
|
+
id: string;
|
|
8
|
+
userId: string;
|
|
9
|
+
token: string;
|
|
10
|
+
platform: MobilePushPlatform;
|
|
11
|
+
deviceName: string | null;
|
|
12
|
+
createdAt: Date;
|
|
13
|
+
lastSeenAt: Date;
|
|
14
|
+
};
|
|
15
|
+
type MobilePushTicketRow = {
|
|
16
|
+
id: string;
|
|
17
|
+
deviceId: string;
|
|
18
|
+
createdAt: Date;
|
|
19
|
+
};
|
|
20
|
+
type MobilePushRendered = Omit<MobilePushMessage, "token">;
|
|
21
|
+
type MobilePushOptions = {
|
|
22
|
+
provider: MobilePushProvider;
|
|
23
|
+
/** What the OS notification shows. `data` rides along to the app's tap handler. */
|
|
24
|
+
render: (input: {
|
|
25
|
+
type: string;
|
|
26
|
+
payload: unknown;
|
|
27
|
+
}) => MobilePushRendered;
|
|
28
|
+
/** Devices untouched for this long are pruned by POST /mobile-push/prune. Default 180 days. */
|
|
29
|
+
staleAfterDays?: number | undefined;
|
|
30
|
+
/** Registering past this evicts the user's least recently seen device. Default 20. */
|
|
31
|
+
maxDevicesPerUser?: number | undefined;
|
|
32
|
+
};
|
|
33
|
+
/** The tables this plugin owns, exported so DDL needs no plugin instance. */
|
|
34
|
+
declare const mobilePushSchema: {
|
|
35
|
+
readonly mobilePushDevice: {
|
|
36
|
+
readonly tableName: "notification_mobile_push_device";
|
|
37
|
+
readonly fields: {
|
|
38
|
+
readonly id: {
|
|
39
|
+
readonly type: "string";
|
|
40
|
+
readonly required: true;
|
|
41
|
+
};
|
|
42
|
+
readonly userId: {
|
|
43
|
+
readonly type: "string";
|
|
44
|
+
readonly required: true;
|
|
45
|
+
};
|
|
46
|
+
readonly token: {
|
|
47
|
+
readonly type: "string";
|
|
48
|
+
readonly required: true;
|
|
49
|
+
};
|
|
50
|
+
readonly platform: {
|
|
51
|
+
readonly type: "string";
|
|
52
|
+
readonly required: true;
|
|
53
|
+
};
|
|
54
|
+
readonly deviceName: {
|
|
55
|
+
readonly type: "string";
|
|
56
|
+
};
|
|
57
|
+
readonly createdAt: {
|
|
58
|
+
readonly type: "date";
|
|
59
|
+
readonly required: true;
|
|
60
|
+
readonly defaultNow: true;
|
|
61
|
+
};
|
|
62
|
+
readonly lastSeenAt: {
|
|
63
|
+
readonly type: "date";
|
|
64
|
+
readonly required: true;
|
|
65
|
+
readonly defaultNow: true;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
readonly primaryKey: readonly ["id"];
|
|
69
|
+
readonly indexes: readonly [{
|
|
70
|
+
readonly on: readonly ["userId"];
|
|
71
|
+
readonly name: "mobile_push_device_user_idx";
|
|
72
|
+
}, {
|
|
73
|
+
readonly on: readonly ["token"];
|
|
74
|
+
readonly unique: true;
|
|
75
|
+
readonly name: "mobile_push_device_token_idx";
|
|
76
|
+
}];
|
|
77
|
+
};
|
|
78
|
+
readonly mobilePushTicket: {
|
|
79
|
+
readonly tableName: "notification_mobile_push_ticket";
|
|
80
|
+
readonly fields: {
|
|
81
|
+
readonly id: {
|
|
82
|
+
readonly type: "string";
|
|
83
|
+
readonly required: true;
|
|
84
|
+
};
|
|
85
|
+
readonly deviceId: {
|
|
86
|
+
readonly type: "string";
|
|
87
|
+
readonly required: true;
|
|
88
|
+
};
|
|
89
|
+
readonly createdAt: {
|
|
90
|
+
readonly type: "date";
|
|
91
|
+
readonly required: true;
|
|
92
|
+
readonly defaultNow: true;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
readonly primaryKey: readonly ["id"];
|
|
96
|
+
readonly indexes: readonly [{
|
|
97
|
+
readonly on: readonly ["createdAt"];
|
|
98
|
+
readonly name: "mobile_push_ticket_created_idx";
|
|
99
|
+
}];
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
type MobilePushPlugin = EasyPingPlugin<"mobilePush", typeof mobilePushSchema> & {
|
|
103
|
+
/**
|
|
104
|
+
* Asks the provider for the verdict on accepted messages and prunes devices
|
|
105
|
+
* it reports gone. Expo learns "device not registered" from APNs/FCM only
|
|
106
|
+
* after accepting the message, so this is where uninstalls are caught.
|
|
107
|
+
* Also mounted as POST /mobile-push/receipts for the cron.
|
|
108
|
+
*/
|
|
109
|
+
checkReceipts(): Promise<{
|
|
110
|
+
checked: number;
|
|
111
|
+
pruned: number;
|
|
112
|
+
}>;
|
|
113
|
+
};
|
|
114
|
+
/** Native push through a token registry. Fans out per device; dead tokens are pruned on the spot or by receipt. */
|
|
115
|
+
declare function mobilePush(options: MobilePushOptions): MobilePushPlugin;
|
|
116
|
+
|
|
117
|
+
export { type MobilePushDevice, type MobilePushOptions, type MobilePushPlatform, type MobilePushPlugin, type MobilePushRendered, type MobilePushTicketRow, mobilePush, mobilePushSchema };
|