@pylonsync/functions 0.3.376 → 0.3.378
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/types.d.ts +38 -5
- package/dist/validators.d.ts +6 -0
- package/package.json +1 -1
- package/src/runtime-email.test.ts +174 -0
- package/src/runtime-rpc-queue.test.ts +168 -0
- package/src/runtime.ts +112 -33
- package/src/types.ts +40 -5
- package/src/validators.ts +11 -0
package/dist/types.d.ts
CHANGED
|
@@ -217,7 +217,7 @@ export interface Scheduler {
|
|
|
217
217
|
* Transactional email transport.
|
|
218
218
|
*
|
|
219
219
|
* Sends through whatever provider the runtime is configured for
|
|
220
|
-
* (PYLON_EMAIL_PROVIDER env var → SendGrid / Resend / Stack0 /
|
|
220
|
+
* (PYLON_EMAIL_PROVIDER env var → SendGrid / Resend / Stack0 /
|
|
221
221
|
* webhook). Available on action ctx only — sending email is external
|
|
222
222
|
* I/O, not allowed in mutation transactions.
|
|
223
223
|
*
|
|
@@ -230,16 +230,49 @@ export interface Scheduler {
|
|
|
230
230
|
* shared auth key can never be used to send arbitrary mail.
|
|
231
231
|
*
|
|
232
232
|
* The runtime owns provider config + credentials; functions only
|
|
233
|
-
* supply the
|
|
234
|
-
*
|
|
233
|
+
* supply the message. Failures are surfaced as thrown errors; on
|
|
234
|
+
* success the return is void.
|
|
235
235
|
*
|
|
236
236
|
* Use cases: invite emails, password-reset hand-offs, notifications,
|
|
237
|
-
* digest reports
|
|
238
|
-
*
|
|
237
|
+
* digest reports, calendar invites (attach an .ics with contentType
|
|
238
|
+
* `text/calendar; method=REQUEST` and mail clients render an
|
|
239
|
+
* RSVP-able event). NOT for marketing email — those should go
|
|
240
|
+
* through a dedicated bulk transport, not the transactional path.
|
|
239
241
|
*/
|
|
242
|
+
export interface EmailAttachment {
|
|
243
|
+
filename: string;
|
|
244
|
+
/**
|
|
245
|
+
* Full MIME content type, passed to the provider VERBATIM —
|
|
246
|
+
* parameterized types like `text/calendar; method=REQUEST` are
|
|
247
|
+
* preserved (that parameter is what makes an invite RSVP-able).
|
|
248
|
+
*/
|
|
249
|
+
contentType: string;
|
|
250
|
+
/** Base64-encoded file bytes. */
|
|
251
|
+
content: string;
|
|
252
|
+
}
|
|
253
|
+
export interface EmailOptions {
|
|
254
|
+
/** Single recipient address. */
|
|
255
|
+
to: string;
|
|
256
|
+
subject: string;
|
|
257
|
+
/**
|
|
258
|
+
* Plain-text body. Required even with `html` — it's the text/plain
|
|
259
|
+
* part clients with HTML disabled fall back to.
|
|
260
|
+
*/
|
|
261
|
+
text: string;
|
|
262
|
+
/** Optional HTML body; providers send multipart with `text`. */
|
|
263
|
+
html?: string;
|
|
264
|
+
/**
|
|
265
|
+
* Base64 attachments. Limits: at most 20 per email, 15MB of base64
|
|
266
|
+
* text total (≈11MB of raw file data); larger sends throw before
|
|
267
|
+
* any network I/O.
|
|
268
|
+
*/
|
|
269
|
+
attachments?: EmailAttachment[];
|
|
270
|
+
}
|
|
240
271
|
export interface EmailSender {
|
|
241
272
|
/** Send a plain-text email. `to` is a single address. */
|
|
242
273
|
send(to: string, subject: string, body: string): Promise<void>;
|
|
274
|
+
/** Send with options: HTML body and/or base64 attachments. */
|
|
275
|
+
send(options: EmailOptions): Promise<void>;
|
|
243
276
|
}
|
|
244
277
|
/**
|
|
245
278
|
* Server-side LLM client. Available on every ctx variant (query,
|
package/dist/validators.d.ts
CHANGED
|
@@ -67,6 +67,12 @@ export declare const v: {
|
|
|
67
67
|
literal: <const TValue extends string | number | boolean>(value: TValue) => Validator<TValue, false>;
|
|
68
68
|
/** Any valid JSON value. */
|
|
69
69
|
any: () => Validator<any, false>;
|
|
70
|
+
/**
|
|
71
|
+
* Arbitrary JSON value (object, array, or scalar). The validator-side
|
|
72
|
+
* match for `field.json()` — accepts any JSON shape but types the arg
|
|
73
|
+
* as `unknown`, so handlers narrow before use instead of getting `any`.
|
|
74
|
+
*/
|
|
75
|
+
json: () => Validator<unknown, false>;
|
|
70
76
|
};
|
|
71
77
|
export declare function validateArgs(args: unknown, schema: Record<string, Validator>): {
|
|
72
78
|
valid: boolean;
|
package/package.json
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ctx.email.send` — options overload and size caps.
|
|
3
|
+
*
|
|
4
|
+
* The contract under test:
|
|
5
|
+
*
|
|
6
|
+
* 1. The positional form still emits the legacy frame shape.
|
|
7
|
+
* 2. The options form emits `html` and snake_case attachments, with
|
|
8
|
+
* the `contentType` value passed through VERBATIM (parameterized
|
|
9
|
+
* types like `text/calendar; method=REQUEST` are load-bearing).
|
|
10
|
+
* 3. Over-limit attachments throw BEFORE any frame is emitted.
|
|
11
|
+
*
|
|
12
|
+
* Same child-process NDJSON harness as runtime-rpc-queue.test.ts.
|
|
13
|
+
*/
|
|
14
|
+
import { expect, test } from "bun:test";
|
|
15
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const RUNTIME = join(import.meta.dir, "runtime.ts");
|
|
20
|
+
|
|
21
|
+
function parseFrames(text: string): Record<string, unknown>[] {
|
|
22
|
+
return text
|
|
23
|
+
.split("\n")
|
|
24
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
25
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function spawnRuntime(probeSource: string) {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-fn-email-"));
|
|
30
|
+
mkdirSync(join(dir, "functions"));
|
|
31
|
+
writeFileSync(join(dir, "functions", "probe.ts"), probeSource);
|
|
32
|
+
return Bun.spawn([process.execPath, RUNTIME, "./functions"], {
|
|
33
|
+
cwd: dir,
|
|
34
|
+
stdin: "pipe",
|
|
35
|
+
stdout: "pipe",
|
|
36
|
+
stderr: "pipe",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function frameReader(proc: ReturnType<typeof Bun.spawn>) {
|
|
41
|
+
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
42
|
+
const decoder = new TextDecoder();
|
|
43
|
+
let buffered = "";
|
|
44
|
+
return async function readUntil(
|
|
45
|
+
pred: (frames: Record<string, unknown>[]) => boolean,
|
|
46
|
+
): Promise<Record<string, unknown>[]> {
|
|
47
|
+
let frames = parseFrames(buffered);
|
|
48
|
+
while (!pred(frames)) {
|
|
49
|
+
const { done, value } = await reader.read();
|
|
50
|
+
if (done) break;
|
|
51
|
+
buffered += decoder.decode(value, { stream: true });
|
|
52
|
+
frames = parseFrames(buffered);
|
|
53
|
+
}
|
|
54
|
+
return frames;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test("options form emits html + snake_case attachments with verbatim contentType", async () => {
|
|
59
|
+
const proc = spawnRuntime(`
|
|
60
|
+
export default {
|
|
61
|
+
type: "action",
|
|
62
|
+
handler: async (ctx) => {
|
|
63
|
+
// Positional form first, then options form.
|
|
64
|
+
await ctx.email.send("a@t.com", "Plain", "plain body");
|
|
65
|
+
await ctx.email.send({
|
|
66
|
+
to: "b@t.com",
|
|
67
|
+
subject: "Invite",
|
|
68
|
+
text: "You're invited",
|
|
69
|
+
html: "<p>hi</p>",
|
|
70
|
+
attachments: [{
|
|
71
|
+
filename: "invite.ics",
|
|
72
|
+
contentType: "text/calendar; method=REQUEST",
|
|
73
|
+
content: "QkVHSU46VkNBTEVOREFS",
|
|
74
|
+
}],
|
|
75
|
+
});
|
|
76
|
+
return { ok: true };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
`);
|
|
80
|
+
const readUntil = frameReader(proc);
|
|
81
|
+
const send = (msg: Record<string, unknown>) =>
|
|
82
|
+
(proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
|
|
83
|
+
|
|
84
|
+
await readUntil((fs) => fs.some((f) => f.type === "ready"));
|
|
85
|
+
send({
|
|
86
|
+
type: "call",
|
|
87
|
+
call_id: "c_1",
|
|
88
|
+
fn_name: "probe",
|
|
89
|
+
fn_type: "action",
|
|
90
|
+
args: {},
|
|
91
|
+
auth: { user_id: "u1", is_admin: false, tenant_id: null },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let frames = await readUntil((fs) => fs.some((f) => f.type === "send_email"));
|
|
95
|
+
const first = frames.find((f) => f.type === "send_email")!;
|
|
96
|
+
expect(first).toMatchObject({ to: "a@t.com", subject: "Plain", body: "plain body" });
|
|
97
|
+
send({ type: "result", call_id: "c_1", data: { sent: true } });
|
|
98
|
+
|
|
99
|
+
frames = await readUntil(
|
|
100
|
+
(fs) => fs.filter((f) => f.type === "send_email").length >= 2,
|
|
101
|
+
);
|
|
102
|
+
const second = frames.filter((f) => f.type === "send_email")[1];
|
|
103
|
+
expect(second).toMatchObject({
|
|
104
|
+
to: "b@t.com",
|
|
105
|
+
subject: "Invite",
|
|
106
|
+
body: "You're invited",
|
|
107
|
+
html: "<p>hi</p>",
|
|
108
|
+
attachments: [
|
|
109
|
+
{
|
|
110
|
+
filename: "invite.ics",
|
|
111
|
+
content_type: "text/calendar; method=REQUEST",
|
|
112
|
+
content: "QkVHSU46VkNBTEVOREFS",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
send({ type: "result", call_id: "c_1", data: { sent: true } });
|
|
117
|
+
|
|
118
|
+
frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
|
|
119
|
+
expect(frames.find((f) => f.type === "return")!.value).toEqual({ ok: true });
|
|
120
|
+
|
|
121
|
+
proc.kill();
|
|
122
|
+
await proc.exited;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("oversized attachments throw before any frame is emitted", async () => {
|
|
126
|
+
const proc = spawnRuntime(`
|
|
127
|
+
export default {
|
|
128
|
+
type: "action",
|
|
129
|
+
handler: async (ctx) => {
|
|
130
|
+
try {
|
|
131
|
+
await ctx.email.send({
|
|
132
|
+
to: "b@t.com",
|
|
133
|
+
subject: "Big",
|
|
134
|
+
text: "x",
|
|
135
|
+
attachments: [{
|
|
136
|
+
filename: "huge.bin",
|
|
137
|
+
contentType: "application/octet-stream",
|
|
138
|
+
content: "A".repeat(16 * 1024 * 1024),
|
|
139
|
+
}],
|
|
140
|
+
});
|
|
141
|
+
return { threw: false };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return { threw: true, message: String(err.message) };
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
`);
|
|
148
|
+
const readUntil = frameReader(proc);
|
|
149
|
+
const send = (msg: Record<string, unknown>) =>
|
|
150
|
+
(proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
|
|
151
|
+
|
|
152
|
+
await readUntil((fs) => fs.some((f) => f.type === "ready"));
|
|
153
|
+
send({
|
|
154
|
+
type: "call",
|
|
155
|
+
call_id: "c_2",
|
|
156
|
+
fn_name: "probe",
|
|
157
|
+
fn_type: "action",
|
|
158
|
+
args: {},
|
|
159
|
+
auth: { user_id: "u1", is_admin: false, tenant_id: null },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
|
|
163
|
+
// The reject happened locally: no send_email frame ever hit the wire.
|
|
164
|
+
expect(frames.filter((f) => f.type === "send_email")).toHaveLength(0);
|
|
165
|
+
const ret = frames.find((f) => f.type === "return")!.value as {
|
|
166
|
+
threw: boolean;
|
|
167
|
+
message: string;
|
|
168
|
+
};
|
|
169
|
+
expect(ret.threw).toBe(true);
|
|
170
|
+
expect(ret.message).toContain("exceeding");
|
|
171
|
+
|
|
172
|
+
proc.kill();
|
|
173
|
+
await proc.exited;
|
|
174
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy (call_id-keyed) RPCs QUEUE instead of rejecting.
|
|
3
|
+
*
|
|
4
|
+
* The contract under test:
|
|
5
|
+
*
|
|
6
|
+
* 1. An UN-AWAITED `ctx.auth.elevate(...)` followed by
|
|
7
|
+
* `ctx.scheduler.runAfter(...)` works: the elevate frame reaches
|
|
8
|
+
* the host BEFORE the schedule frame (wire order == call order),
|
|
9
|
+
* the schedule reply resolves with the real job id, and the call
|
|
10
|
+
* returns normally. Before the queue, this rejected with
|
|
11
|
+
* "Internal: concurrent RPC attempted on same call_id".
|
|
12
|
+
* 2. `Promise.all` over two legacy RPCs serializes: both frames go
|
|
13
|
+
* out (second only after the first's reply) and each promise
|
|
14
|
+
* resolves with its own reply, not its neighbor's.
|
|
15
|
+
*
|
|
16
|
+
* Drives the REAL runtime.ts dispatcher in a child process via a full
|
|
17
|
+
* `call` frame — the handler is loaded from a temp functions dir, and
|
|
18
|
+
* we play the host over stdin/stdout NDJSON exactly like the Rust side.
|
|
19
|
+
*/
|
|
20
|
+
import { expect, test } from "bun:test";
|
|
21
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
const RUNTIME = join(import.meta.dir, "runtime.ts");
|
|
26
|
+
|
|
27
|
+
/** Collect NDJSON frames from a chunk of the child's stdout. */
|
|
28
|
+
function parseFrames(text: string): Record<string, unknown>[] {
|
|
29
|
+
return text
|
|
30
|
+
.split("\n")
|
|
31
|
+
.filter((l) => l.trim().startsWith("{"))
|
|
32
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Spawn the runtime against a temp functions dir holding `probe.ts`. */
|
|
36
|
+
function spawnRuntime(probeSource: string) {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), "pylon-fn-rpcq-"));
|
|
38
|
+
mkdirSync(join(dir, "functions"));
|
|
39
|
+
writeFileSync(join(dir, "functions", "probe.ts"), probeSource);
|
|
40
|
+
const proc = Bun.spawn([process.execPath, RUNTIME, "./functions"], {
|
|
41
|
+
cwd: dir,
|
|
42
|
+
stdin: "pipe",
|
|
43
|
+
stdout: "pipe",
|
|
44
|
+
stderr: "pipe",
|
|
45
|
+
});
|
|
46
|
+
return proc;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Incremental frame reader over the child's stdout. */
|
|
50
|
+
function frameReader(proc: ReturnType<typeof Bun.spawn>) {
|
|
51
|
+
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let buffered = "";
|
|
54
|
+
return async function readUntil(
|
|
55
|
+
pred: (frames: Record<string, unknown>[]) => boolean,
|
|
56
|
+
): Promise<Record<string, unknown>[]> {
|
|
57
|
+
let frames = parseFrames(buffered);
|
|
58
|
+
while (!pred(frames)) {
|
|
59
|
+
const { done, value } = await reader.read();
|
|
60
|
+
if (done) break;
|
|
61
|
+
buffered += decoder.decode(value, { stream: true });
|
|
62
|
+
frames = parseFrames(buffered);
|
|
63
|
+
}
|
|
64
|
+
return frames;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test("un-awaited elevate then runAfter queues: elevate frame first, real job id back", async () => {
|
|
69
|
+
const proc = spawnRuntime(`
|
|
70
|
+
export default {
|
|
71
|
+
type: "action",
|
|
72
|
+
handler: async (ctx) => {
|
|
73
|
+
// Deliberately NOT awaited — the exact bug pattern under test.
|
|
74
|
+
ctx.auth.elevate({ admin: true, reason: "test" });
|
|
75
|
+
const jobId = await ctx.scheduler.runAfter(5000, "internalTarget", { n: 1 });
|
|
76
|
+
return { jobId, isAdmin: ctx.auth.isAdmin };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
`);
|
|
80
|
+
const readUntil = frameReader(proc);
|
|
81
|
+
const send = (msg: Record<string, unknown>) =>
|
|
82
|
+
(proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
|
|
83
|
+
|
|
84
|
+
await readUntil((fs) => fs.some((f) => f.type === "ready"));
|
|
85
|
+
send({
|
|
86
|
+
type: "call",
|
|
87
|
+
call_id: "c_9",
|
|
88
|
+
fn_name: "probe",
|
|
89
|
+
fn_type: "action",
|
|
90
|
+
args: {},
|
|
91
|
+
auth: { user_id: "u1", is_admin: false, tenant_id: null },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// The elevate frame must arrive BEFORE any schedule frame — and with
|
|
95
|
+
// its reply outstanding, the schedule frame must not have been sent.
|
|
96
|
+
let frames = await readUntil((fs) => fs.some((f) => f.type === "elevate_auth"));
|
|
97
|
+
expect(frames.filter((f) => f.type === "schedule")).toHaveLength(0);
|
|
98
|
+
send({ type: "result", call_id: "c_9", data: { elevated: true } });
|
|
99
|
+
|
|
100
|
+
frames = await readUntil((fs) => fs.some((f) => f.type === "schedule"));
|
|
101
|
+
const schedule = frames.find((f) => f.type === "schedule")!;
|
|
102
|
+
expect(schedule).toMatchObject({
|
|
103
|
+
fn_name: "internalTarget",
|
|
104
|
+
args: { n: 1 },
|
|
105
|
+
delay_ms: 5000,
|
|
106
|
+
});
|
|
107
|
+
send({ type: "result", call_id: "c_9", data: { id: "job_123" } });
|
|
108
|
+
|
|
109
|
+
frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
|
|
110
|
+
const ret = frames.find((f) => f.type === "return")!;
|
|
111
|
+
expect(ret.value).toEqual({ jobId: "job_123", isAdmin: true });
|
|
112
|
+
|
|
113
|
+
proc.kill();
|
|
114
|
+
await proc.exited;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("Promise.all over two legacy RPCs serializes and each resolves its own reply", async () => {
|
|
118
|
+
const proc = spawnRuntime(`
|
|
119
|
+
export default {
|
|
120
|
+
type: "action",
|
|
121
|
+
handler: async (ctx) => {
|
|
122
|
+
const [a, b] = await Promise.all([
|
|
123
|
+
ctx.scheduler.runAfter(1000, "first", {}),
|
|
124
|
+
ctx.scheduler.runAfter(2000, "second", {}),
|
|
125
|
+
]);
|
|
126
|
+
return { a, b };
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
`);
|
|
130
|
+
const readUntil = frameReader(proc);
|
|
131
|
+
const send = (msg: Record<string, unknown>) =>
|
|
132
|
+
(proc.stdin as import("bun").FileSink).write(JSON.stringify(msg) + "\n");
|
|
133
|
+
|
|
134
|
+
await readUntil((fs) => fs.some((f) => f.type === "ready"));
|
|
135
|
+
send({
|
|
136
|
+
type: "call",
|
|
137
|
+
call_id: "c_10",
|
|
138
|
+
fn_name: "probe",
|
|
139
|
+
fn_type: "action",
|
|
140
|
+
args: {},
|
|
141
|
+
auth: { user_id: null, is_admin: true, tenant_id: null },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// First schedule frame out; the second must be held until we reply.
|
|
145
|
+
let frames = await readUntil((fs) => fs.some((f) => f.type === "schedule"));
|
|
146
|
+
expect(frames.filter((f) => f.type === "schedule")).toHaveLength(1);
|
|
147
|
+
expect(frames.find((f) => f.type === "schedule")).toMatchObject({
|
|
148
|
+
fn_name: "first",
|
|
149
|
+
});
|
|
150
|
+
send({ type: "result", call_id: "c_10", data: { id: "s1" } });
|
|
151
|
+
|
|
152
|
+
frames = await readUntil(
|
|
153
|
+
(fs) => fs.filter((f) => f.type === "schedule").length >= 2,
|
|
154
|
+
);
|
|
155
|
+
expect(frames.filter((f) => f.type === "schedule")[1]).toMatchObject({
|
|
156
|
+
fn_name: "second",
|
|
157
|
+
});
|
|
158
|
+
send({ type: "result", call_id: "c_10", data: { id: "s2" } });
|
|
159
|
+
|
|
160
|
+
frames = await readUntil((fs) => fs.some((f) => f.type === "return"));
|
|
161
|
+
expect(frames.find((f) => f.type === "return")!.value).toEqual({
|
|
162
|
+
a: "s1",
|
|
163
|
+
b: "s2",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
proc.kill();
|
|
167
|
+
await proc.exited;
|
|
168
|
+
});
|
package/src/runtime.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import type {
|
|
19
19
|
DbReader,
|
|
20
20
|
DbWriter,
|
|
21
|
+
EmailAttachment,
|
|
22
|
+
EmailOptions,
|
|
21
23
|
EmailSender,
|
|
22
24
|
Stream,
|
|
23
25
|
Scheduler,
|
|
@@ -428,34 +430,68 @@ function rpcStreaming(
|
|
|
428
430
|
}
|
|
429
431
|
|
|
430
432
|
/**
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
|
|
434
|
-
|
|
433
|
+
* Tail of the in-order queue of legacy (call_id-keyed) RPCs, per call.
|
|
434
|
+
* See {@link rpc} for why these serialize.
|
|
435
|
+
*/
|
|
436
|
+
const rpcQueues = new Map<string, Promise<unknown>>();
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* RPC for non-db protocol replies (scheduler.runAfter, elevate, email,
|
|
440
|
+
* nested function calls, etc.). These reply frames carry no op_id, so
|
|
441
|
+
* only one can be in flight per call_id — but instead of rejecting a
|
|
442
|
+
* second request (the old "concurrent RPC attempted on same call_id"
|
|
443
|
+
* error), requests QUEUE: each one waits for the previous request on
|
|
444
|
+
* the same call_id to settle, then sends. The host drains a per-call
|
|
445
|
+
* channel strictly in order, so wire order == call order and the
|
|
446
|
+
* replies can't cross.
|
|
447
|
+
*
|
|
448
|
+
* This is what makes an un-awaited `ctx.auth.elevate(...)` followed by
|
|
449
|
+
* `ctx.scheduler.runAfter(...)` behave correctly: the elevate frame is
|
|
450
|
+
* sent (and replied to) before the schedule frame goes out, so the
|
|
451
|
+
* host-side admin flag is already set when the schedule arm reads it.
|
|
452
|
+
* `Promise.all` over these ctx calls serializes for the same reason.
|
|
453
|
+
*
|
|
454
|
+
* The timeout starts when the frame is actually SENT, not while the
|
|
455
|
+
* request is queued behind a predecessor, and each request chains on
|
|
456
|
+
* the predecessor's SETTLEMENT — a rejected predecessor doesn't poison
|
|
457
|
+
* the rest of the queue.
|
|
435
458
|
*/
|
|
436
459
|
function rpc(callId: string, msg: Record<string, unknown>): Promise<unknown> {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
460
|
+
const prev = rpcQueues.get(callId) ?? Promise.resolve();
|
|
461
|
+
const run = prev
|
|
462
|
+
.then(
|
|
463
|
+
() => undefined,
|
|
464
|
+
() => undefined,
|
|
465
|
+
)
|
|
466
|
+
.then(
|
|
467
|
+
() =>
|
|
468
|
+
new Promise((resolve, reject) => {
|
|
469
|
+
const timeout = setTimeout(() => {
|
|
470
|
+
if (pendingRpcs.has(callId)) {
|
|
471
|
+
pendingRpcs.delete(callId);
|
|
472
|
+
reject(
|
|
473
|
+
new Error(
|
|
474
|
+
`RPC timed out after ${RPC_TIMEOUT_MS}ms (call_id=${callId})`,
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}, RPC_TIMEOUT_MS);
|
|
479
|
+
pendingRpcs.set(callId, { resolve, reject, timeout });
|
|
480
|
+
send({ ...msg, call_id: callId });
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
rpcQueues.set(callId, run);
|
|
484
|
+
// Drop the queue entry once this tail settles (if nothing chained
|
|
485
|
+
// after it) so the map doesn't hold one entry per call_id forever.
|
|
486
|
+
run.then(
|
|
487
|
+
() => {
|
|
488
|
+
if (rpcQueues.get(callId) === run) rpcQueues.delete(callId);
|
|
489
|
+
},
|
|
490
|
+
() => {
|
|
491
|
+
if (rpcQueues.get(callId) === run) rpcQueues.delete(callId);
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
return run;
|
|
459
495
|
}
|
|
460
496
|
|
|
461
497
|
// ---------------------------------------------------------------------------
|
|
@@ -705,17 +741,60 @@ function buildScheduler(callId: string): Scheduler {
|
|
|
705
741
|
* replies success or error. Errors arrive as thrown exceptions on
|
|
706
742
|
* the action's await, just like every other RPC. No silent failures.
|
|
707
743
|
*/
|
|
744
|
+
// Mirrors the Rust-side guard in the send_email dispatch arm: attachments
|
|
745
|
+
// ride one NDJSON line, so an oversized payload is an unbounded allocation
|
|
746
|
+
// on both sides of the pipe. Reject before any framing happens.
|
|
747
|
+
const EMAIL_MAX_ATTACHMENT_B64_BYTES = 15 * 1024 * 1024;
|
|
748
|
+
const EMAIL_MAX_ATTACHMENTS = 20;
|
|
749
|
+
|
|
708
750
|
function buildEmail(callId: string): EmailSender {
|
|
709
|
-
|
|
710
|
-
|
|
751
|
+
async function send(
|
|
752
|
+
toOrOptions: string | EmailOptions,
|
|
753
|
+
subject?: string,
|
|
754
|
+
body?: string,
|
|
755
|
+
): Promise<void> {
|
|
756
|
+
// Options form: first argument is the message object.
|
|
757
|
+
if (typeof toOrOptions === "object" && toOrOptions !== null) {
|
|
758
|
+
const opts = toOrOptions;
|
|
759
|
+
const attachments = opts.attachments ?? [];
|
|
760
|
+
if (attachments.length > EMAIL_MAX_ATTACHMENTS) {
|
|
761
|
+
throw new Error(
|
|
762
|
+
`ctx.email.send: ${attachments.length} attachments exceeds the limit of ${EMAIL_MAX_ATTACHMENTS} per email`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
const b64Total = attachments.reduce(
|
|
766
|
+
(n: number, a: EmailAttachment) => n + a.content.length,
|
|
767
|
+
0,
|
|
768
|
+
);
|
|
769
|
+
if (b64Total > EMAIL_MAX_ATTACHMENT_B64_BYTES) {
|
|
770
|
+
throw new Error(
|
|
771
|
+
`ctx.email.send: attachments total ${b64Total} base64 bytes, exceeding the ${EMAIL_MAX_ATTACHMENT_B64_BYTES}-byte limit (≈11MB of raw file data)`,
|
|
772
|
+
);
|
|
773
|
+
}
|
|
711
774
|
await rpc(callId, {
|
|
712
775
|
type: "send_email",
|
|
713
|
-
to,
|
|
714
|
-
subject,
|
|
715
|
-
body,
|
|
776
|
+
to: opts.to,
|
|
777
|
+
subject: opts.subject,
|
|
778
|
+
body: opts.text,
|
|
779
|
+
html: opts.html,
|
|
780
|
+
// Wire fields are snake_case; contentType is the TS-facing name.
|
|
781
|
+
attachments: attachments.map((a: EmailAttachment) => ({
|
|
782
|
+
filename: a.filename,
|
|
783
|
+
content_type: a.contentType,
|
|
784
|
+
content: a.content,
|
|
785
|
+
})),
|
|
716
786
|
});
|
|
717
|
-
|
|
718
|
-
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
// Positional form: send(to, subject, body).
|
|
790
|
+
await rpc(callId, {
|
|
791
|
+
type: "send_email",
|
|
792
|
+
to: toOrOptions,
|
|
793
|
+
subject,
|
|
794
|
+
body,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
return { send } as EmailSender;
|
|
719
798
|
}
|
|
720
799
|
|
|
721
800
|
/**
|
package/src/types.ts
CHANGED
|
@@ -289,7 +289,7 @@ export interface Scheduler {
|
|
|
289
289
|
* Transactional email transport.
|
|
290
290
|
*
|
|
291
291
|
* Sends through whatever provider the runtime is configured for
|
|
292
|
-
* (PYLON_EMAIL_PROVIDER env var → SendGrid / Resend / Stack0 /
|
|
292
|
+
* (PYLON_EMAIL_PROVIDER env var → SendGrid / Resend / Stack0 /
|
|
293
293
|
* webhook). Available on action ctx only — sending email is external
|
|
294
294
|
* I/O, not allowed in mutation transactions.
|
|
295
295
|
*
|
|
@@ -302,16 +302,51 @@ export interface Scheduler {
|
|
|
302
302
|
* shared auth key can never be used to send arbitrary mail.
|
|
303
303
|
*
|
|
304
304
|
* The runtime owns provider config + credentials; functions only
|
|
305
|
-
* supply the
|
|
306
|
-
*
|
|
305
|
+
* supply the message. Failures are surfaced as thrown errors; on
|
|
306
|
+
* success the return is void.
|
|
307
307
|
*
|
|
308
308
|
* Use cases: invite emails, password-reset hand-offs, notifications,
|
|
309
|
-
* digest reports
|
|
310
|
-
*
|
|
309
|
+
* digest reports, calendar invites (attach an .ics with contentType
|
|
310
|
+
* `text/calendar; method=REQUEST` and mail clients render an
|
|
311
|
+
* RSVP-able event). NOT for marketing email — those should go
|
|
312
|
+
* through a dedicated bulk transport, not the transactional path.
|
|
311
313
|
*/
|
|
314
|
+
export interface EmailAttachment {
|
|
315
|
+
filename: string;
|
|
316
|
+
/**
|
|
317
|
+
* Full MIME content type, passed to the provider VERBATIM —
|
|
318
|
+
* parameterized types like `text/calendar; method=REQUEST` are
|
|
319
|
+
* preserved (that parameter is what makes an invite RSVP-able).
|
|
320
|
+
*/
|
|
321
|
+
contentType: string;
|
|
322
|
+
/** Base64-encoded file bytes. */
|
|
323
|
+
content: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface EmailOptions {
|
|
327
|
+
/** Single recipient address. */
|
|
328
|
+
to: string;
|
|
329
|
+
subject: string;
|
|
330
|
+
/**
|
|
331
|
+
* Plain-text body. Required even with `html` — it's the text/plain
|
|
332
|
+
* part clients with HTML disabled fall back to.
|
|
333
|
+
*/
|
|
334
|
+
text: string;
|
|
335
|
+
/** Optional HTML body; providers send multipart with `text`. */
|
|
336
|
+
html?: string;
|
|
337
|
+
/**
|
|
338
|
+
* Base64 attachments. Limits: at most 20 per email, 15MB of base64
|
|
339
|
+
* text total (≈11MB of raw file data); larger sends throw before
|
|
340
|
+
* any network I/O.
|
|
341
|
+
*/
|
|
342
|
+
attachments?: EmailAttachment[];
|
|
343
|
+
}
|
|
344
|
+
|
|
312
345
|
export interface EmailSender {
|
|
313
346
|
/** Send a plain-text email. `to` is a single address. */
|
|
314
347
|
send(to: string, subject: string, body: string): Promise<void>;
|
|
348
|
+
/** Send with options: HTML body and/or base64 attachments. */
|
|
349
|
+
send(options: EmailOptions): Promise<void>;
|
|
315
350
|
}
|
|
316
351
|
|
|
317
352
|
// ---------------------------------------------------------------------------
|
package/src/validators.ts
CHANGED
|
@@ -115,6 +115,13 @@ export const v = {
|
|
|
115
115
|
|
|
116
116
|
/** Any valid JSON value. */
|
|
117
117
|
any: (): Validator<any, false> => validator("any"),
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Arbitrary JSON value (object, array, or scalar). The validator-side
|
|
121
|
+
* match for `field.json()` — accepts any JSON shape but types the arg
|
|
122
|
+
* as `unknown`, so handlers narrow before use instead of getting `any`.
|
|
123
|
+
*/
|
|
124
|
+
json: (): Validator<unknown, false> => validator("json"),
|
|
118
125
|
};
|
|
119
126
|
|
|
120
127
|
// ---------------------------------------------------------------------------
|
|
@@ -179,6 +186,10 @@ function validateValue(
|
|
|
179
186
|
: `${path}: expected null, got ${typeof value}`;
|
|
180
187
|
case "any":
|
|
181
188
|
return null;
|
|
189
|
+
case "json":
|
|
190
|
+
// Any JSON shape is legal, including null — presence/absence is
|
|
191
|
+
// handled by the optional pass above.
|
|
192
|
+
return null;
|
|
182
193
|
case "literal":
|
|
183
194
|
return value === validator.value
|
|
184
195
|
? null
|