@pramen/server 0.0.57 → 0.0.59
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/dist/durable-object.d.ts +12 -4
- package/dist/durable-object.js +58 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/mail.d.ts +34 -2
- package/dist/runtime/mail.js +82 -9
- package/dist/runtime/protocol.d.ts +4 -1
- package/package.json +1 -1
- package/src/durable-object.ts +62 -5
- package/src/index.ts +1 -1
- package/src/runtime/mail.ts +80 -9
- package/src/runtime/protocol.ts +4 -1
package/dist/durable-object.d.ts
CHANGED
|
@@ -38,10 +38,11 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
38
38
|
/** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
|
|
39
39
|
* attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
|
|
40
40
|
* JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
|
|
41
|
-
* tradeoff: this map is lost on DO hibernation/eviction
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
41
|
+
* tradeoff: this map is lost on DO hibernation/eviction. That is NOT self-healing —
|
|
42
|
+
* a hibernated socket stays OPEN, so the client sees no close, never replays, and
|
|
43
|
+
* every push to it is silently dropped forever. `subscribed` on the attachment is the
|
|
44
|
+
* one bit that survives to detect it; `subsFor` turns that into a close, and the close
|
|
45
|
+
* into a replay. Keyed by the WebSocket object; cleaned up in webSocketClose. */
|
|
45
46
|
private readonly subsBySocket;
|
|
46
47
|
constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
|
|
47
48
|
private ensureMigrated;
|
|
@@ -96,6 +97,13 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
96
97
|
* replays them on reconnect. */
|
|
97
98
|
private getSubs;
|
|
98
99
|
private setSubs;
|
|
100
|
+
/** This socket's subscriptions, or `null` if they were lost to hibernation.
|
|
101
|
+
*
|
|
102
|
+
* The two states the in-memory map cannot tell apart: a socket that has subscribed to
|
|
103
|
+
* nothing, and a socket whose subscriptions the map lost. Both read as an empty list;
|
|
104
|
+
* only the first is harmless. The attachment's `subscribed` bit is what survives
|
|
105
|
+
* hibernation, so it is what separates them. */
|
|
106
|
+
private subsFor;
|
|
99
107
|
private send;
|
|
100
108
|
}
|
|
101
109
|
/** Produce the concrete, app-bound Durable Object class. A DO is constructed by the
|
package/dist/durable-object.js
CHANGED
|
@@ -34,6 +34,9 @@ const MAX_SUBSCRIPTIONS = 64;
|
|
|
34
34
|
/** WebSocket close code for an auth failure (RFC 6455 leaves 4000-4999 to the app;
|
|
35
35
|
* 4401 mirrors HTTP 401). Sent when a socket's token has expired since upgrade. */
|
|
36
36
|
const WS_CLOSE_UNAUTHORIZED = 4401;
|
|
37
|
+
/** Application close code for "this socket's subscriptions did not survive hibernation".
|
|
38
|
+
* Sent so the client reconnects and replays them — which it already does on any close. */
|
|
39
|
+
const WS_CLOSE_RESUBSCRIBE = 4410;
|
|
37
40
|
export class PramenDOBase extends DurableObject {
|
|
38
41
|
app;
|
|
39
42
|
acl;
|
|
@@ -59,10 +62,11 @@ export class PramenDOBase extends DurableObject {
|
|
|
59
62
|
/** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
|
|
60
63
|
* attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
|
|
61
64
|
* JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
|
|
62
|
-
* tradeoff: this map is lost on DO hibernation/eviction
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
65
|
+
* tradeoff: this map is lost on DO hibernation/eviction. That is NOT self-healing —
|
|
66
|
+
* a hibernated socket stays OPEN, so the client sees no close, never replays, and
|
|
67
|
+
* every push to it is silently dropped forever. `subscribed` on the attachment is the
|
|
68
|
+
* one bit that survives to detect it; `subsFor` turns that into a close, and the close
|
|
69
|
+
* into a replay. Keyed by the WebSocket object; cleaned up in webSocketClose. */
|
|
66
70
|
subsBySocket = new Map();
|
|
67
71
|
constructor(ctx, env, app) {
|
|
68
72
|
super(ctx, env);
|
|
@@ -291,6 +295,21 @@ export class PramenDOBase extends DurableObject {
|
|
|
291
295
|
// frame and close 4401 so the client re-auths. Synthetic identities carry no exp.
|
|
292
296
|
if (this.isExpired(att.identity))
|
|
293
297
|
return this.rejectExpired(ws, msg.id);
|
|
298
|
+
// A woken socket whose subscriptions the map lost cannot be repaired one frame at a
|
|
299
|
+
// time: every id an `unsubscribe` or a re-`subscribe` names refers to a subscription
|
|
300
|
+
// this instance has never seen, and letting one through would repopulate the map —
|
|
301
|
+
// making the socket look healthy while the rest of its subscriptions stay zombies.
|
|
302
|
+
// Close it instead and let the client replay the whole set. A one-shot `call`
|
|
303
|
+
// depends on none of that, so it is answered normally.
|
|
304
|
+
if (msg.type !== "call" && att.subscribed && !this.subsBySocket.has(ws)) {
|
|
305
|
+
try {
|
|
306
|
+
ws.close(WS_CLOSE_RESUBSCRIBE, "resubscribe");
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* already closing */
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
294
313
|
await this.ensureMigrated();
|
|
295
314
|
switch (msg.type) {
|
|
296
315
|
case "subscribe":
|
|
@@ -380,7 +399,20 @@ export class PramenDOBase extends DurableObject {
|
|
|
380
399
|
this.subsBySocket.delete(ws);
|
|
381
400
|
continue;
|
|
382
401
|
}
|
|
383
|
-
const subs = this.
|
|
402
|
+
const subs = this.subsFor(ws, att);
|
|
403
|
+
// Lost to hibernation. Closing is the fix, not a fallback: the client replays
|
|
404
|
+
// every subscription on `open`, and re-running them there returns the state this
|
|
405
|
+
// broadcast was carrying anyway. Pushing on would push to nobody.
|
|
406
|
+
if (subs === null) {
|
|
407
|
+
try {
|
|
408
|
+
ws.close(WS_CLOSE_RESUBSCRIBE, "resubscribe");
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
/* already closing */
|
|
412
|
+
}
|
|
413
|
+
this.subsBySocket.delete(ws);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
384
416
|
let dirty = false;
|
|
385
417
|
for (const sub of subs) {
|
|
386
418
|
if (!sub.tables.some((t) => written.has(t)))
|
|
@@ -604,6 +636,27 @@ export class PramenDOBase extends DurableObject {
|
|
|
604
636
|
}
|
|
605
637
|
setSubs(ws, subs) {
|
|
606
638
|
this.subsBySocket.set(ws, subs);
|
|
639
|
+
// Keep the durable marker in step, and only when it actually flips — an attachment
|
|
640
|
+
// write per subscription update would be churn for nothing.
|
|
641
|
+
const att = this.getAttachment(ws);
|
|
642
|
+
const subscribed = subs.length > 0;
|
|
643
|
+
if ((att.subscribed ?? false) !== subscribed)
|
|
644
|
+
this.setAttachment(ws, { ...att, subscribed });
|
|
645
|
+
}
|
|
646
|
+
/** This socket's subscriptions, or `null` if they were lost to hibernation.
|
|
647
|
+
*
|
|
648
|
+
* The two states the in-memory map cannot tell apart: a socket that has subscribed to
|
|
649
|
+
* nothing, and a socket whose subscriptions the map lost. Both read as an empty list;
|
|
650
|
+
* only the first is harmless. The attachment's `subscribed` bit is what survives
|
|
651
|
+
* hibernation, so it is what separates them. */
|
|
652
|
+
subsFor(ws, att) {
|
|
653
|
+
const subs = this.subsBySocket.get(ws);
|
|
654
|
+
// An ABSENT entry is the signal, not an empty one: the upgrade seeds every socket
|
|
655
|
+
// with `[]`, so "no entry" can only mean this instance never saw this socket — it
|
|
656
|
+
// was accepted by an instance that has since been evicted.
|
|
657
|
+
if (subs)
|
|
658
|
+
return subs;
|
|
659
|
+
return att.subscribed ? null : [];
|
|
607
660
|
}
|
|
608
661
|
send(ws, msg) {
|
|
609
662
|
ws.send(JSON.stringify(msg));
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET
|
|
|
15
15
|
export { HmacStrategy, JwksStrategy, type VerifyStrategy, type VerifyOptions } from "./auth";
|
|
16
16
|
export type { ExpiringToken } from "./runtime/token";
|
|
17
17
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
18
|
-
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
18
|
+
export { Mail, CloudflareEmailAdapter, MailgunAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
19
19
|
export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
|
|
20
20
|
export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
|
|
21
21
|
export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET
|
|
|
26
26
|
// token with the same JWKS cache (and its key-rotation handling) the Worker uses.
|
|
27
27
|
export { HmacStrategy, JwksStrategy } from "./auth";
|
|
28
28
|
// --- mail (ctx.mail) ---
|
|
29
|
-
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
29
|
+
export { Mail, CloudflareEmailAdapter, MailgunAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
30
30
|
// --- queue (ctx.queue — Cloudflare Queues) ---
|
|
31
31
|
export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
|
|
32
32
|
export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
|
package/dist/runtime/mail.d.ts
CHANGED
|
@@ -47,6 +47,33 @@ export declare class CloudflareEmailAdapter implements MailAdapter {
|
|
|
47
47
|
from: MailAddress;
|
|
48
48
|
}): Promise<void>;
|
|
49
49
|
}
|
|
50
|
+
/** Mailgun — an HTTP transport, for when Cloudflare Email Sending cannot be used.
|
|
51
|
+
*
|
|
52
|
+
* Worth the key for one reason: Cloudflare will only send from a domain that is a zone
|
|
53
|
+
* in the same account, and some accounts additionally refuse any recipient that is not a
|
|
54
|
+
* verified destination in Email Routing ("destination address is not a verified
|
|
55
|
+
* address"). That is workable for a handful of operators and hopeless for real users.
|
|
56
|
+
* Mailgun asks the domain be verified once, then delivers to anyone.
|
|
57
|
+
*
|
|
58
|
+
* A non-2xx THROWS, deliberately: `ctx.mail.send` is normally called from a task, and a
|
|
59
|
+
* throw is what makes the outbox retry and then dead-letter visibly. Swallowing the
|
|
60
|
+
* status would turn a bounced sign-in link into silence. The response body rides along
|
|
61
|
+
* in the message because Mailgun's 400s are specific and worth reading ("not a valid
|
|
62
|
+
* address", "domain not found"); the key never does. */
|
|
63
|
+
export declare class MailgunAdapter implements MailAdapter {
|
|
64
|
+
private readonly apiKey;
|
|
65
|
+
private readonly domain;
|
|
66
|
+
/** `https://api.eu.mailgun.net` for an EU-region account; the two are separate
|
|
67
|
+
* deployments and a key from one 401s against the other. */
|
|
68
|
+
private readonly apiBase;
|
|
69
|
+
constructor(apiKey: string, domain: string,
|
|
70
|
+
/** `https://api.eu.mailgun.net` for an EU-region account; the two are separate
|
|
71
|
+
* deployments and a key from one 401s against the other. */
|
|
72
|
+
apiBase?: string);
|
|
73
|
+
send(message: MailMessage & {
|
|
74
|
+
from: MailAddress;
|
|
75
|
+
}): Promise<void>;
|
|
76
|
+
}
|
|
50
77
|
/** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
|
|
51
78
|
* (or a dashboard) can read the "inbox" instead of really sending. */
|
|
52
79
|
export declare class KvMailAdapter implements MailAdapter {
|
|
@@ -73,9 +100,14 @@ export declare class UnconfiguredMailAdapter implements MailAdapter {
|
|
|
73
100
|
send(): Promise<void>;
|
|
74
101
|
}
|
|
75
102
|
/** Build `ctx.mail` from the environment:
|
|
76
|
-
* - `
|
|
103
|
+
* - `MAILGUN_API_KEY` + `MAILGUN_DOMAIN` + `MAIL_FROM` → Mailgun (real send).
|
|
104
|
+
* - else `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
|
|
77
105
|
* - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
|
|
78
106
|
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
79
107
|
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
80
|
-
* stash security emails in KV).
|
|
108
|
+
* stash security emails in KV).
|
|
109
|
+
*
|
|
110
|
+
* Mailgun outranks the binding on purpose. The binding tends to be present because the
|
|
111
|
+
* infrastructure declares it, whereas an API key is only ever there because somebody put
|
|
112
|
+
* it there — so when both exist, the key is the newer decision. */
|
|
81
113
|
export declare function createMail(env: EnvBag, kv?: Kv): Mail;
|
package/dist/runtime/mail.js
CHANGED
|
@@ -5,10 +5,15 @@
|
|
|
5
5
|
//
|
|
6
6
|
// await ctx.mail.send({ to: "u@x.com", subject: "Welcome", text: "…" });
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// Two real transports. Cloudflare Email Sending (the `send_email`/`EMAIL` binding) needs
|
|
9
|
+
// no API keys, but it can only send FROM a domain that is a zone in the same account,
|
|
10
|
+
// and on some accounts only TO addresses verified in Email Routing — which rules it out
|
|
11
|
+
// whenever the recipients are ordinary people. Mailgun is the way out of both: an HTTP
|
|
12
|
+
// API, any recipient, at the cost of a key. Configure it and it wins.
|
|
13
|
+
//
|
|
14
|
+
// With neither configured (local/dev), mail is captured instead of sent — to KV (so an
|
|
15
|
+
// e2e/dashboard can read the "inbox") or in-memory — so handlers work unchanged
|
|
16
|
+
// off-platform.
|
|
12
17
|
/** The `ctx.mail` facade: resolves the sender, validates, and delegates to the adapter. */
|
|
13
18
|
export class Mail {
|
|
14
19
|
adapter;
|
|
@@ -49,6 +54,60 @@ export class CloudflareEmailAdapter {
|
|
|
49
54
|
});
|
|
50
55
|
}
|
|
51
56
|
}
|
|
57
|
+
/** Mailgun — an HTTP transport, for when Cloudflare Email Sending cannot be used.
|
|
58
|
+
*
|
|
59
|
+
* Worth the key for one reason: Cloudflare will only send from a domain that is a zone
|
|
60
|
+
* in the same account, and some accounts additionally refuse any recipient that is not a
|
|
61
|
+
* verified destination in Email Routing ("destination address is not a verified
|
|
62
|
+
* address"). That is workable for a handful of operators and hopeless for real users.
|
|
63
|
+
* Mailgun asks the domain be verified once, then delivers to anyone.
|
|
64
|
+
*
|
|
65
|
+
* A non-2xx THROWS, deliberately: `ctx.mail.send` is normally called from a task, and a
|
|
66
|
+
* throw is what makes the outbox retry and then dead-letter visibly. Swallowing the
|
|
67
|
+
* status would turn a bounced sign-in link into silence. The response body rides along
|
|
68
|
+
* in the message because Mailgun's 400s are specific and worth reading ("not a valid
|
|
69
|
+
* address", "domain not found"); the key never does. */
|
|
70
|
+
export class MailgunAdapter {
|
|
71
|
+
apiKey;
|
|
72
|
+
domain;
|
|
73
|
+
apiBase;
|
|
74
|
+
constructor(apiKey, domain,
|
|
75
|
+
/** `https://api.eu.mailgun.net` for an EU-region account; the two are separate
|
|
76
|
+
* deployments and a key from one 401s against the other. */
|
|
77
|
+
apiBase = "https://api.mailgun.net") {
|
|
78
|
+
this.apiKey = apiKey;
|
|
79
|
+
this.domain = domain;
|
|
80
|
+
this.apiBase = apiBase;
|
|
81
|
+
}
|
|
82
|
+
async send(message) {
|
|
83
|
+
const body = new URLSearchParams();
|
|
84
|
+
body.set("from", message.from.name ? `${message.from.name} <${message.from.email}>` : message.from.email);
|
|
85
|
+
for (const to of Array.isArray(message.to) ? message.to : [message.to])
|
|
86
|
+
body.append("to", to);
|
|
87
|
+
body.set("subject", message.subject);
|
|
88
|
+
if (message.text)
|
|
89
|
+
body.set("text", message.text);
|
|
90
|
+
if (message.html)
|
|
91
|
+
body.set("html", message.html);
|
|
92
|
+
if (message.replyTo) {
|
|
93
|
+
const r = message.replyTo;
|
|
94
|
+
body.set("h:Reply-To", typeof r === "string" ? r : r.name ? `${r.name} <${r.email}>` : r.email);
|
|
95
|
+
}
|
|
96
|
+
const res = await fetch(`${this.apiBase.replace(/\/+$/, "")}/v3/${encodeURIComponent(this.domain)}/messages`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
// `api` is the literal username Mailgun expects; the key is the password.
|
|
100
|
+
authorization: `Basic ${btoa(`api:${this.apiKey}`)}`,
|
|
101
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
102
|
+
},
|
|
103
|
+
body,
|
|
104
|
+
});
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
const detail = await res.text().catch(() => "");
|
|
107
|
+
throw new Error(`mailgun: send failed (${res.status})${detail ? ` — ${detail.slice(0, 300)}` : ""}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
52
111
|
/** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
|
|
53
112
|
* (or a dashboard) can read the "inbox" instead of really sending. */
|
|
54
113
|
export class KvMailAdapter {
|
|
@@ -76,21 +135,35 @@ export class MemoryMailAdapter {
|
|
|
76
135
|
* instead of delivering them. Mirrors how files fail closed without FILES_SECRET. */
|
|
77
136
|
export class UnconfiguredMailAdapter {
|
|
78
137
|
async send() {
|
|
79
|
-
throw new Error("ctx.mail: no transport configured — set MAIL_FROM
|
|
80
|
-
"or MAIL_CAPTURE=true to capture in dev.");
|
|
138
|
+
throw new Error("ctx.mail: no transport configured — set MAIL_FROM with either the EMAIL binding " +
|
|
139
|
+
"or MAILGUN_API_KEY + MAILGUN_DOMAIN to send, or MAIL_CAPTURE=true to capture in dev.");
|
|
81
140
|
}
|
|
82
141
|
}
|
|
83
142
|
/** Build `ctx.mail` from the environment:
|
|
84
|
-
* - `
|
|
143
|
+
* - `MAILGUN_API_KEY` + `MAILGUN_DOMAIN` + `MAIL_FROM` → Mailgun (real send).
|
|
144
|
+
* - else `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
|
|
85
145
|
* - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
|
|
86
146
|
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
87
147
|
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
88
|
-
* stash security emails in KV).
|
|
148
|
+
* stash security emails in KV).
|
|
149
|
+
*
|
|
150
|
+
* Mailgun outranks the binding on purpose. The binding tends to be present because the
|
|
151
|
+
* infrastructure declares it, whereas an API key is only ever there because somebody put
|
|
152
|
+
* it there — so when both exist, the key is the newer decision. */
|
|
89
153
|
export function createMail(env, kv) {
|
|
90
154
|
const binding = env.EMAIL;
|
|
91
155
|
const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
|
|
156
|
+
const str = (k) => typeof env[k] === "string" && env[k] ? env[k] : undefined;
|
|
157
|
+
const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
|
|
158
|
+
const mailgunKey = str("MAILGUN_API_KEY");
|
|
159
|
+
const mailgunDomain = str("MAILGUN_DOMAIN");
|
|
160
|
+
if (mailgunKey && mailgunDomain && fromAddr) {
|
|
161
|
+
return new Mail(new MailgunAdapter(mailgunKey, mailgunDomain, str("MAILGUN_API_BASE")), {
|
|
162
|
+
email: fromAddr,
|
|
163
|
+
name,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
92
166
|
if (binding && fromAddr) {
|
|
93
|
-
const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
|
|
94
167
|
return new Mail(new CloudflareEmailAdapter(binding), { email: fromAddr, name });
|
|
95
168
|
}
|
|
96
169
|
if (env.MAIL_CAPTURE === "true") {
|
|
@@ -29,7 +29,10 @@ export type ServerMsg = {
|
|
|
29
29
|
id: string;
|
|
30
30
|
error: string;
|
|
31
31
|
};
|
|
32
|
-
/** A live subscription,
|
|
32
|
+
/** A live subscription. Held in the DO's memory, NOT on the socket — the attachment is
|
|
33
|
+
* capped at ~2 KB and a full set of these blows past it. Only a one-bit `subscribed`
|
|
34
|
+
* marker rides the attachment, which is enough for the DO to notice the loss and close
|
|
35
|
+
* the socket so the client replays. */
|
|
33
36
|
export interface Subscription {
|
|
34
37
|
id: string;
|
|
35
38
|
name: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.59",
|
|
4
4
|
"description": "pramen server runtime \u2014 schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/durable-object.ts
CHANGED
|
@@ -48,6 +48,11 @@ interface SocketAttachment {
|
|
|
48
48
|
/** Partition fixed at connect time (read from x-pramen-partition at upgrade);
|
|
49
49
|
* survives hibernation via the attachment, like `tenant`. */
|
|
50
50
|
partition: string;
|
|
51
|
+
/** Whether this socket had any live subscription. One bit, not the list — the list is
|
|
52
|
+
* far too big for the attachment, but this is enough to tell "subscribed to nothing"
|
|
53
|
+
* apart from "subscriptions lost to hibernation", which otherwise look identical and
|
|
54
|
+
* silently kill every push on the socket. See `subsFor`. */
|
|
55
|
+
subscribed?: boolean;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
export interface DoEnv {
|
|
@@ -71,6 +76,10 @@ const MAX_SUBSCRIPTIONS = 64;
|
|
|
71
76
|
* 4401 mirrors HTTP 401). Sent when a socket's token has expired since upgrade. */
|
|
72
77
|
const WS_CLOSE_UNAUTHORIZED = 4401;
|
|
73
78
|
|
|
79
|
+
/** Application close code for "this socket's subscriptions did not survive hibernation".
|
|
80
|
+
* Sent so the client reconnects and replays them — which it already does on any close. */
|
|
81
|
+
const WS_CLOSE_RESUBSCRIBE = 4410;
|
|
82
|
+
|
|
74
83
|
export class PramenDOBase extends DurableObject<DoEnv> {
|
|
75
84
|
private readonly app: PramenApp;
|
|
76
85
|
private readonly acl: CompiledAcl;
|
|
@@ -96,10 +105,11 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
96
105
|
/** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
|
|
97
106
|
* attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
|
|
98
107
|
* JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
|
|
99
|
-
* tradeoff: this map is lost on DO hibernation/eviction
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
108
|
+
* tradeoff: this map is lost on DO hibernation/eviction. That is NOT self-healing —
|
|
109
|
+
* a hibernated socket stays OPEN, so the client sees no close, never replays, and
|
|
110
|
+
* every push to it is silently dropped forever. `subscribed` on the attachment is the
|
|
111
|
+
* one bit that survives to detect it; `subsFor` turns that into a close, and the close
|
|
112
|
+
* into a replay. Keyed by the WebSocket object; cleaned up in webSocketClose. */
|
|
103
113
|
private readonly subsBySocket = new Map<WebSocket, Subscription[]>();
|
|
104
114
|
|
|
105
115
|
constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp) {
|
|
@@ -355,6 +365,21 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
355
365
|
// frame and close 4401 so the client re-auths. Synthetic identities carry no exp.
|
|
356
366
|
if (this.isExpired(att.identity)) return this.rejectExpired(ws, msg.id);
|
|
357
367
|
|
|
368
|
+
// A woken socket whose subscriptions the map lost cannot be repaired one frame at a
|
|
369
|
+
// time: every id an `unsubscribe` or a re-`subscribe` names refers to a subscription
|
|
370
|
+
// this instance has never seen, and letting one through would repopulate the map —
|
|
371
|
+
// making the socket look healthy while the rest of its subscriptions stay zombies.
|
|
372
|
+
// Close it instead and let the client replay the whole set. A one-shot `call`
|
|
373
|
+
// depends on none of that, so it is answered normally.
|
|
374
|
+
if (msg.type !== "call" && att.subscribed && !this.subsBySocket.has(ws)) {
|
|
375
|
+
try {
|
|
376
|
+
ws.close(WS_CLOSE_RESUBSCRIBE, "resubscribe");
|
|
377
|
+
} catch {
|
|
378
|
+
/* already closing */
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
358
383
|
await this.ensureMigrated();
|
|
359
384
|
|
|
360
385
|
switch (msg.type) {
|
|
@@ -445,7 +470,19 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
445
470
|
this.subsBySocket.delete(ws);
|
|
446
471
|
continue;
|
|
447
472
|
}
|
|
448
|
-
const subs = this.
|
|
473
|
+
const subs = this.subsFor(ws, att);
|
|
474
|
+
// Lost to hibernation. Closing is the fix, not a fallback: the client replays
|
|
475
|
+
// every subscription on `open`, and re-running them there returns the state this
|
|
476
|
+
// broadcast was carrying anyway. Pushing on would push to nobody.
|
|
477
|
+
if (subs === null) {
|
|
478
|
+
try {
|
|
479
|
+
ws.close(WS_CLOSE_RESUBSCRIBE, "resubscribe");
|
|
480
|
+
} catch {
|
|
481
|
+
/* already closing */
|
|
482
|
+
}
|
|
483
|
+
this.subsBySocket.delete(ws);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
449
486
|
let dirty = false;
|
|
450
487
|
for (const sub of subs) {
|
|
451
488
|
if (!sub.tables.some((t) => written.has(t))) continue;
|
|
@@ -680,6 +717,26 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
680
717
|
|
|
681
718
|
private setSubs(ws: WebSocket, subs: Subscription[]): void {
|
|
682
719
|
this.subsBySocket.set(ws, subs);
|
|
720
|
+
// Keep the durable marker in step, and only when it actually flips — an attachment
|
|
721
|
+
// write per subscription update would be churn for nothing.
|
|
722
|
+
const att = this.getAttachment(ws);
|
|
723
|
+
const subscribed = subs.length > 0;
|
|
724
|
+
if ((att.subscribed ?? false) !== subscribed) this.setAttachment(ws, { ...att, subscribed });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** This socket's subscriptions, or `null` if they were lost to hibernation.
|
|
728
|
+
*
|
|
729
|
+
* The two states the in-memory map cannot tell apart: a socket that has subscribed to
|
|
730
|
+
* nothing, and a socket whose subscriptions the map lost. Both read as an empty list;
|
|
731
|
+
* only the first is harmless. The attachment's `subscribed` bit is what survives
|
|
732
|
+
* hibernation, so it is what separates them. */
|
|
733
|
+
private subsFor(ws: WebSocket, att: SocketAttachment): Subscription[] | null {
|
|
734
|
+
const subs = this.subsBySocket.get(ws);
|
|
735
|
+
// An ABSENT entry is the signal, not an empty one: the upgrade seeds every socket
|
|
736
|
+
// with `[]`, so "no entry" can only mean this instance never saw this socket — it
|
|
737
|
+
// was accepted by an instance that has since been evicted.
|
|
738
|
+
if (subs) return subs;
|
|
739
|
+
return att.subscribed ? null : [];
|
|
683
740
|
}
|
|
684
741
|
|
|
685
742
|
private send(ws: WebSocket, msg: ServerMsg): void {
|
package/src/index.ts
CHANGED
|
@@ -95,7 +95,7 @@ export type { ExpiringToken } from "./runtime/token";
|
|
|
95
95
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
96
96
|
|
|
97
97
|
// --- mail (ctx.mail) ---
|
|
98
|
-
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
98
|
+
export { Mail, CloudflareEmailAdapter, MailgunAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
99
99
|
export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
|
|
100
100
|
|
|
101
101
|
// --- queue (ctx.queue — Cloudflare Queues) ---
|
package/src/runtime/mail.ts
CHANGED
|
@@ -5,10 +5,15 @@
|
|
|
5
5
|
//
|
|
6
6
|
// await ctx.mail.send({ to: "u@x.com", subject: "Welcome", text: "…" });
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// Two real transports. Cloudflare Email Sending (the `send_email`/`EMAIL` binding) needs
|
|
9
|
+
// no API keys, but it can only send FROM a domain that is a zone in the same account,
|
|
10
|
+
// and on some accounts only TO addresses verified in Email Routing — which rules it out
|
|
11
|
+
// whenever the recipients are ordinary people. Mailgun is the way out of both: an HTTP
|
|
12
|
+
// API, any recipient, at the cost of a key. Configure it and it wins.
|
|
13
|
+
//
|
|
14
|
+
// With neither configured (local/dev), mail is captured instead of sent — to KV (so an
|
|
15
|
+
// e2e/dashboard can read the "inbox") or in-memory — so handlers work unchanged
|
|
16
|
+
// off-platform.
|
|
12
17
|
|
|
13
18
|
import type { Kv } from "./kv";
|
|
14
19
|
import type { EnvBag } from "../sdk/handlers";
|
|
@@ -83,6 +88,56 @@ export class CloudflareEmailAdapter implements MailAdapter {
|
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/** Mailgun — an HTTP transport, for when Cloudflare Email Sending cannot be used.
|
|
92
|
+
*
|
|
93
|
+
* Worth the key for one reason: Cloudflare will only send from a domain that is a zone
|
|
94
|
+
* in the same account, and some accounts additionally refuse any recipient that is not a
|
|
95
|
+
* verified destination in Email Routing ("destination address is not a verified
|
|
96
|
+
* address"). That is workable for a handful of operators and hopeless for real users.
|
|
97
|
+
* Mailgun asks the domain be verified once, then delivers to anyone.
|
|
98
|
+
*
|
|
99
|
+
* A non-2xx THROWS, deliberately: `ctx.mail.send` is normally called from a task, and a
|
|
100
|
+
* throw is what makes the outbox retry and then dead-letter visibly. Swallowing the
|
|
101
|
+
* status would turn a bounced sign-in link into silence. The response body rides along
|
|
102
|
+
* in the message because Mailgun's 400s are specific and worth reading ("not a valid
|
|
103
|
+
* address", "domain not found"); the key never does. */
|
|
104
|
+
export class MailgunAdapter implements MailAdapter {
|
|
105
|
+
constructor(
|
|
106
|
+
private readonly apiKey: string,
|
|
107
|
+
private readonly domain: string,
|
|
108
|
+
/** `https://api.eu.mailgun.net` for an EU-region account; the two are separate
|
|
109
|
+
* deployments and a key from one 401s against the other. */
|
|
110
|
+
private readonly apiBase: string = "https://api.mailgun.net",
|
|
111
|
+
) {}
|
|
112
|
+
|
|
113
|
+
async send(message: MailMessage & { from: MailAddress }): Promise<void> {
|
|
114
|
+
const body = new URLSearchParams();
|
|
115
|
+
body.set("from", message.from.name ? `${message.from.name} <${message.from.email}>` : message.from.email);
|
|
116
|
+
for (const to of Array.isArray(message.to) ? message.to : [message.to]) body.append("to", to);
|
|
117
|
+
body.set("subject", message.subject);
|
|
118
|
+
if (message.text) body.set("text", message.text);
|
|
119
|
+
if (message.html) body.set("html", message.html);
|
|
120
|
+
if (message.replyTo) {
|
|
121
|
+
const r = message.replyTo;
|
|
122
|
+
body.set("h:Reply-To", typeof r === "string" ? r : r.name ? `${r.name} <${r.email}>` : r.email);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const res = await fetch(`${this.apiBase.replace(/\/+$/, "")}/v3/${encodeURIComponent(this.domain)}/messages`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: {
|
|
128
|
+
// `api` is the literal username Mailgun expects; the key is the password.
|
|
129
|
+
authorization: `Basic ${btoa(`api:${this.apiKey}`)}`,
|
|
130
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
131
|
+
},
|
|
132
|
+
body,
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
const detail = await res.text().catch(() => "");
|
|
136
|
+
throw new Error(`mailgun: send failed (${res.status})${detail ? ` — ${detail.slice(0, 300)}` : ""}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
86
141
|
/** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
|
|
87
142
|
* (or a dashboard) can read the "inbox" instead of really sending. */
|
|
88
143
|
export class KvMailAdapter implements MailAdapter {
|
|
@@ -109,23 +164,39 @@ export class MemoryMailAdapter implements MailAdapter {
|
|
|
109
164
|
export class UnconfiguredMailAdapter implements MailAdapter {
|
|
110
165
|
async send(): Promise<void> {
|
|
111
166
|
throw new Error(
|
|
112
|
-
"ctx.mail: no transport configured — set MAIL_FROM
|
|
113
|
-
"or MAIL_CAPTURE=true to capture in dev.",
|
|
167
|
+
"ctx.mail: no transport configured — set MAIL_FROM with either the EMAIL binding " +
|
|
168
|
+
"or MAILGUN_API_KEY + MAILGUN_DOMAIN to send, or MAIL_CAPTURE=true to capture in dev.",
|
|
114
169
|
);
|
|
115
170
|
}
|
|
116
171
|
}
|
|
117
172
|
|
|
118
173
|
/** Build `ctx.mail` from the environment:
|
|
119
|
-
* - `
|
|
174
|
+
* - `MAILGUN_API_KEY` + `MAILGUN_DOMAIN` + `MAIL_FROM` → Mailgun (real send).
|
|
175
|
+
* - else `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
|
|
120
176
|
* - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
|
|
121
177
|
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
122
178
|
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
123
|
-
* stash security emails in KV).
|
|
179
|
+
* stash security emails in KV).
|
|
180
|
+
*
|
|
181
|
+
* Mailgun outranks the binding on purpose. The binding tends to be present because the
|
|
182
|
+
* infrastructure declares it, whereas an API key is only ever there because somebody put
|
|
183
|
+
* it there — so when both exist, the key is the newer decision. */
|
|
124
184
|
export function createMail(env: EnvBag, kv?: Kv): Mail {
|
|
125
185
|
const binding = env.EMAIL as SendEmailBinding | undefined;
|
|
126
186
|
const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
|
|
187
|
+
const str = (k: string): string | undefined =>
|
|
188
|
+
typeof env[k] === "string" && (env[k] as string) ? (env[k] as string) : undefined;
|
|
189
|
+
const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
|
|
190
|
+
|
|
191
|
+
const mailgunKey = str("MAILGUN_API_KEY");
|
|
192
|
+
const mailgunDomain = str("MAILGUN_DOMAIN");
|
|
193
|
+
if (mailgunKey && mailgunDomain && fromAddr) {
|
|
194
|
+
return new Mail(new MailgunAdapter(mailgunKey, mailgunDomain, str("MAILGUN_API_BASE")), {
|
|
195
|
+
email: fromAddr,
|
|
196
|
+
name,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
127
199
|
if (binding && fromAddr) {
|
|
128
|
-
const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
|
|
129
200
|
return new Mail(new CloudflareEmailAdapter(binding), { email: fromAddr, name });
|
|
130
201
|
}
|
|
131
202
|
if (env.MAIL_CAPTURE === "true") {
|
package/src/runtime/protocol.ts
CHANGED
|
@@ -36,7 +36,10 @@ export type ServerMsg =
|
|
|
36
36
|
| { type: "result"; id: string; result: unknown }
|
|
37
37
|
| { type: "error"; id: string; error: string };
|
|
38
38
|
|
|
39
|
-
/** A live subscription,
|
|
39
|
+
/** A live subscription. Held in the DO's memory, NOT on the socket — the attachment is
|
|
40
|
+
* capped at ~2 KB and a full set of these blows past it. Only a one-bit `subscribed`
|
|
41
|
+
* marker rides the attachment, which is enough for the DO to notice the loss and close
|
|
42
|
+
* the socket so the client replays. */
|
|
40
43
|
export interface Subscription {
|
|
41
44
|
id: string;
|
|
42
45
|
name: string;
|