easy-ping 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/LICENSE +21 -0
- package/README.md +374 -0
- package/dist/adapter-f0OR2DhY.d.cts +200 -0
- package/dist/adapter-f0OR2DhY.d.ts +200 -0
- package/dist/adapters/drizzle.cjs +440 -0
- package/dist/adapters/drizzle.cjs.map +1 -0
- package/dist/adapters/drizzle.d.cts +991 -0
- package/dist/adapters/drizzle.d.ts +991 -0
- package/dist/adapters/drizzle.js +409 -0
- package/dist/adapters/drizzle.js.map +1 -0
- package/dist/adapters/mongodb.cjs +458 -0
- package/dist/adapters/mongodb.cjs.map +1 -0
- package/dist/adapters/mongodb.d.cts +32 -0
- package/dist/adapters/mongodb.d.ts +32 -0
- package/dist/adapters/mongodb.js +376 -0
- package/dist/adapters/mongodb.js.map +1 -0
- package/dist/browser.cjs +96 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +45 -0
- package/dist/browser.d.ts +45 -0
- package/dist/browser.js +89 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-BV3U542P.js +11 -0
- package/dist/chunk-BV3U542P.js.map +1 -0
- package/dist/chunk-NYDVNMKG.js +62 -0
- package/dist/chunk-NYDVNMKG.js.map +1 -0
- package/dist/chunk-OZSOIX23.js +120 -0
- package/dist/chunk-OZSOIX23.js.map +1 -0
- package/dist/chunk-PTGHMCCG.js +23 -0
- package/dist/chunk-PTGHMCCG.js.map +1 -0
- package/dist/chunk-W3BMEPPQ.js +50 -0
- package/dist/chunk-W3BMEPPQ.js.map +1 -0
- package/dist/client.cjs +160 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +42 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.js +158 -0
- package/dist/client.js.map +1 -0
- package/dist/index.cjs +920 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +203 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +723 -0
- package/dist/index.js.map +1 -0
- package/dist/node.cjs +49 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +8 -0
- package/dist/node.d.ts +8 -0
- package/dist/node.js +46 -0
- package/dist/node.js.map +1 -0
- package/dist/plugin-Bqj9xAOS.d.cts +217 -0
- package/dist/plugin-bTJKmnlq.d.ts +217 -0
- package/dist/plugins/digests.cjs +206 -0
- package/dist/plugins/digests.cjs.map +1 -0
- package/dist/plugins/digests.d.cts +68 -0
- package/dist/plugins/digests.d.ts +68 -0
- package/dist/plugins/digests.js +201 -0
- package/dist/plugins/digests.js.map +1 -0
- package/dist/plugins/preferences-client.cjs +23 -0
- package/dist/plugins/preferences-client.cjs.map +1 -0
- package/dist/plugins/preferences-client.d.cts +29 -0
- package/dist/plugins/preferences-client.d.ts +29 -0
- package/dist/plugins/preferences-client.js +18 -0
- package/dist/plugins/preferences-client.js.map +1 -0
- package/dist/plugins/preferences-react.cjs +125 -0
- package/dist/plugins/preferences-react.cjs.map +1 -0
- package/dist/plugins/preferences-react.d.cts +40 -0
- package/dist/plugins/preferences-react.d.ts +40 -0
- package/dist/plugins/preferences-react.js +101 -0
- package/dist/plugins/preferences-react.js.map +1 -0
- package/dist/plugins/preferences.cjs +147 -0
- package/dist/plugins/preferences.cjs.map +1 -0
- package/dist/plugins/preferences.d.cts +22 -0
- package/dist/plugins/preferences.d.ts +22 -0
- package/dist/plugins/preferences.js +119 -0
- package/dist/plugins/preferences.js.map +1 -0
- package/dist/plugins/push.cjs +146 -0
- package/dist/plugins/push.cjs.map +1 -0
- package/dist/plugins/push.d.cts +60 -0
- package/dist/plugins/push.d.ts +60 -0
- package/dist/plugins/push.js +144 -0
- package/dist/plugins/push.js.map +1 -0
- package/dist/provider-CA45WVqt.d.cts +23 -0
- package/dist/provider-CA45WVqt.d.ts +23 -0
- package/dist/providers/resend.cjs +69 -0
- package/dist/providers/resend.cjs.map +1 -0
- package/dist/providers/resend.d.cts +20 -0
- package/dist/providers/resend.d.ts +20 -0
- package/dist/providers/resend.js +66 -0
- package/dist/providers/resend.js.map +1 -0
- package/dist/providers/web-push.cjs +211 -0
- package/dist/providers/web-push.cjs.map +1 -0
- package/dist/providers/web-push.d.cts +33 -0
- package/dist/providers/web-push.d.ts +33 -0
- package/dist/providers/web-push.js +193 -0
- package/dist/providers/web-push.js.map +1 -0
- package/dist/react.cjs +218 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +57 -0
- package/dist/react.d.ts +57 -0
- package/dist/react.js +193 -0
- package/dist/react.js.map +1 -0
- package/dist/schema.cjs +263 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +180 -0
- package/dist/schema.d.ts +180 -0
- package/dist/schema.js +200 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing.cjs +342 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +38 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +340 -0
- package/dist/testing.js.map +1 -0
- package/package.json +168 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/push/index.ts"],"names":[],"mappings":"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","file":"push.cjs","sourcesContent":["import type { DeliveryOutcome } from \"../../core/adapter\";\nimport type { DeliverContext, EasyPingPlugin, PluginInitContext } from \"../../core/plugin\";\n\nconst DEVICE = \"notification_push_device\";\n\nexport type PushSubscription = {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n};\n\nexport type PushMessage = {\n subscription: PushSubscription;\n title: string;\n body: string;\n data?: Record<string, unknown> | undefined;\n signal?: AbortSignal | undefined;\n};\n\nexport type PushSendResult = {\n /** True when the endpoint is permanently gone (404/410) and must be pruned. */\n expired?: boolean;\n retryable?: boolean;\n};\n\nexport type PushProvider = {\n name: string;\n send(message: PushMessage): Promise<PushSendResult>;\n};\n\nexport type PushDevice = {\n id: string;\n userId: string;\n endpoint: string;\n p256dh: string;\n auth: string;\n userAgent: string | null;\n createdAt: Date;\n lastSeenAt: Date;\n};\n\nexport type PushOptions = {\n provider: PushProvider;\n /** Builds the notification shown on the device. */\n render: (input: { type: string; payload: unknown }) => { title: string; body: string };\n /** Devices untouched for this long are pruned on next delivery. */\n staleAfterDays?: number;\n};\n\n/**\n * Web push over the device registry.\n *\n * Tokens are per device, not per user, so a delivery fans out and succeeds if\n * any endpoint accepts. Endpoints that report themselves gone are deleted\n * immediately: an unpruned registry accumulates dead subscriptions forever and\n * every send slows down behind them.\n */\nexport function push(options: PushOptions): EasyPingPlugin<\"push\"> {\n let ctx: PluginInitContext;\n const staleAfterDays = options.staleAfterDays ?? 180;\n\n return {\n id: \"push\",\n channels: [\"push\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n pushDevice: {\n tableName: DEVICE,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n endpoint: { type: \"string\", required: true },\n p256dh: { type: \"string\", required: true },\n auth: { type: \"string\", required: true },\n userAgent: { 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: \"push_device_user_idx\" },\n // One row per endpoint: re-registering the same browser must update,\n // not duplicate, or every send goes out N times to one device.\n { on: [\"endpoint\"], unique: true, name: \"push_device_endpoint_idx\" },\n ],\n },\n },\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const devices = await ctx.store.find<PushDevice>(DEVICE, {\n userId: context.notification.userId,\n });\n\n if (devices.length === 0) {\n return { result: \"failed\", error: \"no registered devices\", retryable: false };\n }\n\n const content = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n\n const expired: string[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n\n for (const device of devices) {\n try {\n const result = await options.provider.send({\n subscription: {\n endpoint: device.endpoint,\n keys: { p256dh: device.p256dh, auth: device.auth },\n },\n ...content,\n data: { notificationId: context.notification.id, type: context.notification.type },\n signal: context.signal,\n });\n\n if (result.expired) expired.push(device.id);\n else {\n delivered += 1;\n if (result.retryable) retryable = true;\n }\n } catch (error) {\n lastError = error instanceof Error ? error.message : String(error);\n retryable = true;\n }\n }\n\n if (expired.length > 0) await ctx.store.remove(DEVICE, { id: { in: expired } });\n\n if (delivered > 0) return { result: \"sent\" };\n\n // Every endpoint was gone: retrying cannot help, and the rows are\n // already deleted.\n if (expired.length === devices.length) {\n return { result: \"failed\", error: \"all devices expired\", retryable: false };\n }\n\n return {\n result: \"failed\",\n error: lastError || \"no device accepted the push\",\n retryable,\n };\n },\n },\n\n routes: [\n {\n path: \"/push/devices\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as PushSubscription | null;\n if (!body?.endpoint || !body.keys?.p256dh || !body.keys?.auth) {\n return Response.json({ error: \"endpoint and keys are required\" }, { status: 400 });\n }\n\n // Upsert on endpoint: the same browser re-registering after a\n // service-worker update must not create a second row.\n await ctx.store.upsert(\n DEVICE,\n [\n {\n id: crypto.randomUUID(),\n userId: userId ?? \"\",\n endpoint: body.endpoint,\n p256dh: body.keys.p256dh,\n auth: body.keys.auth,\n userAgent: request.headers.get(\"user-agent\"),\n createdAt: new Date(),\n lastSeenAt: new Date(),\n },\n ],\n { onConflict: [\"endpoint\"] },\n );\n\n return Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/devices/remove\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as { endpoint?: string } | null;\n if (!body?.endpoint) {\n return Response.json({ error: \"endpoint is required\" }, { status: 400 });\n }\n\n // Scoped by userId as well as endpoint: an endpoint string is not a\n // secret, and must not let one user unregister another's device.\n const removed = await ctx.store.remove(DEVICE, {\n userId: userId ?? \"\",\n endpoint: body.endpoint,\n });\n\n return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });\n },\n },\n\n {\n path: \"/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}\n"]}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-Bqj9xAOS.cjs';
|
|
2
|
+
import '../adapter-f0OR2DhY.cjs';
|
|
3
|
+
|
|
4
|
+
type PushSubscription = {
|
|
5
|
+
endpoint: string;
|
|
6
|
+
keys: {
|
|
7
|
+
p256dh: string;
|
|
8
|
+
auth: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
type PushMessage = {
|
|
12
|
+
subscription: PushSubscription;
|
|
13
|
+
title: string;
|
|
14
|
+
body: string;
|
|
15
|
+
data?: Record<string, unknown> | undefined;
|
|
16
|
+
signal?: AbortSignal | undefined;
|
|
17
|
+
};
|
|
18
|
+
type PushSendResult = {
|
|
19
|
+
/** True when the endpoint is permanently gone (404/410) and must be pruned. */
|
|
20
|
+
expired?: boolean;
|
|
21
|
+
retryable?: boolean;
|
|
22
|
+
};
|
|
23
|
+
type PushProvider = {
|
|
24
|
+
name: string;
|
|
25
|
+
send(message: PushMessage): Promise<PushSendResult>;
|
|
26
|
+
};
|
|
27
|
+
type PushDevice = {
|
|
28
|
+
id: string;
|
|
29
|
+
userId: string;
|
|
30
|
+
endpoint: string;
|
|
31
|
+
p256dh: string;
|
|
32
|
+
auth: string;
|
|
33
|
+
userAgent: string | null;
|
|
34
|
+
createdAt: Date;
|
|
35
|
+
lastSeenAt: Date;
|
|
36
|
+
};
|
|
37
|
+
type PushOptions = {
|
|
38
|
+
provider: PushProvider;
|
|
39
|
+
/** Builds the notification shown on the device. */
|
|
40
|
+
render: (input: {
|
|
41
|
+
type: string;
|
|
42
|
+
payload: unknown;
|
|
43
|
+
}) => {
|
|
44
|
+
title: string;
|
|
45
|
+
body: string;
|
|
46
|
+
};
|
|
47
|
+
/** Devices untouched for this long are pruned on next delivery. */
|
|
48
|
+
staleAfterDays?: number;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Web push over the device registry.
|
|
52
|
+
*
|
|
53
|
+
* Tokens are per device, not per user, so a delivery fans out and succeeds if
|
|
54
|
+
* any endpoint accepts. Endpoints that report themselves gone are deleted
|
|
55
|
+
* immediately: an unpruned registry accumulates dead subscriptions forever and
|
|
56
|
+
* every send slows down behind them.
|
|
57
|
+
*/
|
|
58
|
+
declare function push(options: PushOptions): EasyPingPlugin<"push">;
|
|
59
|
+
|
|
60
|
+
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { h as EasyPingPlugin } from '../plugin-bTJKmnlq.js';
|
|
2
|
+
import '../adapter-f0OR2DhY.js';
|
|
3
|
+
|
|
4
|
+
type PushSubscription = {
|
|
5
|
+
endpoint: string;
|
|
6
|
+
keys: {
|
|
7
|
+
p256dh: string;
|
|
8
|
+
auth: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
type PushMessage = {
|
|
12
|
+
subscription: PushSubscription;
|
|
13
|
+
title: string;
|
|
14
|
+
body: string;
|
|
15
|
+
data?: Record<string, unknown> | undefined;
|
|
16
|
+
signal?: AbortSignal | undefined;
|
|
17
|
+
};
|
|
18
|
+
type PushSendResult = {
|
|
19
|
+
/** True when the endpoint is permanently gone (404/410) and must be pruned. */
|
|
20
|
+
expired?: boolean;
|
|
21
|
+
retryable?: boolean;
|
|
22
|
+
};
|
|
23
|
+
type PushProvider = {
|
|
24
|
+
name: string;
|
|
25
|
+
send(message: PushMessage): Promise<PushSendResult>;
|
|
26
|
+
};
|
|
27
|
+
type PushDevice = {
|
|
28
|
+
id: string;
|
|
29
|
+
userId: string;
|
|
30
|
+
endpoint: string;
|
|
31
|
+
p256dh: string;
|
|
32
|
+
auth: string;
|
|
33
|
+
userAgent: string | null;
|
|
34
|
+
createdAt: Date;
|
|
35
|
+
lastSeenAt: Date;
|
|
36
|
+
};
|
|
37
|
+
type PushOptions = {
|
|
38
|
+
provider: PushProvider;
|
|
39
|
+
/** Builds the notification shown on the device. */
|
|
40
|
+
render: (input: {
|
|
41
|
+
type: string;
|
|
42
|
+
payload: unknown;
|
|
43
|
+
}) => {
|
|
44
|
+
title: string;
|
|
45
|
+
body: string;
|
|
46
|
+
};
|
|
47
|
+
/** Devices untouched for this long are pruned on next delivery. */
|
|
48
|
+
staleAfterDays?: number;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Web push over the device registry.
|
|
52
|
+
*
|
|
53
|
+
* Tokens are per device, not per user, so a delivery fans out and succeeds if
|
|
54
|
+
* any endpoint accepts. Endpoints that report themselves gone are deleted
|
|
55
|
+
* immediately: an unpruned registry accumulates dead subscriptions forever and
|
|
56
|
+
* every send slows down behind them.
|
|
57
|
+
*/
|
|
58
|
+
declare function push(options: PushOptions): EasyPingPlugin<"push">;
|
|
59
|
+
|
|
60
|
+
export { type PushDevice, type PushMessage, type PushOptions, type PushProvider, type PushSendResult, type PushSubscription, push };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// src/plugins/push/index.ts
|
|
2
|
+
var DEVICE = "notification_push_device";
|
|
3
|
+
function push(options) {
|
|
4
|
+
let ctx;
|
|
5
|
+
const staleAfterDays = options.staleAfterDays ?? 180;
|
|
6
|
+
return {
|
|
7
|
+
id: "push",
|
|
8
|
+
channels: ["push"],
|
|
9
|
+
init: (context) => {
|
|
10
|
+
ctx = context;
|
|
11
|
+
},
|
|
12
|
+
schema: {
|
|
13
|
+
pushDevice: {
|
|
14
|
+
tableName: DEVICE,
|
|
15
|
+
fields: {
|
|
16
|
+
id: { type: "string", required: true },
|
|
17
|
+
userId: { type: "string", required: true },
|
|
18
|
+
endpoint: { type: "string", required: true },
|
|
19
|
+
p256dh: { type: "string", required: true },
|
|
20
|
+
auth: { type: "string", required: true },
|
|
21
|
+
userAgent: { type: "string" },
|
|
22
|
+
createdAt: { type: "date", required: true, defaultNow: true },
|
|
23
|
+
lastSeenAt: { type: "date", required: true, defaultNow: true }
|
|
24
|
+
},
|
|
25
|
+
primaryKey: ["id"],
|
|
26
|
+
indexes: [
|
|
27
|
+
{ on: ["userId"], name: "push_device_user_idx" },
|
|
28
|
+
// One row per endpoint: re-registering the same browser must update,
|
|
29
|
+
// not duplicate, or every send goes out N times to one device.
|
|
30
|
+
{ on: ["endpoint"], unique: true, name: "push_device_endpoint_idx" }
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
hooks: {
|
|
35
|
+
deliver: async (context) => {
|
|
36
|
+
const devices = await ctx.store.find(DEVICE, {
|
|
37
|
+
userId: context.notification.userId
|
|
38
|
+
});
|
|
39
|
+
if (devices.length === 0) {
|
|
40
|
+
return { result: "failed", error: "no registered devices", retryable: false };
|
|
41
|
+
}
|
|
42
|
+
const content = options.render({
|
|
43
|
+
type: context.notification.type,
|
|
44
|
+
payload: context.notification.payload
|
|
45
|
+
});
|
|
46
|
+
const expired = [];
|
|
47
|
+
let delivered = 0;
|
|
48
|
+
let retryable = false;
|
|
49
|
+
let lastError = "";
|
|
50
|
+
for (const device of devices) {
|
|
51
|
+
try {
|
|
52
|
+
const result = await options.provider.send({
|
|
53
|
+
subscription: {
|
|
54
|
+
endpoint: device.endpoint,
|
|
55
|
+
keys: { p256dh: device.p256dh, auth: device.auth }
|
|
56
|
+
},
|
|
57
|
+
...content,
|
|
58
|
+
data: { notificationId: context.notification.id, type: context.notification.type },
|
|
59
|
+
signal: context.signal
|
|
60
|
+
});
|
|
61
|
+
if (result.expired) expired.push(device.id);
|
|
62
|
+
else {
|
|
63
|
+
delivered += 1;
|
|
64
|
+
if (result.retryable) retryable = true;
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
68
|
+
retryable = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (expired.length > 0) await ctx.store.remove(DEVICE, { id: { in: expired } });
|
|
72
|
+
if (delivered > 0) return { result: "sent" };
|
|
73
|
+
if (expired.length === devices.length) {
|
|
74
|
+
return { result: "failed", error: "all devices expired", retryable: false };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
result: "failed",
|
|
78
|
+
error: lastError || "no device accepted the push",
|
|
79
|
+
retryable
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
routes: [
|
|
84
|
+
{
|
|
85
|
+
path: "/push/devices",
|
|
86
|
+
method: "POST",
|
|
87
|
+
scope: { type: "user" },
|
|
88
|
+
handler: async ({ request, userId }) => {
|
|
89
|
+
const body = await request.json().catch(() => null);
|
|
90
|
+
if (!body?.endpoint || !body.keys?.p256dh || !body.keys?.auth) {
|
|
91
|
+
return Response.json({ error: "endpoint and keys are required" }, { status: 400 });
|
|
92
|
+
}
|
|
93
|
+
await ctx.store.upsert(
|
|
94
|
+
DEVICE,
|
|
95
|
+
[
|
|
96
|
+
{
|
|
97
|
+
id: crypto.randomUUID(),
|
|
98
|
+
userId: userId ?? "",
|
|
99
|
+
endpoint: body.endpoint,
|
|
100
|
+
p256dh: body.keys.p256dh,
|
|
101
|
+
auth: body.keys.auth,
|
|
102
|
+
userAgent: request.headers.get("user-agent"),
|
|
103
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
104
|
+
lastSeenAt: /* @__PURE__ */ new Date()
|
|
105
|
+
}
|
|
106
|
+
],
|
|
107
|
+
{ onConflict: ["endpoint"] }
|
|
108
|
+
);
|
|
109
|
+
return Response.json({ ok: true });
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
path: "/push/devices/remove",
|
|
114
|
+
method: "POST",
|
|
115
|
+
scope: { type: "user" },
|
|
116
|
+
handler: async ({ request, userId }) => {
|
|
117
|
+
const body = await request.json().catch(() => null);
|
|
118
|
+
if (!body?.endpoint) {
|
|
119
|
+
return Response.json({ error: "endpoint is required" }, { status: 400 });
|
|
120
|
+
}
|
|
121
|
+
const removed = await ctx.store.remove(DEVICE, {
|
|
122
|
+
userId: userId ?? "",
|
|
123
|
+
endpoint: body.endpoint
|
|
124
|
+
});
|
|
125
|
+
return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
path: "/push/prune",
|
|
130
|
+
method: "POST",
|
|
131
|
+
scope: { type: "machine" },
|
|
132
|
+
handler: async () => {
|
|
133
|
+
const cutoff = new Date(Date.now() - staleAfterDays * 864e5);
|
|
134
|
+
const removed = await ctx.store.remove(DEVICE, { lastSeenAt: { lt: cutoff } });
|
|
135
|
+
return Response.json({ removed });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export { push };
|
|
143
|
+
//# sourceMappingURL=push.js.map
|
|
144
|
+
//# sourceMappingURL=push.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/plugins/push/index.ts"],"names":[],"mappings":"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","file":"push.js","sourcesContent":["import type { DeliveryOutcome } from \"../../core/adapter\";\nimport type { DeliverContext, EasyPingPlugin, PluginInitContext } from \"../../core/plugin\";\n\nconst DEVICE = \"notification_push_device\";\n\nexport type PushSubscription = {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n};\n\nexport type PushMessage = {\n subscription: PushSubscription;\n title: string;\n body: string;\n data?: Record<string, unknown> | undefined;\n signal?: AbortSignal | undefined;\n};\n\nexport type PushSendResult = {\n /** True when the endpoint is permanently gone (404/410) and must be pruned. */\n expired?: boolean;\n retryable?: boolean;\n};\n\nexport type PushProvider = {\n name: string;\n send(message: PushMessage): Promise<PushSendResult>;\n};\n\nexport type PushDevice = {\n id: string;\n userId: string;\n endpoint: string;\n p256dh: string;\n auth: string;\n userAgent: string | null;\n createdAt: Date;\n lastSeenAt: Date;\n};\n\nexport type PushOptions = {\n provider: PushProvider;\n /** Builds the notification shown on the device. */\n render: (input: { type: string; payload: unknown }) => { title: string; body: string };\n /** Devices untouched for this long are pruned on next delivery. */\n staleAfterDays?: number;\n};\n\n/**\n * Web push over the device registry.\n *\n * Tokens are per device, not per user, so a delivery fans out and succeeds if\n * any endpoint accepts. Endpoints that report themselves gone are deleted\n * immediately: an unpruned registry accumulates dead subscriptions forever and\n * every send slows down behind them.\n */\nexport function push(options: PushOptions): EasyPingPlugin<\"push\"> {\n let ctx: PluginInitContext;\n const staleAfterDays = options.staleAfterDays ?? 180;\n\n return {\n id: \"push\",\n channels: [\"push\"],\n\n init: (context) => {\n ctx = context;\n },\n\n schema: {\n pushDevice: {\n tableName: DEVICE,\n fields: {\n id: { type: \"string\", required: true },\n userId: { type: \"string\", required: true },\n endpoint: { type: \"string\", required: true },\n p256dh: { type: \"string\", required: true },\n auth: { type: \"string\", required: true },\n userAgent: { 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: \"push_device_user_idx\" },\n // One row per endpoint: re-registering the same browser must update,\n // not duplicate, or every send goes out N times to one device.\n { on: [\"endpoint\"], unique: true, name: \"push_device_endpoint_idx\" },\n ],\n },\n },\n\n hooks: {\n deliver: async (context: DeliverContext): Promise<DeliveryOutcome> => {\n const devices = await ctx.store.find<PushDevice>(DEVICE, {\n userId: context.notification.userId,\n });\n\n if (devices.length === 0) {\n return { result: \"failed\", error: \"no registered devices\", retryable: false };\n }\n\n const content = options.render({\n type: context.notification.type,\n payload: context.notification.payload,\n });\n\n const expired: string[] = [];\n let delivered = 0;\n let retryable = false;\n let lastError = \"\";\n\n for (const device of devices) {\n try {\n const result = await options.provider.send({\n subscription: {\n endpoint: device.endpoint,\n keys: { p256dh: device.p256dh, auth: device.auth },\n },\n ...content,\n data: { notificationId: context.notification.id, type: context.notification.type },\n signal: context.signal,\n });\n\n if (result.expired) expired.push(device.id);\n else {\n delivered += 1;\n if (result.retryable) retryable = true;\n }\n } catch (error) {\n lastError = error instanceof Error ? error.message : String(error);\n retryable = true;\n }\n }\n\n if (expired.length > 0) await ctx.store.remove(DEVICE, { id: { in: expired } });\n\n if (delivered > 0) return { result: \"sent\" };\n\n // Every endpoint was gone: retrying cannot help, and the rows are\n // already deleted.\n if (expired.length === devices.length) {\n return { result: \"failed\", error: \"all devices expired\", retryable: false };\n }\n\n return {\n result: \"failed\",\n error: lastError || \"no device accepted the push\",\n retryable,\n };\n },\n },\n\n routes: [\n {\n path: \"/push/devices\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as PushSubscription | null;\n if (!body?.endpoint || !body.keys?.p256dh || !body.keys?.auth) {\n return Response.json({ error: \"endpoint and keys are required\" }, { status: 400 });\n }\n\n // Upsert on endpoint: the same browser re-registering after a\n // service-worker update must not create a second row.\n await ctx.store.upsert(\n DEVICE,\n [\n {\n id: crypto.randomUUID(),\n userId: userId ?? \"\",\n endpoint: body.endpoint,\n p256dh: body.keys.p256dh,\n auth: body.keys.auth,\n userAgent: request.headers.get(\"user-agent\"),\n createdAt: new Date(),\n lastSeenAt: new Date(),\n },\n ],\n { onConflict: [\"endpoint\"] },\n );\n\n return Response.json({ ok: true });\n },\n },\n\n {\n path: \"/push/devices/remove\",\n method: \"POST\",\n scope: { type: \"user\" },\n handler: async ({ request, userId }) => {\n const body = (await request.json().catch(() => null)) as { endpoint?: string } | null;\n if (!body?.endpoint) {\n return Response.json({ error: \"endpoint is required\" }, { status: 400 });\n }\n\n // Scoped by userId as well as endpoint: an endpoint string is not a\n // secret, and must not let one user unregister another's device.\n const removed = await ctx.store.remove(DEVICE, {\n userId: userId ?? \"\",\n endpoint: body.endpoint,\n });\n\n return removed === 0 ? new Response(null, { status: 404 }) : Response.json({ ok: true });\n },\n },\n\n {\n path: \"/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}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type ProviderSendResult = {
|
|
2
|
+
providerMessageId?: string | undefined;
|
|
3
|
+
};
|
|
4
|
+
type EmailMessage = {
|
|
5
|
+
to: string;
|
|
6
|
+
subject: string;
|
|
7
|
+
html: string;
|
|
8
|
+
text?: string | undefined;
|
|
9
|
+
headers?: Record<string, string> | undefined;
|
|
10
|
+
/** Derived from the delivery id. Delivery is at-least-once; see RFC 0003. */
|
|
11
|
+
idempotencyKey: string;
|
|
12
|
+
/** Aborted on timeout; honour it or the request outlives the recorded failure. */
|
|
13
|
+
signal?: AbortSignal | undefined;
|
|
14
|
+
};
|
|
15
|
+
type EmailProvider = {
|
|
16
|
+
name: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
send(message: EmailMessage): Promise<ProviderSendResult>;
|
|
19
|
+
/** False fails the delivery outright; a revoked key is not worth 45min of backoff. */
|
|
20
|
+
isRetryable(error: unknown): boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type { EmailProvider as E, ProviderSendResult as P, EmailMessage as a };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type ProviderSendResult = {
|
|
2
|
+
providerMessageId?: string | undefined;
|
|
3
|
+
};
|
|
4
|
+
type EmailMessage = {
|
|
5
|
+
to: string;
|
|
6
|
+
subject: string;
|
|
7
|
+
html: string;
|
|
8
|
+
text?: string | undefined;
|
|
9
|
+
headers?: Record<string, string> | undefined;
|
|
10
|
+
/** Derived from the delivery id. Delivery is at-least-once; see RFC 0003. */
|
|
11
|
+
idempotencyKey: string;
|
|
12
|
+
/** Aborted on timeout; honour it or the request outlives the recorded failure. */
|
|
13
|
+
signal?: AbortSignal | undefined;
|
|
14
|
+
};
|
|
15
|
+
type EmailProvider = {
|
|
16
|
+
name: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
send(message: EmailMessage): Promise<ProviderSendResult>;
|
|
19
|
+
/** False fails the delivery outright; a revoked key is not worth 45min of backoff. */
|
|
20
|
+
isRetryable(error: unknown): boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type { EmailProvider as E, ProviderSendResult as P, EmailMessage as a };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/providers/resend/index.ts
|
|
4
|
+
var ResendError = class extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ResendError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
function classify(status) {
|
|
13
|
+
if (status === 408 || status === 429) return true;
|
|
14
|
+
return status >= 500;
|
|
15
|
+
}
|
|
16
|
+
function resend(options) {
|
|
17
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
18
|
+
const baseUrl = options.baseUrl ?? "https://api.resend.com";
|
|
19
|
+
return {
|
|
20
|
+
name: "resend",
|
|
21
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
|
|
22
|
+
async send(message) {
|
|
23
|
+
let response;
|
|
24
|
+
try {
|
|
25
|
+
response = await doFetch(`${baseUrl}/emails`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
...message.signal ? { signal: message.signal } : {},
|
|
28
|
+
headers: {
|
|
29
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
// Delivery is at-least-once; a retry must not send twice.
|
|
32
|
+
"Idempotency-Key": message.idempotencyKey
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
from: options.from,
|
|
36
|
+
to: message.to,
|
|
37
|
+
subject: message.subject,
|
|
38
|
+
html: message.html,
|
|
39
|
+
...message.text ? { text: message.text } : {},
|
|
40
|
+
...options.replyTo ? { reply_to: options.replyTo } : {},
|
|
41
|
+
...message.headers ? { headers: message.headers } : {}
|
|
42
|
+
})
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new ResendError(error instanceof Error ? error.message : String(error));
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const body = await response.text().catch(() => "");
|
|
49
|
+
throw new ResendError(
|
|
50
|
+
`resend responded ${response.status}${body ? `: ${body.slice(0, 500)}` : ""}`,
|
|
51
|
+
response.status
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const payload = await response.json().catch(() => null);
|
|
55
|
+
return payload?.id ? { providerMessageId: payload.id } : {};
|
|
56
|
+
},
|
|
57
|
+
isRetryable(error) {
|
|
58
|
+
if (error instanceof ResendError) {
|
|
59
|
+
return error.status === void 0 ? true : classify(error.status);
|
|
60
|
+
}
|
|
61
|
+
return error instanceof Error && error.message.includes("timed out");
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
exports.ResendError = ResendError;
|
|
67
|
+
exports.resend = resend;
|
|
68
|
+
//# sourceMappingURL=resend.cjs.map
|
|
69
|
+
//# sourceMappingURL=resend.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/providers/resend/index.ts"],"names":[],"mappings":"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","file":"resend.cjs","sourcesContent":["import type { EmailProvider } from \"../../core/provider\";\n\nexport type ResendOptions = {\n apiKey: string;\n /** Verified sender, e.g. \"Acme <notifications@acme.dev>\". */\n from: string;\n replyTo?: string;\n timeoutMs?: number;\n /** Overridable for tests. */\n fetch?: typeof globalThis.fetch;\n baseUrl?: string;\n};\n\nexport class ResendError extends Error {\n readonly status: number | undefined;\n\n constructor(message: string, status?: number) {\n super(message);\n this.name = \"ResendError\";\n this.status = status;\n }\n}\n\n/** Rate limits, timeouts and 5xx may work later. Auth and validation never will. */\nfunction classify(status: number): boolean {\n if (status === 408 || status === 429) return true;\n return status >= 500;\n}\n\n/** Plain fetch, no SDK: it is one POST, and adapters should not add deps. */\nexport function resend(options: ResendOptions): EmailProvider {\n const doFetch = options.fetch ?? globalThis.fetch;\n const baseUrl = options.baseUrl ?? \"https://api.resend.com\";\n\n return {\n name: \"resend\",\n ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),\n\n async send(message) {\n let response: Response;\n try {\n response = await doFetch(`${baseUrl}/emails`, {\n method: \"POST\",\n ...(message.signal ? { signal: message.signal } : {}),\n headers: {\n Authorization: `Bearer ${options.apiKey}`,\n \"Content-Type\": \"application/json\",\n // Delivery is at-least-once; a retry must not send twice.\n \"Idempotency-Key\": message.idempotencyKey,\n },\n body: JSON.stringify({\n from: options.from,\n to: message.to,\n subject: message.subject,\n html: message.html,\n ...(message.text ? { text: message.text } : {}),\n ...(options.replyTo ? { reply_to: options.replyTo } : {}),\n ...(message.headers ? { headers: message.headers } : {}),\n }),\n });\n } catch (error) {\n // Network-level failure — no status, always worth retrying.\n throw new ResendError(error instanceof Error ? error.message : String(error));\n }\n\n if (!response.ok) {\n const body = await response.text().catch(() => \"\");\n throw new ResendError(\n `resend responded ${response.status}${body ? `: ${body.slice(0, 500)}` : \"\"}`,\n response.status,\n );\n }\n\n const payload = (await response.json().catch(() => null)) as { id?: string } | null;\n return payload?.id ? { providerMessageId: payload.id } : {};\n },\n\n isRetryable(error) {\n if (error instanceof ResendError) {\n // No status means the request never landed: DNS, socket, timeout.\n return error.status === undefined ? true : classify(error.status);\n }\n // Timeouts from the runner's own guard arrive as plain Errors.\n return error instanceof Error && error.message.includes(\"timed out\");\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { E as EmailProvider } from '../provider-CA45WVqt.cjs';
|
|
2
|
+
|
|
3
|
+
type ResendOptions = {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
/** Verified sender, e.g. "Acme <notifications@acme.dev>". */
|
|
6
|
+
from: string;
|
|
7
|
+
replyTo?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
/** Overridable for tests. */
|
|
10
|
+
fetch?: typeof globalThis.fetch;
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
};
|
|
13
|
+
declare class ResendError extends Error {
|
|
14
|
+
readonly status: number | undefined;
|
|
15
|
+
constructor(message: string, status?: number);
|
|
16
|
+
}
|
|
17
|
+
/** Plain fetch, no SDK: it is one POST, and adapters should not add deps. */
|
|
18
|
+
declare function resend(options: ResendOptions): EmailProvider;
|
|
19
|
+
|
|
20
|
+
export { ResendError, type ResendOptions, resend };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { E as EmailProvider } from '../provider-CA45WVqt.js';
|
|
2
|
+
|
|
3
|
+
type ResendOptions = {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
/** Verified sender, e.g. "Acme <notifications@acme.dev>". */
|
|
6
|
+
from: string;
|
|
7
|
+
replyTo?: string;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
/** Overridable for tests. */
|
|
10
|
+
fetch?: typeof globalThis.fetch;
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
};
|
|
13
|
+
declare class ResendError extends Error {
|
|
14
|
+
readonly status: number | undefined;
|
|
15
|
+
constructor(message: string, status?: number);
|
|
16
|
+
}
|
|
17
|
+
/** Plain fetch, no SDK: it is one POST, and adapters should not add deps. */
|
|
18
|
+
declare function resend(options: ResendOptions): EmailProvider;
|
|
19
|
+
|
|
20
|
+
export { ResendError, type ResendOptions, resend };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/providers/resend/index.ts
|
|
2
|
+
var ResendError = class extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ResendError";
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
function classify(status) {
|
|
11
|
+
if (status === 408 || status === 429) return true;
|
|
12
|
+
return status >= 500;
|
|
13
|
+
}
|
|
14
|
+
function resend(options) {
|
|
15
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
16
|
+
const baseUrl = options.baseUrl ?? "https://api.resend.com";
|
|
17
|
+
return {
|
|
18
|
+
name: "resend",
|
|
19
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
|
|
20
|
+
async send(message) {
|
|
21
|
+
let response;
|
|
22
|
+
try {
|
|
23
|
+
response = await doFetch(`${baseUrl}/emails`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
...message.signal ? { signal: message.signal } : {},
|
|
26
|
+
headers: {
|
|
27
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
// Delivery is at-least-once; a retry must not send twice.
|
|
30
|
+
"Idempotency-Key": message.idempotencyKey
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
from: options.from,
|
|
34
|
+
to: message.to,
|
|
35
|
+
subject: message.subject,
|
|
36
|
+
html: message.html,
|
|
37
|
+
...message.text ? { text: message.text } : {},
|
|
38
|
+
...options.replyTo ? { reply_to: options.replyTo } : {},
|
|
39
|
+
...message.headers ? { headers: message.headers } : {}
|
|
40
|
+
})
|
|
41
|
+
});
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new ResendError(error instanceof Error ? error.message : String(error));
|
|
44
|
+
}
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const body = await response.text().catch(() => "");
|
|
47
|
+
throw new ResendError(
|
|
48
|
+
`resend responded ${response.status}${body ? `: ${body.slice(0, 500)}` : ""}`,
|
|
49
|
+
response.status
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const payload = await response.json().catch(() => null);
|
|
53
|
+
return payload?.id ? { providerMessageId: payload.id } : {};
|
|
54
|
+
},
|
|
55
|
+
isRetryable(error) {
|
|
56
|
+
if (error instanceof ResendError) {
|
|
57
|
+
return error.status === void 0 ? true : classify(error.status);
|
|
58
|
+
}
|
|
59
|
+
return error instanceof Error && error.message.includes("timed out");
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { ResendError, resend };
|
|
65
|
+
//# sourceMappingURL=resend.js.map
|
|
66
|
+
//# sourceMappingURL=resend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/providers/resend/index.ts"],"names":[],"mappings":";AAaO,IAAM,WAAA,GAAN,cAA0B,KAAA,CAAM;AAAA,EAC5B,MAAA;AAAA,EAET,WAAA,CAAY,SAAiB,MAAA,EAAiB;AAC5C,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,IAAA,GAAO,aAAA;AACZ,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AAAA,EAChB;AACF;AAGA,SAAS,SAAS,MAAA,EAAyB;AACzC,EAAA,IAAI,MAAA,KAAW,GAAA,IAAO,MAAA,KAAW,GAAA,EAAK,OAAO,IAAA;AAC7C,EAAA,OAAO,MAAA,IAAU,GAAA;AACnB;AAGO,SAAS,OAAO,OAAA,EAAuC;AAC5D,EAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,KAAA,IAAS,UAAA,CAAW,KAAA;AAC5C,EAAA,MAAM,OAAA,GAAU,QAAQ,OAAA,IAAW,wBAAA;AAEnC,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,QAAA;AAAA,IACN,GAAI,QAAQ,SAAA,KAAc,MAAA,GAAY,EAAC,GAAI,EAAE,SAAA,EAAW,OAAA,CAAQ,SAAA,EAAU;AAAA,IAE1E,MAAM,KAAK,OAAA,EAAS;AAClB,MAAA,IAAI,QAAA;AACJ,MAAA,IAAI;AACF,QAAA,QAAA,GAAW,MAAM,OAAA,CAAQ,CAAA,EAAG,OAAO,CAAA,OAAA,CAAA,EAAW;AAAA,UAC5C,MAAA,EAAQ,MAAA;AAAA,UACR,GAAI,QAAQ,MAAA,GAAS,EAAE,QAAQ,OAAA,CAAQ,MAAA,KAAW,EAAC;AAAA,UACnD,OAAA,EAAS;AAAA,YACP,aAAA,EAAe,CAAA,OAAA,EAAU,OAAA,CAAQ,MAAM,CAAA,CAAA;AAAA,YACvC,cAAA,EAAgB,kBAAA;AAAA;AAAA,YAEhB,mBAAmB,OAAA,CAAQ;AAAA,WAC7B;AAAA,UACA,IAAA,EAAM,KAAK,SAAA,CAAU;AAAA,YACnB,MAAM,OAAA,CAAQ,IAAA;AAAA,YACd,IAAI,OAAA,CAAQ,EAAA;AAAA,YACZ,SAAS,OAAA,CAAQ,OAAA;AAAA,YACjB,MAAM,OAAA,CAAQ,IAAA;AAAA,YACd,GAAI,QAAQ,IAAA,GAAO,EAAE,MAAM,OAAA,CAAQ,IAAA,KAAS,EAAC;AAAA,YAC7C,GAAI,QAAQ,OAAA,GAAU,EAAE,UAAU,OAAA,CAAQ,OAAA,KAAY,EAAC;AAAA,YACvD,GAAI,QAAQ,OAAA,GAAU,EAAE,SAAS,OAAA,CAAQ,OAAA,KAAY;AAAC,WACvD;AAAA,SACF,CAAA;AAAA,MACH,SAAS,KAAA,EAAO;AAEd,QAAA,MAAM,IAAI,YAAY,KAAA,YAAiB,KAAA,GAAQ,MAAM,OAAA,GAAU,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,MAC9E;AAEA,MAAA,IAAI,CAAC,SAAS,EAAA,EAAI;AAChB,QAAA,MAAM,OAAO,MAAM,QAAA,CAAS,MAAK,CAAE,KAAA,CAAM,MAAM,EAAE,CAAA;AACjD,QAAA,MAAM,IAAI,WAAA;AAAA,UACR,CAAA,iBAAA,EAAoB,QAAA,CAAS,MAAM,CAAA,EAAG,IAAA,GAAO,CAAA,EAAA,EAAK,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,GAAG,CAAC,CAAA,CAAA,GAAK,EAAE,CAAA,CAAA;AAAA,UAC3E,QAAA,CAAS;AAAA,SACX;AAAA,MACF;AAEA,MAAA,MAAM,UAAW,MAAM,QAAA,CAAS,MAAK,CAAE,KAAA,CAAM,MAAM,IAAI,CAAA;AACvD,MAAA,OAAO,SAAS,EAAA,GAAK,EAAE,mBAAmB,OAAA,CAAQ,EAAA,KAAO,EAAC;AAAA,IAC5D,CAAA;AAAA,IAEA,YAAY,KAAA,EAAO;AACjB,MAAA,IAAI,iBAAiB,WAAA,EAAa;AAEhC,QAAA,OAAO,MAAM,MAAA,KAAW,MAAA,GAAY,IAAA,GAAO,QAAA,CAAS,MAAM,MAAM,CAAA;AAAA,MAClE;AAEA,MAAA,OAAO,KAAA,YAAiB,KAAA,IAAS,KAAA,CAAM,OAAA,CAAQ,SAAS,WAAW,CAAA;AAAA,IACrE;AAAA,GACF;AACF","file":"resend.js","sourcesContent":["import type { EmailProvider } from \"../../core/provider\";\n\nexport type ResendOptions = {\n apiKey: string;\n /** Verified sender, e.g. \"Acme <notifications@acme.dev>\". */\n from: string;\n replyTo?: string;\n timeoutMs?: number;\n /** Overridable for tests. */\n fetch?: typeof globalThis.fetch;\n baseUrl?: string;\n};\n\nexport class ResendError extends Error {\n readonly status: number | undefined;\n\n constructor(message: string, status?: number) {\n super(message);\n this.name = \"ResendError\";\n this.status = status;\n }\n}\n\n/** Rate limits, timeouts and 5xx may work later. Auth and validation never will. */\nfunction classify(status: number): boolean {\n if (status === 408 || status === 429) return true;\n return status >= 500;\n}\n\n/** Plain fetch, no SDK: it is one POST, and adapters should not add deps. */\nexport function resend(options: ResendOptions): EmailProvider {\n const doFetch = options.fetch ?? globalThis.fetch;\n const baseUrl = options.baseUrl ?? \"https://api.resend.com\";\n\n return {\n name: \"resend\",\n ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),\n\n async send(message) {\n let response: Response;\n try {\n response = await doFetch(`${baseUrl}/emails`, {\n method: \"POST\",\n ...(message.signal ? { signal: message.signal } : {}),\n headers: {\n Authorization: `Bearer ${options.apiKey}`,\n \"Content-Type\": \"application/json\",\n // Delivery is at-least-once; a retry must not send twice.\n \"Idempotency-Key\": message.idempotencyKey,\n },\n body: JSON.stringify({\n from: options.from,\n to: message.to,\n subject: message.subject,\n html: message.html,\n ...(message.text ? { text: message.text } : {}),\n ...(options.replyTo ? { reply_to: options.replyTo } : {}),\n ...(message.headers ? { headers: message.headers } : {}),\n }),\n });\n } catch (error) {\n // Network-level failure — no status, always worth retrying.\n throw new ResendError(error instanceof Error ? error.message : String(error));\n }\n\n if (!response.ok) {\n const body = await response.text().catch(() => \"\");\n throw new ResendError(\n `resend responded ${response.status}${body ? `: ${body.slice(0, 500)}` : \"\"}`,\n response.status,\n );\n }\n\n const payload = (await response.json().catch(() => null)) as { id?: string } | null;\n return payload?.id ? { providerMessageId: payload.id } : {};\n },\n\n isRetryable(error) {\n if (error instanceof ResendError) {\n // No status means the request never landed: DNS, socket, timeout.\n return error.status === undefined ? true : classify(error.status);\n }\n // Timeouts from the runner's own guard arrive as plain Errors.\n return error instanceof Error && error.message.includes(\"timed out\");\n },\n };\n}\n"]}
|