@mandujs/core 0.20.10 → 0.22.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.
Files changed (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -0,0 +1,180 @@
1
+ /**
2
+ * @mandujs/core/desktop — Worker entry
3
+ *
4
+ * Canonical Worker body that owns a single Webview instance. Import as a
5
+ * module URL to spawn alongside a Mandu HTTP server on the main thread:
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // main.ts — main thread owns the Bun.serve() instance
10
+ * import { startServer } from "@mandujs/core";
11
+ * import manifest from "../../.mandu/manifest.json" with { type: "json" };
12
+ *
13
+ * const server = startServer(manifest, { port: 0, hostname: "127.0.0.1" });
14
+ *
15
+ * // Worker owns the Webview — its blocking `run()` never competes with
16
+ * // `Bun.serve()` for the event loop on the main thread.
17
+ * const worker = new Worker(
18
+ * new URL("@mandujs/core/desktop/worker", import.meta.url),
19
+ * );
20
+ *
21
+ * worker.postMessage({
22
+ * type: "open",
23
+ * options: {
24
+ * url: `http://127.0.0.1:${server.server.port}`,
25
+ * title: "Mandu Desktop",
26
+ * width: 1280,
27
+ * height: 800,
28
+ * },
29
+ * });
30
+ *
31
+ * worker.onmessage = (ev) => {
32
+ * if (ev.data?.type === "closed") {
33
+ * server.stop();
34
+ * process.exit(0);
35
+ * }
36
+ * };
37
+ * ```
38
+ *
39
+ * Protocol — exact shapes in `./types.ts`:
40
+ * - Parent → Worker: `{ type: "open", options }` | `{ type: "close" }` | `{ type: "eval", js }`
41
+ * - Worker → Parent: `{ type: "ready" }` | `{ type: "closed" }` | `{ type: "error", message }`
42
+ * | `{ type: "bind-call", name, args, seq }` — reserved for bidirectional bind bridges
43
+ *
44
+ * Functions (bind handlers, onReady, onClose) do NOT cross the `postMessage`
45
+ * boundary — structured clone cannot serialize them. Authoring a Worker with
46
+ * bound handlers requires copying this file into the app and registering
47
+ * handlers locally inside the Worker thread.
48
+ */
49
+
50
+ import type { WorkerInbound, WorkerOutbound } from "./types.js";
51
+ import { createWindow } from "./window.js";
52
+ import type { WindowHandle } from "./types.js";
53
+
54
+ /**
55
+ * Safely post a message to the parent. Bun's Worker global type does not
56
+ * export `postMessage` on `self` in all strictness modes, so we gate on it.
57
+ *
58
+ * @internal
59
+ */
60
+ export function _postToParent(msg: WorkerOutbound): void {
61
+ const gPost = (globalThis as { postMessage?: (msg: unknown) => void })
62
+ .postMessage;
63
+ if (typeof gPost === "function") {
64
+ gPost(msg);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Install the worker message handler. Split out so tests can construct a
70
+ * mock message-event emitter without actually spawning a Worker.
71
+ *
72
+ * @internal
73
+ */
74
+ export function _installHandler(
75
+ addListener: (
76
+ cb: (ev: { data: WorkerInbound }) => Promise<void> | void,
77
+ ) => void,
78
+ createWindowImpl: typeof createWindow = createWindow,
79
+ ): { getHandle: () => WindowHandle | null } {
80
+ let handle: WindowHandle | null = null;
81
+ let running = false;
82
+
83
+ addListener(async (ev) => {
84
+ const msg = ev?.data;
85
+ if (!msg || typeof msg !== "object") return;
86
+
87
+ try {
88
+ switch (msg.type) {
89
+ case "open": {
90
+ if (handle) {
91
+ _postToParent({
92
+ type: "error",
93
+ message: "Window already open — ignoring duplicate 'open'.",
94
+ });
95
+ return;
96
+ }
97
+ handle = await createWindowImpl(msg.options);
98
+ _postToParent({ type: "ready" });
99
+
100
+ // Block the Worker thread on `run()`. When the window closes we
101
+ // report back and let the parent decide whether to process.exit().
102
+ // We wrap in a microtask to unblock the postMessage pipeline; the
103
+ // subsequent `run()` is synchronous-blocking by design.
104
+ if (!running) {
105
+ running = true;
106
+ queueMicrotask(() => {
107
+ try {
108
+ handle!.run();
109
+ } catch (error) {
110
+ _postToParent({
111
+ type: "error",
112
+ message: `window.run() threw: ${
113
+ error instanceof Error ? error.message : String(error)
114
+ }`,
115
+ });
116
+ } finally {
117
+ _postToParent({ type: "closed" });
118
+ }
119
+ });
120
+ }
121
+ return;
122
+ }
123
+
124
+ case "close": {
125
+ if (!handle) return;
126
+ await handle.close();
127
+ return;
128
+ }
129
+
130
+ case "eval": {
131
+ if (!handle) {
132
+ _postToParent({
133
+ type: "error",
134
+ message: "eval received before 'open'.",
135
+ });
136
+ return;
137
+ }
138
+ await handle.eval(msg.js);
139
+ return;
140
+ }
141
+
142
+ default: {
143
+ const unknownType = (msg as { type?: string }).type ?? "<missing>";
144
+ _postToParent({
145
+ type: "error",
146
+ message: `Unknown message type: ${unknownType}`,
147
+ });
148
+ }
149
+ }
150
+ } catch (error) {
151
+ _postToParent({
152
+ type: "error",
153
+ message: error instanceof Error ? error.message : String(error),
154
+ });
155
+ }
156
+ });
157
+
158
+ return {
159
+ getHandle() {
160
+ return handle;
161
+ },
162
+ };
163
+ }
164
+
165
+ // Install the handler on module evaluation — Bun Worker entries run this
166
+ // module body on spawn. We gate on `addEventListener` presence so the file
167
+ // can still be imported from a non-Worker context (tests, type-checking).
168
+ // Bun's Worker global has `addEventListener("message", ...)` but omits the
169
+ // `on`-prefixed shim; we use the standard DOM-style name.
170
+ {
171
+ const gAdd = (globalThis as {
172
+ addEventListener?: (
173
+ type: string,
174
+ listener: (ev: { data: WorkerInbound }) => void | Promise<void>,
175
+ ) => void;
176
+ }).addEventListener;
177
+ if (typeof gAdd === "function") {
178
+ _installHandler((cb) => gAdd.call(globalThis, "message", cb));
179
+ }
180
+ }
@@ -85,22 +85,13 @@ export type MCPConnectionStatus =
85
85
  // ============================================================================
86
86
 
87
87
  /**
88
- * #176: 런타임에 현재 페이지 호스트/포트에서 MCP WebSocket URL을 유추한다.
89
- * Bun 번들러가 `typeof window`를 컴파일 dead-code 제거하므로
90
- * 함수로 감싸서 런타임 평가를 보장한다.
88
+ * #181: 이전에는 `ws://{host}:{port}/mcp` 형태로 자동 유추했으나, dev 서버는
89
+ * 해당 엔드포인트를 제공하지 않아 브라우저가 reconnect 루프에 빠져 멈추는 문제가 있었음.
90
+ * 이제는 명시적인 `serverUrl`을 받지 못하면 connect() 자체가 실패하도록 한다.
91
+ * 호출자가 의도적으로 MCP 엔드포인트 URL을 넘겨야만 WebSocket을 시도한다.
91
92
  */
92
- function resolveDefaultServerUrl(): string {
93
- try {
94
- // eslint-disable-next-line no-restricted-globals
95
- if (globalThis.document && globalThis.location) {
96
- return `ws://${globalThis.location.hostname}:${globalThis.location.port}/mcp`;
97
- }
98
- } catch { /* SSR / Node / Bun 환경 */ }
99
- return 'ws://localhost:3333/mcp';
100
- }
101
-
102
93
  const DEFAULT_OPTIONS: Required<MCPConnectorOptions> = {
103
- serverUrl: '', // connect() 시 resolveDefaultServerUrl()로 대체
94
+ serverUrl: '',
104
95
  connectionTimeout: 5000,
105
96
  requestTimeout: 30000,
106
97
  // #175: MCP 서버가 없을 수 있으므로 기본 비활성화
@@ -150,6 +141,19 @@ export class MCPConnector {
150
141
  return;
151
142
  }
152
143
 
144
+ // #181: 명시적 serverUrl이 없으면 즉시 실패. 자동 URL 유추는
145
+ // 존재하지 않는 /mcp 엔드포인트로 향해 브라우저를 멈추게 했음.
146
+ const url = this.options.serverUrl;
147
+ if (!url) {
148
+ const err = new Error(
149
+ '[Mandu Kitchen] MCPConnector.connect() requires an explicit serverUrl. ' +
150
+ 'No MCP WebSocket endpoint is started by the dev server.',
151
+ );
152
+ this.handleConnectionError(err);
153
+ reject(err);
154
+ return;
155
+ }
156
+
153
157
  this.setStatus('connecting');
154
158
 
155
159
  const timeout = setTimeout(() => {
@@ -158,8 +162,6 @@ export class MCPConnector {
158
162
  }, this.options.connectionTimeout);
159
163
 
160
164
  try {
161
- // #176: 빈 serverUrl이면 런타임에서 현재 페이지 기준으로 유추
162
- const url = this.options.serverUrl || resolveDefaultServerUrl();
163
165
  this.ws = new WebSocket(url);
164
166
 
165
167
  this.ws.onopen = () => {
@@ -35,7 +35,10 @@ const styles = {
35
35
  userSelect: 'none' as const,
36
36
  fontFamily: typography.fontFamily.mono,
37
37
  textTransform: 'uppercase' as const,
38
- border: `1px solid ${colors.background.light}`,
38
+ // #183: borderColor longhand override 충돌을 피하기 위해 shorthand를 분리
39
+ borderWidth: '1px',
40
+ borderStyle: 'solid',
41
+ borderColor: colors.background.light,
39
42
  },
40
43
  content: {
41
44
  display: 'flex',
@@ -119,13 +119,19 @@ const styles = {
119
119
  fontWeight: typography.fontWeight.semibold,
120
120
  letterSpacing: '0.08em',
121
121
  textTransform: 'uppercase' as const,
122
- border: `1px solid ${colors.background.light}`,
122
+ // #183: borderColor longhand override 충돌 회피
123
+ borderWidth: '1px',
124
+ borderStyle: 'solid' as const,
125
+ borderColor: colors.background.light,
123
126
  },
124
127
  headerButton: {
125
128
  padding: `${spacing.xs} ${spacing.sm}`,
126
129
  borderRadius: borderRadius.full,
127
130
  backgroundColor: colors.background.light,
128
- border: `1px solid transparent`,
131
+ // #183: borderColor longhand override 충돌 회피
132
+ borderWidth: '1px',
133
+ borderStyle: 'solid' as const,
134
+ borderColor: 'transparent',
129
135
  color: colors.text.secondary,
130
136
  fontSize: typography.fontSize.xs,
131
137
  fontWeight: typography.fontWeight.medium,
@@ -134,7 +140,10 @@ const styles = {
134
140
  },
135
141
  restartButton: {
136
142
  backgroundColor: colors.background.light,
137
- border: `1px solid transparent`,
143
+ // #183: borderColor longhand override 충돌 회피
144
+ borderWidth: '1px',
145
+ borderStyle: 'solid' as const,
146
+ borderColor: 'transparent',
138
147
  color: colors.text.secondary,
139
148
  fontSize: typography.fontSize.xs,
140
149
  fontWeight: typography.fontWeight.medium,
@@ -146,7 +155,10 @@ const styles = {
146
155
  },
147
156
  closeButton: {
148
157
  backgroundColor: 'transparent',
149
- border: `1px solid transparent`,
158
+ // #183: borderColor longhand override 충돌 회피
159
+ borderWidth: '1px',
160
+ borderStyle: 'solid' as const,
161
+ borderColor: 'transparent',
150
162
  color: colors.text.secondary,
151
163
  fontSize: typography.fontSize.md,
152
164
  cursor: 'pointer',
@@ -190,7 +202,10 @@ const styles = {
190
202
  gap: spacing.xs,
191
203
  padding: `${spacing.sm} ${spacing.sm}`,
192
204
  backgroundColor: 'rgba(255, 255, 255, 0.02)',
193
- border: `1px solid rgba(255, 255, 255, 0.04)`,
205
+ // #183: borderColor longhand override 충돌 회피
206
+ borderWidth: '1px',
207
+ borderStyle: 'solid' as const,
208
+ borderColor: 'rgba(255, 255, 255, 0.04)',
194
209
  borderRadius: borderRadius.md,
195
210
  color: colors.text.secondary,
196
211
  fontSize: typography.fontSize.xs,
@@ -0,0 +1,355 @@
1
+ /**
2
+ * @mandujs/core/email tests
3
+ *
4
+ * Exercises the memory adapter end-to-end (real spool, real timers) and the
5
+ * Resend adapter with a fully injected fetch — we never touch the network.
6
+ * The Resend tests also pin the exact wire format (headers, snake_case body
7
+ * field names) so a future refactor can't silently break the contract.
8
+ */
9
+
10
+ import { describe, it, expect } from "bun:test";
11
+ import {
12
+ createMemoryEmailSender,
13
+ createResendSender,
14
+ createSmtpSender,
15
+ _validateMessage,
16
+ type EmailMessage,
17
+ } from "../index";
18
+ import { _buildResendBody } from "../resend";
19
+
20
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
21
+
22
+ const VALID_MSG: EmailMessage = {
23
+ from: "sender@example.com",
24
+ to: "user@example.com",
25
+ subject: "Hello",
26
+ html: "<p>Hi</p>",
27
+ };
28
+
29
+ /**
30
+ * Builds a fetch stub that returns a JSON response with the supplied `body`
31
+ * and `status`. Records every call for assertions.
32
+ */
33
+ interface FetchCall {
34
+ url: string;
35
+ init: RequestInit;
36
+ }
37
+ function makeFetchStub(options: {
38
+ status?: number;
39
+ body?: unknown;
40
+ rawBody?: string;
41
+ throwError?: Error;
42
+ }): { fetch: typeof globalThis.fetch; calls: FetchCall[] } {
43
+ const calls: FetchCall[] = [];
44
+ const fetchImpl = async (
45
+ input: URL | RequestInfo,
46
+ init?: RequestInit,
47
+ ): Promise<Response> => {
48
+ calls.push({
49
+ url: typeof input === "string" ? input : (input as URL).toString(),
50
+ init: init ?? {},
51
+ });
52
+ if (options.throwError) throw options.throwError;
53
+ const status = options.status ?? 200;
54
+ const body =
55
+ options.rawBody !== undefined
56
+ ? options.rawBody
57
+ : JSON.stringify(options.body ?? {});
58
+ return new Response(body, {
59
+ status,
60
+ headers: { "Content-Type": "application/json" },
61
+ });
62
+ };
63
+ // Bun's `typeof globalThis.fetch` has a `preconnect` callable property.
64
+ // Attach a no-op so the stub satisfies the structural type.
65
+ (fetchImpl as unknown as { preconnect: (url: string) => void }).preconnect =
66
+ () => {};
67
+ return { fetch: fetchImpl as unknown as typeof globalThis.fetch, calls };
68
+ }
69
+
70
+ // ─── Memory adapter ─────────────────────────────────────────────────────────
71
+
72
+ describe("createMemoryEmailSender", () => {
73
+ it("stores the message in .sent with id and sentAt", async () => {
74
+ const sender = createMemoryEmailSender();
75
+ const result = await sender.send(VALID_MSG);
76
+ expect(sender.sent.length).toBe(1);
77
+ expect(sender.sent[0].id).toBe(result.id);
78
+ expect(sender.sent[0].sentAt).toBe(result.sentAt);
79
+ expect(sender.sent[0].subject).toBe("Hello");
80
+ expect(typeof result.id).toBe("string");
81
+ expect(result.id.length).toBeGreaterThan(0);
82
+ expect(typeof result.sentAt).toBe("number");
83
+ });
84
+
85
+ it("returns the same id that was stored", async () => {
86
+ const sender = createMemoryEmailSender();
87
+ const r1 = await sender.send(VALID_MSG);
88
+ const r2 = await sender.send(VALID_MSG);
89
+ expect(sender.sent[0].id).toBe(r1.id);
90
+ expect(sender.sent[1].id).toBe(r2.id);
91
+ expect(r1.id).not.toBe(r2.id); // unique per send
92
+ });
93
+
94
+ it("preserves send order across multiple calls", async () => {
95
+ const sender = createMemoryEmailSender();
96
+ for (let i = 0; i < 5; i++) {
97
+ await sender.send({ ...VALID_MSG, subject: `msg-${i}` });
98
+ }
99
+ expect(sender.sent.map((m) => m.subject)).toEqual([
100
+ "msg-0",
101
+ "msg-1",
102
+ "msg-2",
103
+ "msg-3",
104
+ "msg-4",
105
+ ]);
106
+ });
107
+
108
+ it("clear() empties the spool", async () => {
109
+ const sender = createMemoryEmailSender();
110
+ await sender.send(VALID_MSG);
111
+ await sender.send(VALID_MSG);
112
+ expect(sender.sent.length).toBe(2);
113
+ sender.clear();
114
+ expect(sender.sent.length).toBe(0);
115
+ });
116
+
117
+ it("waitFor(3) resolves after 3 sends", async () => {
118
+ const sender = createMemoryEmailSender();
119
+ // Drive the sends on a delay so waitFor actually has to poll.
120
+ setTimeout(() => {
121
+ void sender.send(VALID_MSG);
122
+ }, 5);
123
+ setTimeout(() => {
124
+ void sender.send(VALID_MSG);
125
+ }, 15);
126
+ setTimeout(() => {
127
+ void sender.send(VALID_MSG);
128
+ }, 25);
129
+ await sender.waitFor(3, 500);
130
+ expect(sender.sent.length).toBeGreaterThanOrEqual(3);
131
+ });
132
+
133
+ it("waitFor(3, 50) rejects with timeout if fewer than 3 sent in 50ms", async () => {
134
+ const sender = createMemoryEmailSender();
135
+ await sender.send(VALID_MSG); // only one, not three
136
+ await expect(sender.waitFor(3, 50)).rejects.toThrow(/waitFor timed out/);
137
+ });
138
+
139
+ it("waitFor() with default args resolves immediately when already satisfied", async () => {
140
+ const sender = createMemoryEmailSender();
141
+ await sender.send(VALID_MSG);
142
+ await sender.waitFor(); // default n=1 — already satisfied
143
+ expect(sender.sent.length).toBe(1);
144
+ });
145
+
146
+ it("send() throws TypeError on missing html AND text", async () => {
147
+ const sender = createMemoryEmailSender();
148
+ await expect(
149
+ sender.send({
150
+ from: "a@b.com",
151
+ to: "c@d.com",
152
+ subject: "x",
153
+ }),
154
+ ).rejects.toBeInstanceOf(TypeError);
155
+ });
156
+
157
+ it("send() throws TypeError on empty to array", async () => {
158
+ const sender = createMemoryEmailSender();
159
+ await expect(
160
+ sender.send({ ...VALID_MSG, to: [] }),
161
+ ).rejects.toThrow(/'to' must be a non-empty/);
162
+ });
163
+
164
+ it("send() accepts a single-string to", async () => {
165
+ const sender = createMemoryEmailSender();
166
+ await sender.send({ ...VALID_MSG, to: "one@example.com" });
167
+ expect(sender.sent[0].to).toBe("one@example.com");
168
+ });
169
+
170
+ it("send() accepts an array to", async () => {
171
+ const sender = createMemoryEmailSender();
172
+ await sender.send({ ...VALID_MSG, to: ["a@x.com", "b@x.com"] });
173
+ expect(sender.sent[0].to).toEqual(["a@x.com", "b@x.com"]);
174
+ });
175
+
176
+ it("send() accepts display-name from", async () => {
177
+ const sender = createMemoryEmailSender();
178
+ await sender.send({ ...VALID_MSG, from: "Alice <alice@example.com>" });
179
+ expect(sender.sent[0].from).toBe("Alice <alice@example.com>");
180
+ });
181
+
182
+ it("send() throws TypeError on malformed from", async () => {
183
+ const sender = createMemoryEmailSender();
184
+ await expect(
185
+ sender.send({ ...VALID_MSG, from: "not-an-email" }),
186
+ ).rejects.toBeInstanceOf(TypeError);
187
+ });
188
+
189
+ it("send() throws TypeError on empty subject", async () => {
190
+ const sender = createMemoryEmailSender();
191
+ await expect(
192
+ sender.send({ ...VALID_MSG, subject: "" }),
193
+ ).rejects.toThrow(/subject/);
194
+ });
195
+
196
+ it("send() accepts text-only body", async () => {
197
+ const sender = createMemoryEmailSender();
198
+ await sender.send({
199
+ from: "a@b.com",
200
+ to: "c@d.com",
201
+ subject: "plain",
202
+ text: "hello",
203
+ });
204
+ expect(sender.sent[0].text).toBe("hello");
205
+ expect(sender.sent[0].html).toBeUndefined();
206
+ });
207
+
208
+ it("_validateMessage throws on non-object input", () => {
209
+ expect(() =>
210
+ _validateMessage(null as unknown as EmailMessage),
211
+ ).toThrow(/must be an object/);
212
+ });
213
+ });
214
+
215
+ // ─── Resend adapter ─────────────────────────────────────────────────────────
216
+
217
+ describe("createResendSender", () => {
218
+ it("requires an apiKey", () => {
219
+ expect(() => createResendSender({ apiKey: "" })).toThrow(/apiKey/);
220
+ });
221
+
222
+ it("happy path: returns id + sentAt on 2xx", async () => {
223
+ const { fetch, calls } = makeFetchStub({ body: { id: "resend-123" } });
224
+ const sender = createResendSender({ apiKey: "re_test", fetch });
225
+ const result = await sender.send(VALID_MSG);
226
+ expect(result.id).toBe("resend-123");
227
+ expect(typeof result.sentAt).toBe("number");
228
+ expect(calls.length).toBe(1);
229
+ });
230
+
231
+ it("posts with correct headers and JSON body shape", async () => {
232
+ const { fetch, calls } = makeFetchStub({ body: { id: "id-1" } });
233
+ const sender = createResendSender({ apiKey: "re_abcdef", fetch });
234
+ await sender.send({
235
+ from: "Alice <alice@a.com>",
236
+ to: "bob@b.com",
237
+ subject: "hi",
238
+ html: "<p>hi</p>",
239
+ });
240
+ const call = calls[0];
241
+ expect(call.url).toBe("https://api.resend.com/emails");
242
+ expect(call.init.method).toBe("POST");
243
+ const headers = call.init.headers as Record<string, string>;
244
+ expect(headers.Authorization).toBe("Bearer re_abcdef");
245
+ expect(headers["Content-Type"]).toBe("application/json");
246
+ const body = JSON.parse(call.init.body as string);
247
+ expect(body.from).toBe("Alice <alice@a.com>");
248
+ expect(body.to).toEqual(["bob@b.com"]);
249
+ expect(body.subject).toBe("hi");
250
+ expect(body.html).toBe("<p>hi</p>");
251
+ });
252
+
253
+ it("passes array to through as array", async () => {
254
+ const { fetch, calls } = makeFetchStub({ body: { id: "id-2" } });
255
+ const sender = createResendSender({ apiKey: "k", fetch });
256
+ await sender.send({ ...VALID_MSG, to: ["a@x.com", "b@x.com"] });
257
+ const body = JSON.parse(calls[0].init.body as string);
258
+ expect(Array.isArray(body.to)).toBe(true);
259
+ expect(body.to).toEqual(["a@x.com", "b@x.com"]);
260
+ });
261
+
262
+ it("converts replyTo → reply_to (snake_case)", async () => {
263
+ const { fetch, calls } = makeFetchStub({ body: { id: "id-3" } });
264
+ const sender = createResendSender({ apiKey: "k", fetch });
265
+ await sender.send({ ...VALID_MSG, replyTo: "reply@x.com" });
266
+ const body = JSON.parse(calls[0].init.body as string);
267
+ expect(body.reply_to).toBe("reply@x.com");
268
+ expect(body.replyTo).toBeUndefined();
269
+ });
270
+
271
+ it("lowercases custom headers", async () => {
272
+ const { fetch, calls } = makeFetchStub({ body: { id: "id-4" } });
273
+ const sender = createResendSender({ apiKey: "k", fetch });
274
+ await sender.send({
275
+ ...VALID_MSG,
276
+ headers: { "X-Tag": "promo", "X-Campaign": "spring" },
277
+ });
278
+ const body = JSON.parse(calls[0].init.body as string);
279
+ expect(body.headers).toEqual({ "x-tag": "promo", "x-campaign": "spring" });
280
+ });
281
+
282
+ it("throws on non-2xx with status and body excerpt", async () => {
283
+ const { fetch } = makeFetchStub({
284
+ status: 422,
285
+ rawBody: "validation failed: invalid from address",
286
+ });
287
+ const sender = createResendSender({ apiKey: "k", fetch });
288
+ await expect(sender.send(VALID_MSG)).rejects.toThrow(/status=422/);
289
+ await expect(sender.send(VALID_MSG)).rejects.toThrow(/validation failed/);
290
+ });
291
+
292
+ it("throws on network failure with cause context", async () => {
293
+ const { fetch } = makeFetchStub({
294
+ throwError: new Error("ECONNRESET"),
295
+ });
296
+ const sender = createResendSender({ apiKey: "k", fetch });
297
+ await expect(sender.send(VALID_MSG)).rejects.toThrow(/email send failed/);
298
+ await expect(sender.send(VALID_MSG)).rejects.toThrow(/ECONNRESET/);
299
+ });
300
+
301
+ it("honors baseUrl override (trailing slash stripped)", async () => {
302
+ const { fetch, calls } = makeFetchStub({ body: { id: "id-5" } });
303
+ const sender = createResendSender({
304
+ apiKey: "k",
305
+ fetch,
306
+ baseUrl: "https://private.resend.example.com/",
307
+ });
308
+ await sender.send(VALID_MSG);
309
+ expect(calls[0].url).toBe("https://private.resend.example.com/emails");
310
+ });
311
+
312
+ it("throws when provider response is missing id", async () => {
313
+ const { fetch } = makeFetchStub({ body: { somethingElse: 1 } });
314
+ const sender = createResendSender({ apiKey: "k", fetch });
315
+ await expect(sender.send(VALID_MSG)).rejects.toThrow(/missing 'id'/);
316
+ });
317
+
318
+ it("validates message before calling fetch (fast-fail)", async () => {
319
+ const { fetch, calls } = makeFetchStub({ body: { id: "x" } });
320
+ const sender = createResendSender({ apiKey: "k", fetch });
321
+ await expect(
322
+ sender.send({ ...VALID_MSG, from: "bad" }),
323
+ ).rejects.toBeInstanceOf(TypeError);
324
+ expect(calls.length).toBe(0);
325
+ });
326
+
327
+ it("_buildResendBody omits undefined optional fields", () => {
328
+ const body = _buildResendBody(VALID_MSG);
329
+ expect(body.text).toBeUndefined();
330
+ expect(body.cc).toBeUndefined();
331
+ expect(body.bcc).toBeUndefined();
332
+ expect(body.reply_to).toBeUndefined();
333
+ expect(body.headers).toBeUndefined();
334
+ });
335
+
336
+ it("_buildResendBody includes cc/bcc arrays when provided", () => {
337
+ const body = _buildResendBody({
338
+ ...VALID_MSG,
339
+ cc: "c@c.com",
340
+ bcc: ["d@d.com", "e@e.com"],
341
+ });
342
+ expect(body.cc).toEqual(["c@c.com"]);
343
+ expect(body.bcc).toEqual(["d@d.com", "e@e.com"]);
344
+ });
345
+ });
346
+
347
+ // ─── SMTP stub ──────────────────────────────────────────────────────────────
348
+
349
+ describe("createSmtpSender (stub)", () => {
350
+ it("throws with the planned-but-not-implemented message", () => {
351
+ expect(() =>
352
+ createSmtpSender({ host: "smtp.example.com" }),
353
+ ).toThrow(/not yet implemented/);
354
+ });
355
+ });