easy-ping 0.3.0 → 0.4.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/README.md +15 -4
- package/dist/adapters/drizzle.cjs +3 -2
- package/dist/adapters/drizzle.cjs.map +1 -1
- package/dist/adapters/drizzle.d.cts +34 -0
- package/dist/adapters/drizzle.d.ts +34 -0
- package/dist/adapters/drizzle.js +4 -3
- package/dist/adapters/drizzle.js.map +1 -1
- package/dist/adapters/mongodb.cjs +6 -2
- package/dist/adapters/mongodb.cjs.map +1 -1
- package/dist/adapters/mongodb.d.cts +1 -1
- package/dist/adapters/mongodb.d.ts +1 -1
- package/dist/adapters/mongodb.js +5 -2
- package/dist/adapters/mongodb.js.map +1 -1
- package/dist/adapters/postgres.cjs +3 -2
- package/dist/adapters/postgres.cjs.map +1 -1
- package/dist/adapters/postgres.d.cts +1 -1
- package/dist/adapters/postgres.d.ts +1 -1
- package/dist/adapters/postgres.js +3 -3
- package/dist/{chunk-OZSOIX23.js → chunk-3Y2MBUAW.js} +53 -12
- package/dist/chunk-3Y2MBUAW.js.map +1 -0
- package/dist/chunk-CYKAM7LE.js +226 -0
- package/dist/chunk-CYKAM7LE.js.map +1 -0
- package/dist/chunk-DG3ZL5NP.js +315 -0
- package/dist/chunk-DG3ZL5NP.js.map +1 -0
- package/dist/{chunk-NYDVNMKG.js → chunk-PSJHXZPR.js} +4 -3
- package/dist/chunk-PSJHXZPR.js.map +1 -0
- package/dist/{chunk-BV3U542P.js → chunk-W6I3B2CI.js} +2 -2
- package/dist/chunk-W6I3B2CI.js.map +1 -0
- package/dist/{chunk-RBDW4S6F.js → chunk-YEMRRMJQ.js} +3 -3
- package/dist/{chunk-RBDW4S6F.js.map → chunk-YEMRRMJQ.js.map} +1 -1
- package/dist/index.cjs +360 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -20
- package/dist/index.d.ts +84 -20
- package/dist/index.js +119 -357
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +27 -6
- package/dist/node.cjs.map +1 -1
- package/dist/node.d.cts +8 -2
- package/dist/node.d.ts +8 -2
- package/dist/node.js +26 -6
- package/dist/node.js.map +1 -1
- package/dist/{plugin-CmPOFVmn.d.ts → plugin-C9Hoxus2.d.ts} +37 -9
- package/dist/{plugin-f5KaFWSN.d.cts → plugin-CiMsGlQe.d.cts} +37 -9
- package/dist/plugins/digests.d.cts +1 -1
- package/dist/plugins/digests.d.ts +1 -1
- package/dist/plugins/preferences-client.cjs.map +1 -1
- package/dist/plugins/preferences-client.d.cts +1 -1
- package/dist/plugins/preferences-client.d.ts +1 -1
- package/dist/plugins/preferences-client.js +1 -1
- package/dist/plugins/preferences.cjs +100 -15
- package/dist/plugins/preferences.cjs.map +1 -1
- package/dist/plugins/preferences.d.cts +3 -1
- package/dist/plugins/preferences.d.ts +3 -1
- package/dist/plugins/preferences.js +35 -7
- package/dist/plugins/preferences.js.map +1 -1
- package/dist/plugins/push.cjs +197 -38
- package/dist/plugins/push.cjs.map +1 -1
- package/dist/plugins/push.d.cts +32 -10
- package/dist/plugins/push.d.ts +32 -10
- package/dist/plugins/push.js +140 -39
- package/dist/plugins/push.js.map +1 -1
- package/dist/providers/web-push.cjs +37 -11
- package/dist/providers/web-push.cjs.map +1 -1
- package/dist/providers/web-push.d.cts +8 -2
- package/dist/providers/web-push.d.ts +8 -2
- package/dist/providers/web-push.js +37 -12
- package/dist/providers/web-push.js.map +1 -1
- package/dist/schema.cjs +2 -1
- package/dist/schema.cjs.map +1 -1
- package/dist/schema.d.cts +6 -1
- package/dist/schema.d.ts +6 -1
- package/dist/schema.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-BV3U542P.js.map +0 -1
- package/dist/chunk-NYDVNMKG.js.map +0 -1
- package/dist/chunk-OZSOIX23.js.map +0 -1
- package/dist/chunk-W3BMEPPQ.js +0 -50
- package/dist/chunk-W3BMEPPQ.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,367 +1,60 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export {
|
|
3
|
-
export { defineClientPlugin, definePlugin } from './chunk-
|
|
4
|
-
import {
|
|
5
|
-
export {
|
|
1
|
+
import { createRunner } from './chunk-CYKAM7LE.js';
|
|
2
|
+
export { createRunner, redactErrorMessage } from './chunk-CYKAM7LE.js';
|
|
3
|
+
export { defineClientPlugin, definePlugin } from './chunk-W6I3B2CI.js';
|
|
4
|
+
import { createHandler, createScopedSigner } from './chunk-DG3ZL5NP.js';
|
|
5
|
+
export { DEFAULT_TOKEN_TTL_SECONDS, MAX_BODY_BYTES, MAX_TOKEN_TTL_SECONDS, expiresIn, readJsonBody, signToken, verifyToken } from './chunk-DG3ZL5NP.js';
|
|
6
|
+
import { consoleLogger, ConfigError, createPluginStore, ValidationError } from './chunk-3Y2MBUAW.js';
|
|
7
|
+
export { ConfigError, EasyPingError, ValidationError, consoleLogger } from './chunk-3Y2MBUAW.js';
|
|
6
8
|
export { decodeBase64Url, encodeBase64Url } from './chunk-PTGHMCCG.js';
|
|
7
|
-
import './chunk-
|
|
9
|
+
import './chunk-PSJHXZPR.js';
|
|
8
10
|
|
|
9
11
|
// src/core/definition.ts
|
|
10
12
|
function defineNotification(definition) {
|
|
11
13
|
return definition;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
// src/core/
|
|
15
|
-
var
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const left = encoder.encode(a);
|
|
24
|
-
const right = encoder.encode(b);
|
|
25
|
-
let diff = left.length ^ right.length;
|
|
26
|
-
const length = Math.max(left.length, right.length);
|
|
27
|
-
for (let i = 0; i < length; i += 1) {
|
|
28
|
-
diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
|
|
29
|
-
}
|
|
30
|
-
return diff === 0;
|
|
31
|
-
}
|
|
32
|
-
function routePath(url, basePath) {
|
|
33
|
-
const index = url.pathname.indexOf(basePath);
|
|
34
|
-
if (index < 0) return url.pathname;
|
|
35
|
-
return url.pathname.slice(index + basePath.length) || "/";
|
|
36
|
-
}
|
|
37
|
-
async function readJson(request) {
|
|
38
|
-
try {
|
|
39
|
-
const body = await request.json();
|
|
40
|
-
return body && typeof body === "object" ? body : {};
|
|
41
|
-
} catch {
|
|
42
|
-
return {};
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
function createHandler(deps) {
|
|
46
|
-
async function resolveUserId(request) {
|
|
47
|
-
try {
|
|
48
|
-
return await deps.session.getUserId(request);
|
|
49
|
-
} catch (error) {
|
|
50
|
-
deps.logger.error("session.getUserId threw", { error });
|
|
51
|
-
return "error";
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
async function handleCron(request) {
|
|
55
|
-
if (!deps.cronSecret) return empty(404);
|
|
56
|
-
const header = request.headers.get("authorization") ?? "";
|
|
57
|
-
const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
|
|
58
|
-
if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
|
|
59
|
-
const result = await deps.runner.drain();
|
|
60
|
-
return json(result);
|
|
61
|
-
}
|
|
62
|
-
async function handlePluginRoute(route, request, url) {
|
|
63
|
-
if (route.scope.type === "user") {
|
|
64
|
-
const userId = await resolveUserId(request);
|
|
65
|
-
if (userId === "error") return empty(500);
|
|
66
|
-
if (userId === null) return empty(401);
|
|
67
|
-
return route.handler({ request, userId, claims: null, params: {} });
|
|
68
|
-
}
|
|
69
|
-
if (route.scope.type === "machine") {
|
|
70
|
-
if (!deps.cronSecret) return empty(404);
|
|
71
|
-
const header = request.headers.get("authorization") ?? "";
|
|
72
|
-
const presented = header.startsWith("Bearer ") ? header.slice(7) : header;
|
|
73
|
-
if (!timingSafeEqual(presented, deps.cronSecret)) return empty(401);
|
|
74
|
-
return route.handler({ request, userId: null, claims: null, params: {} });
|
|
75
|
-
}
|
|
76
|
-
if (route.scope.type === "signed") {
|
|
77
|
-
const token = url.searchParams.get("token") ?? (request.headers.get("authorization") ?? "").replace(/^Bearer /, "");
|
|
78
|
-
const claims = await verifyToken(deps.secret, token, route.scope.purpose);
|
|
79
|
-
if (!claims) return empty(400);
|
|
80
|
-
return route.handler({ request, userId: claims.uid, claims, params: {} });
|
|
81
|
-
}
|
|
82
|
-
return route.handler({ request, userId: null, claims: null, params: {} });
|
|
83
|
-
}
|
|
84
|
-
async function handle(request) {
|
|
85
|
-
const url = new URL(request.url);
|
|
86
|
-
const path = routePath(url, deps.basePath);
|
|
87
|
-
if (path === "/cron") {
|
|
88
|
-
if (request.method !== "POST") return empty(405);
|
|
89
|
-
return handleCron(request);
|
|
90
|
-
}
|
|
91
|
-
const pluginRoute = deps.pluginRoutes?.find(
|
|
92
|
-
(route) => route.path === path && route.method === request.method
|
|
93
|
-
);
|
|
94
|
-
if (pluginRoute) return handlePluginRoute(pluginRoute, request, url);
|
|
95
|
-
const userId = await resolveUserId(request);
|
|
96
|
-
if (userId === "error") return empty(500);
|
|
97
|
-
if (userId === null) return empty(401);
|
|
98
|
-
if (request.method === "GET" && path === "/") {
|
|
99
|
-
const limitParam = Number(url.searchParams.get("limit") ?? 20);
|
|
100
|
-
const limit = Number.isFinite(limitParam) ? Math.min(Math.max(limitParam, 1), 100) : 20;
|
|
101
|
-
const cursor = url.searchParams.get("cursor");
|
|
102
|
-
const [page, unseenCount] = await Promise.all([
|
|
103
|
-
deps.adapter.listNotifications({
|
|
104
|
-
userId,
|
|
105
|
-
limit,
|
|
106
|
-
...cursor ? { cursor } : {},
|
|
107
|
-
...url.searchParams.get("unreadOnly") === "true" ? { unreadOnly: true } : {}
|
|
108
|
-
}),
|
|
109
|
-
cursor ? Promise.resolve(null) : deps.adapter.countUnseen(userId)
|
|
110
|
-
]);
|
|
111
|
-
return json(unseenCount === null ? page : { ...page, unseenCount });
|
|
112
|
-
}
|
|
113
|
-
if (request.method === "GET" && path === "/count") {
|
|
114
|
-
return json({ unseen: await deps.adapter.countUnseen(userId) });
|
|
115
|
-
}
|
|
116
|
-
if (request.method === "POST" && path === "/seen") {
|
|
117
|
-
const body = await readJson(request);
|
|
118
|
-
const before = typeof body.before === "string" ? new Date(body.before) : null;
|
|
119
|
-
const cutoff = before && !Number.isNaN(before.getTime()) ? new Date(before.getTime() + 1) : /* @__PURE__ */ new Date();
|
|
120
|
-
await deps.adapter.markSeen(userId, cutoff);
|
|
121
|
-
return json({ ok: true });
|
|
122
|
-
}
|
|
123
|
-
if (request.method === "POST" && path === "/read") {
|
|
124
|
-
const body = await readJson(request);
|
|
125
|
-
const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === "string") : [];
|
|
126
|
-
if (ids.length === 0) return json({ error: "ids must be a non-empty string array" }, 400);
|
|
127
|
-
if (ids.length > MAX_READ_IDS) {
|
|
128
|
-
return json({ error: `at most ${MAX_READ_IDS} ids per request` }, 400);
|
|
129
|
-
}
|
|
130
|
-
const updated = await deps.adapter.markRead(userId, ids);
|
|
131
|
-
return updated === 0 ? empty(404) : json({ updated });
|
|
132
|
-
}
|
|
133
|
-
if (request.method === "POST" && path === "/read-all") {
|
|
134
|
-
return json({ updated: await deps.adapter.markAllRead(userId) });
|
|
135
|
-
}
|
|
136
|
-
return empty(404);
|
|
137
|
-
}
|
|
138
|
-
return {
|
|
139
|
-
handle,
|
|
140
|
-
GET: handle,
|
|
141
|
-
POST: handle
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// src/core/backoff.ts
|
|
146
|
-
var SCHEDULE_MS = [3e4, 12e4, 48e4, 192e4];
|
|
147
|
-
function backoffMs(attempt, strategy = "exponential") {
|
|
148
|
-
if (typeof strategy === "function") return Math.max(0, strategy(attempt));
|
|
149
|
-
const index = Math.min(Math.max(attempt, 1), SCHEDULE_MS.length) - 1;
|
|
150
|
-
const base = SCHEDULE_MS[index] ?? SCHEDULE_MS[SCHEDULE_MS.length - 1] ?? 3e4;
|
|
151
|
-
return Math.round(base * (0.8 + Math.random() * 0.4));
|
|
152
|
-
}
|
|
153
|
-
var nextAttemptAt = (attempt, strategy, now = /* @__PURE__ */ new Date()) => new Date(now.getTime() + backoffMs(attempt, strategy));
|
|
16
|
+
// src/core/html.ts
|
|
17
|
+
var ESCAPES = {
|
|
18
|
+
"&": "&",
|
|
19
|
+
"<": "<",
|
|
20
|
+
">": ">",
|
|
21
|
+
'"': """,
|
|
22
|
+
"'": "'"
|
|
23
|
+
};
|
|
24
|
+
var escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
|
|
154
25
|
|
|
155
|
-
// src/core/
|
|
156
|
-
var
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return { result: "failed", error: "no email provider configured", retryable: false };
|
|
185
|
-
}
|
|
186
|
-
const to = recipient.email;
|
|
187
|
-
if (!to) {
|
|
188
|
-
return { result: "failed", error: "recipient has no email address", retryable: false };
|
|
189
|
-
}
|
|
190
|
-
const email = deps.definitions[delivery.notification.type]?.email;
|
|
191
|
-
if (!email) {
|
|
192
|
-
return {
|
|
193
|
-
result: "failed",
|
|
194
|
-
error: `notification type "${delivery.notification.type}" has no email template`,
|
|
195
|
-
retryable: false
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
const payload = delivery.notification.payload;
|
|
200
|
-
const html = await email.template(payload);
|
|
201
|
-
await withTimeout(
|
|
202
|
-
(signal) => provider.send({
|
|
203
|
-
to,
|
|
204
|
-
subject: email.subject(payload),
|
|
205
|
-
html,
|
|
206
|
-
// Delivery is at-least-once; the provider dedupes on this.
|
|
207
|
-
idempotencyKey: delivery.id,
|
|
208
|
-
signal
|
|
209
|
-
}),
|
|
210
|
-
provider.timeoutMs ?? 3e4,
|
|
211
|
-
`${provider.name} send`
|
|
212
|
-
);
|
|
213
|
-
return { result: "sent" };
|
|
214
|
-
} catch (error) {
|
|
215
|
-
return {
|
|
216
|
-
result: "failed",
|
|
217
|
-
error: message(error),
|
|
218
|
-
retryable: provider.isRetryable(error)
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
const owner = deps.plugins.find(
|
|
223
|
-
(plugin) => plugin.channels?.includes(delivery.channel) && plugin.hooks?.deliver
|
|
224
|
-
);
|
|
225
|
-
if (!owner?.hooks?.deliver) {
|
|
226
|
-
return {
|
|
227
|
-
result: "failed",
|
|
228
|
-
error: `no plugin delivers channel "${delivery.channel}"`,
|
|
229
|
-
retryable: false
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
try {
|
|
233
|
-
return await withTimeout(
|
|
234
|
-
(signal) => Promise.resolve(
|
|
235
|
-
// biome-ignore lint/style/noNonNullAssertion: guarded directly above
|
|
236
|
-
owner.hooks.deliver({
|
|
237
|
-
channel: delivery.channel,
|
|
238
|
-
deliveryId: delivery.id,
|
|
239
|
-
attempt: delivery.attempts + 1,
|
|
240
|
-
recipient,
|
|
241
|
-
notification: {
|
|
242
|
-
id: delivery.notificationId,
|
|
243
|
-
userId: delivery.notification.userId,
|
|
244
|
-
type: delivery.notification.type,
|
|
245
|
-
payload: delivery.notification.payload
|
|
246
|
-
},
|
|
247
|
-
signal
|
|
248
|
-
})
|
|
249
|
-
),
|
|
250
|
-
3e4,
|
|
251
|
-
`plugin "${owner.id}" deliver`
|
|
252
|
-
);
|
|
253
|
-
} catch (error) {
|
|
254
|
-
return { result: "failed", error: message(error), retryable: true };
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
async function notifyAfterDeliver(delivery, outcome) {
|
|
258
|
-
const isTerminal = outcome.result !== "failed" || !outcome.retryable || delivery.attempts + 1 >= delivery.maxAttempts;
|
|
259
|
-
for (const plugin of deps.plugins) {
|
|
260
|
-
const hook = plugin.hooks?.afterDeliver;
|
|
261
|
-
if (!hook) continue;
|
|
262
|
-
try {
|
|
263
|
-
await hook({
|
|
264
|
-
deliveryId: delivery.id,
|
|
265
|
-
notificationId: delivery.notificationId,
|
|
266
|
-
userId: delivery.notification.userId,
|
|
267
|
-
channel: delivery.channel,
|
|
268
|
-
outcome,
|
|
269
|
-
attempt: delivery.attempts + 1,
|
|
270
|
-
isTerminal
|
|
271
|
-
});
|
|
272
|
-
} catch (error) {
|
|
273
|
-
deps.logger.error(`plugin "${plugin.id}" afterDeliver threw; ignoring`, {
|
|
274
|
-
deliveryId: delivery.id,
|
|
275
|
-
error
|
|
276
|
-
});
|
|
26
|
+
// src/core/rate-limit.ts
|
|
27
|
+
var clientAddress = (request) => {
|
|
28
|
+
const headers = request.headers;
|
|
29
|
+
return headers.get("cf-connecting-ip") ?? headers.get("x-real-ip") ?? headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
|
|
30
|
+
};
|
|
31
|
+
function createRateLimiter(config) {
|
|
32
|
+
const keyOf = config.key ?? clientAddress;
|
|
33
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
34
|
+
let lastSweep = 0;
|
|
35
|
+
return (request) => {
|
|
36
|
+
const key = keyOf(request);
|
|
37
|
+
if (key === null) return null;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
if (now - lastSweep > config.windowMs) {
|
|
40
|
+
for (const [id, bucket2] of buckets) if (bucket2.resetAt <= now) buckets.delete(id);
|
|
41
|
+
lastSweep = now;
|
|
42
|
+
}
|
|
43
|
+
let bucket = buckets.get(key);
|
|
44
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
45
|
+
bucket = { count: 0, resetAt: now + config.windowMs };
|
|
46
|
+
buckets.set(key, bucket);
|
|
47
|
+
}
|
|
48
|
+
bucket.count += 1;
|
|
49
|
+
if (bucket.count <= config.max) return null;
|
|
50
|
+
return new Response(null, {
|
|
51
|
+
status: 429,
|
|
52
|
+
headers: {
|
|
53
|
+
"retry-after": String(Math.max(1, Math.ceil((bucket.resetAt - now) / 1e3))),
|
|
54
|
+
"cache-control": "private, no-store"
|
|
277
55
|
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
async function runOnce(options = {}) {
|
|
281
|
-
const claimToken = crypto.randomUUID();
|
|
282
|
-
const claimed = await deps.adapter.claimPendingDeliveries({
|
|
283
|
-
limit: options.limit ?? deps.batchSize,
|
|
284
|
-
leaseMs: deps.leaseMs,
|
|
285
|
-
claimToken,
|
|
286
|
-
...options.channels ? { channels: options.channels } : {},
|
|
287
|
-
...options.ids ? { ids: options.ids } : {}
|
|
288
56
|
});
|
|
289
|
-
|
|
290
|
-
const userIds = [...new Set(claimed.map((row) => row.notification.userId))];
|
|
291
|
-
const recipients = await deps.getRecipients(userIds);
|
|
292
|
-
const byUserId = new Map(recipients.map((recipient) => [recipient.userId, recipient]));
|
|
293
|
-
const releases = [];
|
|
294
|
-
let sent = 0;
|
|
295
|
-
let failed = 0;
|
|
296
|
-
let skipped = 0;
|
|
297
|
-
for (const delivery of claimed) {
|
|
298
|
-
const outcome = await deliverOne(delivery, byUserId.get(delivery.notification.userId));
|
|
299
|
-
if (outcome.result === "sent") sent += 1;
|
|
300
|
-
else if (outcome.result === "skipped") skipped += 1;
|
|
301
|
-
else failed += 1;
|
|
302
|
-
releases.push({
|
|
303
|
-
id: delivery.id,
|
|
304
|
-
claimToken,
|
|
305
|
-
outcome,
|
|
306
|
-
...outcome.result === "failed" && outcome.retryable ? { nextAttemptAt: nextAttemptAt(delivery.attempts + 1, deps.backoff) } : {}
|
|
307
|
-
});
|
|
308
|
-
await notifyAfterDeliver(delivery, outcome);
|
|
309
|
-
}
|
|
310
|
-
await deps.adapter.releaseDeliveries(releases);
|
|
311
|
-
return { claimed: claimed.length, sent, failed, skipped };
|
|
312
|
-
}
|
|
313
|
-
async function drain(options = {}) {
|
|
314
|
-
const maxSweeps = options.maxSweeps ?? 50;
|
|
315
|
-
const total = { claimed: 0, sent: 0, failed: 0, skipped: 0 };
|
|
316
|
-
for (let sweep = 0; sweep < maxSweeps; sweep += 1) {
|
|
317
|
-
const result = await runOnce(options.ids ? { ids: options.ids } : {});
|
|
318
|
-
total.claimed += result.claimed;
|
|
319
|
-
total.sent += result.sent;
|
|
320
|
-
total.failed += result.failed;
|
|
321
|
-
total.skipped += result.skipped;
|
|
322
|
-
if (result.claimed === 0) break;
|
|
323
|
-
}
|
|
324
|
-
return total;
|
|
325
|
-
}
|
|
326
|
-
let warnedMissingWaitUntil = false;
|
|
327
|
-
function scheduleDeferred(waitUntil, ids) {
|
|
328
|
-
if (waitUntil) {
|
|
329
|
-
waitUntil(
|
|
330
|
-
drain({ maxSweeps: 10, ...ids ? { ids } : {} }).catch(
|
|
331
|
-
(error) => deps.logger.error("deferred sweep failed", { error })
|
|
332
|
-
)
|
|
333
|
-
);
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
if (!warnedMissingWaitUntil) {
|
|
337
|
-
warnedMissingWaitUntil = true;
|
|
338
|
-
deps.logger.warn(
|
|
339
|
-
"delivery.mode is 'deferred' but no delivery.waitUntil was supplied \u2014 falling back to the cron sweep. Notifications are not lost, but they will not be delivered until POST /cron runs."
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
function startWorker(options = {}) {
|
|
344
|
-
const intervalMs = options.intervalMs ?? 1e3;
|
|
345
|
-
let stopped = false;
|
|
346
|
-
const loop = (async () => {
|
|
347
|
-
while (!stopped) {
|
|
348
|
-
try {
|
|
349
|
-
const result = await runOnce({ limit: options.batchSize ?? deps.batchSize });
|
|
350
|
-
if (result.claimed === 0) await sleep(intervalMs);
|
|
351
|
-
} catch (error) {
|
|
352
|
-
deps.logger.error("worker sweep failed", { error });
|
|
353
|
-
await sleep(intervalMs);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
})();
|
|
357
|
-
return {
|
|
358
|
-
stop: async () => {
|
|
359
|
-
stopped = true;
|
|
360
|
-
await loop;
|
|
361
|
-
}
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
return { runOnce, drain, scheduleDeferred, startWorker };
|
|
57
|
+
};
|
|
365
58
|
}
|
|
366
59
|
|
|
367
60
|
// src/core/send.ts
|
|
@@ -555,6 +248,14 @@ async function runSend(type, input, deps) {
|
|
|
555
248
|
}
|
|
556
249
|
|
|
557
250
|
// src/core/instance.ts
|
|
251
|
+
var CORE_ROUTES = [
|
|
252
|
+
{ method: "GET", path: "/", scope: { type: "user" }, owner: "core" },
|
|
253
|
+
{ method: "GET", path: "/count", scope: { type: "user" }, owner: "core" },
|
|
254
|
+
{ method: "POST", path: "/seen", scope: { type: "user" }, owner: "core" },
|
|
255
|
+
{ method: "POST", path: "/read", scope: { type: "user" }, owner: "core" },
|
|
256
|
+
{ method: "POST", path: "/read-all", scope: { type: "user" }, owner: "core" },
|
|
257
|
+
{ method: "POST", path: "/cron", scope: { type: "machine" }, owner: "core" }
|
|
258
|
+
];
|
|
558
259
|
var DEFAULTS = {
|
|
559
260
|
maxAttempts: 5,
|
|
560
261
|
leaseMs: 6e4,
|
|
@@ -562,6 +263,24 @@ var DEFAULTS = {
|
|
|
562
263
|
basePath: "/api/notifications",
|
|
563
264
|
mode: "cron"
|
|
564
265
|
};
|
|
266
|
+
var MIN_SECRET_LENGTH = 16;
|
|
267
|
+
var PLACEHOLDER_SECRETS = /* @__PURE__ */ new Set([
|
|
268
|
+
"secret",
|
|
269
|
+
"changeme",
|
|
270
|
+
"change-me",
|
|
271
|
+
"replace-me",
|
|
272
|
+
"replace-me-too",
|
|
273
|
+
"password",
|
|
274
|
+
"demo-signing-secret",
|
|
275
|
+
"demo-cron-secret"
|
|
276
|
+
]);
|
|
277
|
+
function checkSecretStrength(name, value) {
|
|
278
|
+
if (value.length < MIN_SECRET_LENGTH || PLACEHOLDER_SECRETS.has(value.toLowerCase())) {
|
|
279
|
+
throw new ConfigError(
|
|
280
|
+
`\`${name}\` must be at least ${MIN_SECRET_LENGTH} characters and not a placeholder. Generate one with \`openssl rand -base64 32\`.`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
565
284
|
function validate(config, plugins) {
|
|
566
285
|
if (!config.database) throw new ConfigError("`database` is required.");
|
|
567
286
|
if (!config.notifications) throw new ConfigError("`notifications` is required.");
|
|
@@ -575,6 +294,12 @@ function validate(config, plugins) {
|
|
|
575
294
|
"`secret` is required \u2014 it signs unsubscribe and other session-less links. Adding it later is a breaking change, so it is mandatory from the start."
|
|
576
295
|
);
|
|
577
296
|
}
|
|
297
|
+
checkSecretStrength("secret", config.secret);
|
|
298
|
+
if (config.cron?.secret) checkSecretStrength("cron.secret", config.cron.secret);
|
|
299
|
+
if (config.machineSecret) checkSecretStrength("machineSecret", config.machineSecret);
|
|
300
|
+
if (config.rateLimit && (config.rateLimit.max < 1 || config.rateLimit.windowMs < 1)) {
|
|
301
|
+
throw new ConfigError("`rateLimit.max` and `rateLimit.windowMs` must both be positive.");
|
|
302
|
+
}
|
|
578
303
|
if (typeof config.getRecipients !== "function") {
|
|
579
304
|
throw new ConfigError("`getRecipients` is required to resolve emails and timezones.");
|
|
580
305
|
}
|
|
@@ -643,6 +368,15 @@ function easyPing(config) {
|
|
|
643
368
|
"delivery.mode is 'deferred' but no delivery.waitUntil was supplied; delivery will wait for the cron sweep."
|
|
644
369
|
);
|
|
645
370
|
}
|
|
371
|
+
for (const plugin of plugins) {
|
|
372
|
+
for (const route of plugin.routes ?? []) {
|
|
373
|
+
if (route.scope.type === "custom") {
|
|
374
|
+
warnings.push(
|
|
375
|
+
`plugin "${plugin.id}" mounts ${route.method} ${route.path} with custom auth: ` + route.scope.justification
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
646
380
|
for (const warning of warnings) logger.warn(warning);
|
|
647
381
|
const runner = createRunner({
|
|
648
382
|
adapter: config.database,
|
|
@@ -664,7 +398,13 @@ function easyPing(config) {
|
|
|
664
398
|
secret: config.secret,
|
|
665
399
|
basePath: config.basePath ?? DEFAULTS.basePath,
|
|
666
400
|
pluginRoutes,
|
|
667
|
-
|
|
401
|
+
trustedOrigins: config.trustedOrigins,
|
|
402
|
+
onRequest: config.onRequest,
|
|
403
|
+
maxBodyBytes: config.maxBodyBytes,
|
|
404
|
+
rateLimit: config.rateLimit ? createRateLimiter(config.rateLimit) : void 0,
|
|
405
|
+
cronMaxSweeps: config.cron?.maxSweeps,
|
|
406
|
+
...config.cron?.secret ? { cronSecret: config.cron.secret } : {},
|
|
407
|
+
...config.machineSecret ?? config.cron?.secret ? { machineSecret: config.machineSecret ?? config.cron?.secret } : {}
|
|
668
408
|
});
|
|
669
409
|
async function send(type, args) {
|
|
670
410
|
const result = await runSend(
|
|
@@ -696,10 +436,21 @@ function easyPing(config) {
|
|
|
696
436
|
return result;
|
|
697
437
|
}
|
|
698
438
|
for (const plugin of plugins) {
|
|
439
|
+
const purposes = new Set(
|
|
440
|
+
(plugin.routes ?? []).flatMap(
|
|
441
|
+
(route) => route.scope.type === "signed" ? [route.scope.purpose] : []
|
|
442
|
+
)
|
|
443
|
+
);
|
|
699
444
|
plugin.init?.({
|
|
700
445
|
store: createPluginStore(plugin.id, plugin.schema, config.database, tablePrefix),
|
|
701
|
-
|
|
446
|
+
sign: createScopedSigner(
|
|
447
|
+
config.secret,
|
|
448
|
+
plugin.id,
|
|
449
|
+
purposes,
|
|
450
|
+
(message) => new ConfigError(message)
|
|
451
|
+
),
|
|
702
452
|
logger,
|
|
453
|
+
notificationTypes: Object.keys(config.notifications),
|
|
703
454
|
getRecipients: config.getRecipients,
|
|
704
455
|
send: (type, args) => send(type, args)
|
|
705
456
|
});
|
|
@@ -708,6 +459,17 @@ function easyPing(config) {
|
|
|
708
459
|
send,
|
|
709
460
|
handler,
|
|
710
461
|
startWorker: (options) => runner.startWorker(options ?? {}),
|
|
462
|
+
listRoutes: () => [
|
|
463
|
+
...CORE_ROUTES.filter((route) => route.path !== "/cron" || Boolean(config.cron?.secret)),
|
|
464
|
+
...plugins.flatMap(
|
|
465
|
+
(plugin) => (plugin.routes ?? []).map((route) => ({
|
|
466
|
+
method: route.method,
|
|
467
|
+
path: route.path,
|
|
468
|
+
scope: route.scope,
|
|
469
|
+
owner: plugin.id
|
|
470
|
+
}))
|
|
471
|
+
)
|
|
472
|
+
],
|
|
711
473
|
getFailedDeliveries: (options = {}) => config.database.getFailedDeliveries({
|
|
712
474
|
since: options.since ?? new Date(Date.now() - 24 * 36e5),
|
|
713
475
|
limit: Math.min(options.limit ?? 100, 1e3)
|
|
@@ -724,6 +486,6 @@ function easyPing(config) {
|
|
|
724
486
|
};
|
|
725
487
|
}
|
|
726
488
|
|
|
727
|
-
export {
|
|
489
|
+
export { MIN_SECRET_LENGTH, createRateLimiter, defineNotification, easyPing, escapeHtml };
|
|
728
490
|
//# sourceMappingURL=index.js.map
|
|
729
491
|
//# sourceMappingURL=index.js.map
|