@shaferllc/keel 0.66.0 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +169 -0
- package/README.md +44 -6
- package/bin/keel-mcp.mjs +9 -0
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +8 -2
- package/dist/core/cache.d.ts +81 -5
- package/dist/core/cache.js +188 -23
- package/dist/core/cli/stubs.d.ts +26 -0
- package/dist/core/cli/stubs.js +225 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +39 -13
- package/dist/core/index.js +19 -6
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +89 -4
- package/dist/core/logger.js +167 -22
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +325 -14
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +299 -7
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +3206 -0
- package/docs/ai.md +128 -0
- package/docs/api-resources.md +118 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +385 -0
- package/docs/console.md +536 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +631 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +121 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +208 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/i18n.ts +117 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +166 -0
- package/docs/examples/mail.ts +263 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/pages.ts +82 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/telemetry.ts +127 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +215 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/i18n.md +302 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +436 -0
- package/docs/mail.md +751 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +452 -0
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +314 -0
- package/docs/testing.md +376 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/docs/watch.md +118 -0
- package/llms-full.txt +19816 -0
- package/llms.txt +127 -0
- package/package.json +42 -7
package/dist/core/mail.js
CHANGED
|
@@ -13,15 +13,33 @@
|
|
|
13
13
|
* .html("<h1>Hi</h1>")
|
|
14
14
|
* .send();
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* Sending is slow and it fails — which is exactly what a queue is for. `sendLater()`
|
|
17
|
+
* puts the message on the queue instead of holding the request open for an SMTP
|
|
18
|
+
* round trip:
|
|
19
|
+
*
|
|
20
|
+
* await mail().to(user.email).subject("Welcome").html(body).sendLater();
|
|
21
|
+
*
|
|
22
|
+
* In tests, `fakeMail()` swaps the transport for one that records and asserts.
|
|
23
|
+
*/
|
|
24
|
+
import { logger, emit, hasApplication } from "./helpers.js";
|
|
25
|
+
import { dispatch, Job } from "./queue.js";
|
|
26
|
+
import { contentTypeFor } from "./storage.js";
|
|
27
|
+
/**
|
|
28
|
+
* Fire a mail lifecycle event, but only if there's an application to fire it on —
|
|
29
|
+
* the mailer has to work in a unit test that never bootstrapped one. A listener
|
|
30
|
+
* that throws still surfaces; it's only the missing-app case we skip.
|
|
17
31
|
*/
|
|
18
|
-
|
|
32
|
+
async function notify(event, message) {
|
|
33
|
+
if (hasApplication())
|
|
34
|
+
await emit(event, message);
|
|
35
|
+
}
|
|
19
36
|
function list(value) {
|
|
20
37
|
if (value === undefined)
|
|
21
38
|
return [];
|
|
22
39
|
return Array.isArray(value) ? value : [value];
|
|
23
40
|
}
|
|
24
|
-
|
|
41
|
+
/* --------------------------------- builder -------------------------------- */
|
|
42
|
+
/** A fluent, immutable-ish builder — chain setters, then `send()` or `sendLater()`. */
|
|
25
43
|
export class PendingMail {
|
|
26
44
|
mailer;
|
|
27
45
|
message = { to: [], subject: "" };
|
|
@@ -64,6 +82,29 @@ export class PendingMail {
|
|
|
64
82
|
(this.message.headers ??= {})[name] = value;
|
|
65
83
|
return this;
|
|
66
84
|
}
|
|
85
|
+
/** Attach a file. The content type is inferred from the extension if omitted. */
|
|
86
|
+
attach(filename, content, contentType) {
|
|
87
|
+
(this.message.attachments ??= []).push({
|
|
88
|
+
filename,
|
|
89
|
+
content,
|
|
90
|
+
contentType: contentType ?? contentTypeFor(filename),
|
|
91
|
+
});
|
|
92
|
+
return this;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Attach an image and give it a content id, so the HTML body can show it inline:
|
|
96
|
+
*
|
|
97
|
+
* .embed("logo", bytes, "logo.png").html(`<img src="cid:logo">`)
|
|
98
|
+
*/
|
|
99
|
+
embed(cid, content, filename = cid, contentType) {
|
|
100
|
+
(this.message.attachments ??= []).push({
|
|
101
|
+
filename,
|
|
102
|
+
content,
|
|
103
|
+
contentType: contentType ?? contentTypeFor(filename),
|
|
104
|
+
cid,
|
|
105
|
+
});
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
67
108
|
/** Seed several fields at once (merges into what's been chained). */
|
|
68
109
|
fill(partial) {
|
|
69
110
|
const { to, cc, bcc, ...rest } = partial;
|
|
@@ -76,25 +117,79 @@ export class PendingMail {
|
|
|
76
117
|
Object.assign(this.message, rest);
|
|
77
118
|
return this;
|
|
78
119
|
}
|
|
120
|
+
/** The message as composed so far — before the mailer applies its defaults. */
|
|
121
|
+
toMessage() {
|
|
122
|
+
return { ...this.message };
|
|
123
|
+
}
|
|
79
124
|
/** Freeze and hand the message to the mailer. */
|
|
80
125
|
async send() {
|
|
81
126
|
return this.mailer.send(this.message);
|
|
82
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Queue the message instead of sending it now — the request returns without
|
|
130
|
+
* waiting on the provider, and a failed send retries on the queue rather than
|
|
131
|
+
* failing the user's action.
|
|
132
|
+
*
|
|
133
|
+
* With the default `SyncDriver` this still sends inline; register a real queue
|
|
134
|
+
* driver to actually defer it.
|
|
135
|
+
*/
|
|
136
|
+
async sendLater() {
|
|
137
|
+
await this.mailer.sendLater(this.message);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/* ------------------------------- class mails ------------------------------ */
|
|
141
|
+
/**
|
|
142
|
+
* A reusable, testable email as a class — the mail equivalent of a `Job`.
|
|
143
|
+
* Implement `build()` to compose the message; the mailer calls it for you.
|
|
144
|
+
*
|
|
145
|
+
* export class WelcomeEmail extends BaseMail {
|
|
146
|
+
* constructor(private user: User) { super(); }
|
|
147
|
+
*
|
|
148
|
+
* build(message: PendingMail) {
|
|
149
|
+
* message.to(this.user.email).subject("Welcome").html(`<h1>Hi ${this.user.name}</h1>`);
|
|
150
|
+
* }
|
|
151
|
+
* }
|
|
152
|
+
*
|
|
153
|
+
* await send(new WelcomeEmail(user)); // or sendLater(new WelcomeEmail(user))
|
|
154
|
+
*/
|
|
155
|
+
export class BaseMail {
|
|
156
|
+
}
|
|
157
|
+
/** The job that carries a queued message. Exported so a driver can recognize it. */
|
|
158
|
+
export class SendMailJob extends Job {
|
|
159
|
+
message;
|
|
160
|
+
mailerName;
|
|
161
|
+
constructor(message, mailerName) {
|
|
162
|
+
super();
|
|
163
|
+
this.message = message;
|
|
164
|
+
this.mailerName = mailerName;
|
|
165
|
+
}
|
|
166
|
+
async handle() {
|
|
167
|
+
await mailer(this.mailerName).send(this.message);
|
|
168
|
+
}
|
|
83
169
|
}
|
|
170
|
+
/* --------------------------------- mailer --------------------------------- */
|
|
84
171
|
export class Mailer {
|
|
85
172
|
transport;
|
|
86
173
|
options;
|
|
87
|
-
|
|
174
|
+
name;
|
|
175
|
+
constructor(transport, options = {},
|
|
176
|
+
/** The name this mailer is registered under — carried onto queued jobs. */
|
|
177
|
+
name = "default") {
|
|
88
178
|
this.transport = transport;
|
|
89
179
|
this.options = options;
|
|
180
|
+
this.name = name;
|
|
90
181
|
}
|
|
91
182
|
/** Start composing a message. */
|
|
92
183
|
message() {
|
|
93
184
|
return new PendingMail(this);
|
|
94
185
|
}
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
const final = {
|
|
186
|
+
/** Apply this mailer's defaults and check the message is sendable. */
|
|
187
|
+
prepare(message) {
|
|
188
|
+
const final = {
|
|
189
|
+
...message,
|
|
190
|
+
from: message.from ?? this.options.from,
|
|
191
|
+
replyTo: message.replyTo ?? this.options.replyTo,
|
|
192
|
+
};
|
|
98
193
|
if (!final.to.length)
|
|
99
194
|
throw new Error("Mail: at least one recipient (to) is required.");
|
|
100
195
|
if (!final.subject)
|
|
@@ -103,9 +198,29 @@ export class Mailer {
|
|
|
103
198
|
throw new Error("Mail: a text or html body is required.");
|
|
104
199
|
if (!final.from)
|
|
105
200
|
throw new Error("Mail: a from address is required (set one or a default).");
|
|
201
|
+
return final;
|
|
202
|
+
}
|
|
203
|
+
/** Validate, apply defaults, and dispatch through the transport. */
|
|
204
|
+
async send(message) {
|
|
205
|
+
const final = this.prepare(message);
|
|
206
|
+
await notify("mail.sending", final);
|
|
106
207
|
await this.transport.send(final);
|
|
208
|
+
await notify("mail.sent", final);
|
|
209
|
+
return final;
|
|
210
|
+
}
|
|
211
|
+
/** Validate, apply defaults, and put the message on the queue. */
|
|
212
|
+
async sendLater(message) {
|
|
213
|
+
// Validate *now*, not on the worker: a malformed message should blow up at
|
|
214
|
+
// the call site, where the stack trace means something.
|
|
215
|
+
const final = this.prepare(message);
|
|
216
|
+
await dispatch(new SendMailJob(final, this.name));
|
|
217
|
+
await notify("mail.queued", final);
|
|
107
218
|
return final;
|
|
108
219
|
}
|
|
220
|
+
/** The underlying transport, for provider-specific operations. */
|
|
221
|
+
get driver() {
|
|
222
|
+
return this.transport;
|
|
223
|
+
}
|
|
109
224
|
}
|
|
110
225
|
/* ------------------------------- transports ------------------------------- */
|
|
111
226
|
/** Collects sent messages in memory — the default, and ideal for tests. */
|
|
@@ -140,18 +255,151 @@ export function fetchTransport(options) {
|
|
|
140
255
|
},
|
|
141
256
|
};
|
|
142
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* A `Mailer` that records instead of delivering, with assertions — what
|
|
260
|
+
* `fakeMail()` installs so tests never talk to a provider.
|
|
261
|
+
*/
|
|
262
|
+
export class FakeMailer extends Mailer {
|
|
263
|
+
mails = [];
|
|
264
|
+
constructor(name = "default", options = {}) {
|
|
265
|
+
// A from address so a fake never trips the "from is required" check.
|
|
266
|
+
super(new ArrayTransport(), { from: "fake@example.com", ...options }, name);
|
|
267
|
+
}
|
|
268
|
+
async send(message) {
|
|
269
|
+
const final = await super.send(message);
|
|
270
|
+
this.mails.push({ message: final, queued: false });
|
|
271
|
+
return final;
|
|
272
|
+
}
|
|
273
|
+
async sendLater(message) {
|
|
274
|
+
// Validate exactly as a real mailer would, but don't touch the queue —
|
|
275
|
+
// recording the intent *is* the point.
|
|
276
|
+
const final = this.prepare(message);
|
|
277
|
+
this.mails.push({ message: final, queued: true });
|
|
278
|
+
await notify("mail.queued", final);
|
|
279
|
+
return final;
|
|
280
|
+
}
|
|
281
|
+
/** Everything sent immediately. */
|
|
282
|
+
sent() {
|
|
283
|
+
return this.mails.filter((m) => !m.queued).map((m) => m.message);
|
|
284
|
+
}
|
|
285
|
+
/** Everything queued with `sendLater()`. */
|
|
286
|
+
queued() {
|
|
287
|
+
return this.mails.filter((m) => m.queued).map((m) => m.message);
|
|
288
|
+
}
|
|
289
|
+
assertSent(where) {
|
|
290
|
+
const matches = this.sent().filter((m) => where?.(m) ?? true);
|
|
291
|
+
if (matches.length)
|
|
292
|
+
return;
|
|
293
|
+
throw new Error(where && this.sent().length
|
|
294
|
+
? `Expected a sent mail matching the predicate. ${this.sent().length} were sent, but none matched.`
|
|
295
|
+
: `Expected a mail to be sent, but none was. ${this.summary()}`);
|
|
296
|
+
}
|
|
297
|
+
assertNotSent(where) {
|
|
298
|
+
const found = this.sent().filter((m) => where?.(m) ?? true).length;
|
|
299
|
+
if (found)
|
|
300
|
+
throw new Error(`Expected no matching mail to be sent, but ${found} was.`);
|
|
301
|
+
}
|
|
302
|
+
assertSentCount(expected) {
|
|
303
|
+
const found = this.sent().length;
|
|
304
|
+
if (found !== expected) {
|
|
305
|
+
throw new Error(`Expected ${expected} mail(s) to be sent, but ${found} were.`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
assertQueued(where) {
|
|
309
|
+
const matches = this.queued().filter((m) => where?.(m) ?? true);
|
|
310
|
+
if (matches.length)
|
|
311
|
+
return;
|
|
312
|
+
throw new Error(where && this.queued().length
|
|
313
|
+
? `Expected a queued mail matching the predicate. ${this.queued().length} were queued, but none matched.`
|
|
314
|
+
: `Expected a mail to be queued, but none was. ${this.summary()}`);
|
|
315
|
+
}
|
|
316
|
+
assertNotQueued(where) {
|
|
317
|
+
const found = this.queued().filter((m) => where?.(m) ?? true).length;
|
|
318
|
+
if (found)
|
|
319
|
+
throw new Error(`Expected no matching mail to be queued, but ${found} was.`);
|
|
320
|
+
}
|
|
321
|
+
assertQueuedCount(expected) {
|
|
322
|
+
const found = this.queued().length;
|
|
323
|
+
if (found !== expected) {
|
|
324
|
+
throw new Error(`Expected ${expected} mail(s) to be queued, but ${found} were.`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** Nothing sent and nothing queued. */
|
|
328
|
+
assertNothingSent() {
|
|
329
|
+
if (this.mails.length) {
|
|
330
|
+
throw new Error(`Expected no mail at all, but ${this.mails.length} were recorded. ${this.summary()}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
summary() {
|
|
334
|
+
if (!this.mails.length)
|
|
335
|
+
return "No mail was sent or queued.";
|
|
336
|
+
const subjects = this.mails.map((m) => `"${m.message.subject}"`).join(", ");
|
|
337
|
+
return `Recorded: ${subjects}.`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
143
340
|
/* --------------------------------- global --------------------------------- */
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
341
|
+
const mailers = new Map([["default", new Mailer(new ArrayTransport())]]);
|
|
342
|
+
/** Mailers displaced by `fakeMail()`, so `restoreMail()` can put them back. */
|
|
343
|
+
const realMailers = new Map();
|
|
344
|
+
/** Register a mailer, optionally under a name (default: `"default"`). */
|
|
345
|
+
export function setMailer(transport, options = {}, name = "default") {
|
|
346
|
+
const instance = new Mailer(transport, options, name);
|
|
347
|
+
mailers.set(name, instance);
|
|
348
|
+
return instance;
|
|
349
|
+
}
|
|
350
|
+
/** The default mailer, or a named one registered with `setMailer(…, …, name)`. */
|
|
351
|
+
export function mailer(name = "default") {
|
|
352
|
+
const instance = mailers.get(name);
|
|
353
|
+
if (!instance)
|
|
354
|
+
throw new Error(`No mailer named "${name}". Register it with setMailer().`);
|
|
355
|
+
return instance;
|
|
149
356
|
}
|
|
150
357
|
/** The default mailer instance. */
|
|
151
358
|
export function getMailer() {
|
|
152
|
-
return mailer;
|
|
359
|
+
return mailer();
|
|
360
|
+
}
|
|
361
|
+
/** Start a message on a mailer: `mail().to(…).subject(…).send()`. */
|
|
362
|
+
export function mail(name = "default") {
|
|
363
|
+
return mailer(name).message();
|
|
364
|
+
}
|
|
365
|
+
/** Build a `BaseMail` into a message on the given mailer. */
|
|
366
|
+
async function build(email, name) {
|
|
367
|
+
const pending = mailer(name).message();
|
|
368
|
+
await email.build(pending);
|
|
369
|
+
return pending.toMessage();
|
|
153
370
|
}
|
|
154
|
-
/**
|
|
155
|
-
export function
|
|
156
|
-
return mailer.
|
|
371
|
+
/** Send a class-based mail now. */
|
|
372
|
+
export async function send(email, name = "default") {
|
|
373
|
+
return mailer(name).send(await build(email, name));
|
|
374
|
+
}
|
|
375
|
+
/** Queue a class-based mail. */
|
|
376
|
+
export async function sendLater(email, name = "default") {
|
|
377
|
+
return mailer(name).sendLater(await build(email, name));
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Swap a mailer for a `FakeMailer` so tests never talk to a provider, and assert
|
|
381
|
+
* on what was sent or queued. Undo with `restoreMail()`.
|
|
382
|
+
*
|
|
383
|
+
* const mail = fakeMail();
|
|
384
|
+
* await registerUser();
|
|
385
|
+
* mail.assertQueued((m) => m.subject === "Welcome");
|
|
386
|
+
*/
|
|
387
|
+
export function fakeMail(name = "default") {
|
|
388
|
+
const existing = mailers.get(name);
|
|
389
|
+
// Only remember the *real* mailer — faking twice must not stash a fake.
|
|
390
|
+
if (existing && !realMailers.has(name))
|
|
391
|
+
realMailers.set(name, existing);
|
|
392
|
+
const fake = new FakeMailer(name);
|
|
393
|
+
mailers.set(name, fake);
|
|
394
|
+
return fake;
|
|
395
|
+
}
|
|
396
|
+
/** Restore the real mailer after `fakeMail()`. With no name, restores them all. */
|
|
397
|
+
export function restoreMail(name) {
|
|
398
|
+
const names = name ? [name] : [...realMailers.keys()];
|
|
399
|
+
for (const key of names) {
|
|
400
|
+
const real = realMailers.get(key);
|
|
401
|
+
if (real)
|
|
402
|
+
mailers.set(key, real);
|
|
403
|
+
realMailers.delete(key);
|
|
404
|
+
}
|
|
157
405
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { db } from "./database.js";
|
|
21
21
|
import { getMailer } from "./mail.js";
|
|
22
22
|
import { dispatch } from "./queue.js";
|
|
23
|
+
import { instrument, currentRequestId } from "./instrumentation.js";
|
|
23
24
|
export class Notification {
|
|
24
25
|
/** Deliver from a queued job instead of inline. */
|
|
25
26
|
shouldQueue = false;
|
|
@@ -103,8 +104,16 @@ export class Notifier {
|
|
|
103
104
|
async send(notifiables, notification) {
|
|
104
105
|
const recipients = Array.isArray(notifiables) ? notifiables : [notifiables];
|
|
105
106
|
const run = async () => {
|
|
106
|
-
for (const recipient of recipients)
|
|
107
|
+
for (const recipient of recipients) {
|
|
107
108
|
await this.deliver(recipient, notification);
|
|
109
|
+
const requestId = currentRequestId();
|
|
110
|
+
instrument("notification.sent", {
|
|
111
|
+
notification: notification.constructor.name,
|
|
112
|
+
channels: notification.via(recipient),
|
|
113
|
+
notifiable: recipient,
|
|
114
|
+
...(requestId ? { requestId } : {}),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
108
117
|
};
|
|
109
118
|
if (notification.shouldQueue)
|
|
110
119
|
await dispatch(run);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package system. Keel's `ServiceProvider` is already its plugin unit —
|
|
3
|
+
* `PackageProvider` is the same thing with batteries for the jobs a *shippable*
|
|
4
|
+
* package does over and over: merge its config defaults, register its routes and
|
|
5
|
+
* bundled UI assets, contribute migrations and console commands, and declare
|
|
6
|
+
* files a consuming app can publish. Each helper is a thin wrapper over an
|
|
7
|
+
* existing Keel primitive; the value is the convention, not new machinery.
|
|
8
|
+
*
|
|
9
|
+
* export class WatchServiceProvider extends PackageProvider {
|
|
10
|
+
* readonly name = "watch";
|
|
11
|
+
* register() {
|
|
12
|
+
* this.mergeConfig("watch", defaultConfig);
|
|
13
|
+
* this.migrations([watchMigration]);
|
|
14
|
+
* this.publishes({ [stub]: "config/watch.ts" }, "watch-config");
|
|
15
|
+
* }
|
|
16
|
+
* boot() {
|
|
17
|
+
* this.assets("watch/assets", uiDir, { immutable: true });
|
|
18
|
+
* this.routes((r) => registerWatchRoutes(r), { prefix: "watch", as: "watch" });
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* A note on lifecycle: register/boot run *before* the app's HTTP kernel is bound
|
|
23
|
+
* (see `bootstrap/app.ts`), so a package must not reach for the kernel. That's
|
|
24
|
+
* why `routes()` and `assets()` go through the `Router` (bound in the
|
|
25
|
+
* Application constructor) — routes are compiled onto the kernel later, at build.
|
|
26
|
+
*/
|
|
27
|
+
import type { MiddlewareHandler } from "hono";
|
|
28
|
+
import type { Command } from "commander";
|
|
29
|
+
import { ServiceProvider } from "./provider.js";
|
|
30
|
+
import type { Application } from "./application.js";
|
|
31
|
+
import type { Router, RouteGroup, MiddlewareRef } from "./http/router.js";
|
|
32
|
+
import type { Migration } from "./migrations.js";
|
|
33
|
+
/**
|
|
34
|
+
* Every migration a package contributes, gathered in one place so `keel migrate`
|
|
35
|
+
* can run them alongside the app's own. Bound as a singleton on the Application.
|
|
36
|
+
*/
|
|
37
|
+
export declare class MigrationRegistry {
|
|
38
|
+
private list;
|
|
39
|
+
add(migrations: Migration[]): void;
|
|
40
|
+
all(): Migration[];
|
|
41
|
+
}
|
|
42
|
+
/** A console command a package adds to `keel`. */
|
|
43
|
+
export interface PackageCommand {
|
|
44
|
+
/** The command name, e.g. `"watch:prune"`. */
|
|
45
|
+
name: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
/** Add arguments/options to the commander command before its action. */
|
|
48
|
+
configure?: (cmd: Command) => void;
|
|
49
|
+
/** What the command does. Receives parsed options and the command. */
|
|
50
|
+
action: (opts: Record<string, unknown>, cmd: Command) => void | Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
/** Package-contributed console commands, mounted by the CLI after boot. */
|
|
53
|
+
export declare class CommandRegistry {
|
|
54
|
+
private list;
|
|
55
|
+
add(commands: PackageCommand[]): void;
|
|
56
|
+
all(): PackageCommand[];
|
|
57
|
+
}
|
|
58
|
+
/** One `publishes()` declaration: source→destination files, optionally tagged. */
|
|
59
|
+
export interface PublishEntry {
|
|
60
|
+
package: string;
|
|
61
|
+
tag?: string;
|
|
62
|
+
/** Absolute-or-package source path → app-relative destination path. */
|
|
63
|
+
files: Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
/** What `keel vendor:publish` copies into a consuming app. */
|
|
66
|
+
export declare class PublishRegistry {
|
|
67
|
+
private entries;
|
|
68
|
+
add(entry: PublishEntry): void;
|
|
69
|
+
all(tag?: string): PublishEntry[];
|
|
70
|
+
tags(): string[];
|
|
71
|
+
}
|
|
72
|
+
export interface PackageRouteOptions {
|
|
73
|
+
/** Mount every route under this path prefix. */
|
|
74
|
+
prefix?: string;
|
|
75
|
+
/** Middleware to run before every route in the group. */
|
|
76
|
+
middleware?: MiddlewareRef | MiddlewareRef[];
|
|
77
|
+
/** Prefix every named route with `<as>.`. */
|
|
78
|
+
as?: string;
|
|
79
|
+
}
|
|
80
|
+
export interface PackageAssetOptions {
|
|
81
|
+
/** Cache-Control max-age (seconds). */
|
|
82
|
+
maxAge?: number;
|
|
83
|
+
/** Add `immutable` — for content-hashed filenames. */
|
|
84
|
+
immutable?: boolean;
|
|
85
|
+
}
|
|
86
|
+
export declare abstract class PackageProvider<O = Record<string, unknown>> extends ServiceProvider<O> {
|
|
87
|
+
/** The package's short name — used for publish grouping and diagnostics. */
|
|
88
|
+
abstract readonly name: string;
|
|
89
|
+
constructor(app: Application, options?: O);
|
|
90
|
+
/**
|
|
91
|
+
* Set config defaults under `key` without clobbering what the app already
|
|
92
|
+
* configured. Config files load before providers, so the app's `config/<key>.ts`
|
|
93
|
+
* (if any) is deep-merged *over* these defaults — the app always wins.
|
|
94
|
+
*/
|
|
95
|
+
protected mergeConfig(key: string, defaults: Record<string, unknown>): void;
|
|
96
|
+
/**
|
|
97
|
+
* Register a group of routes. The callback receives the `Router`; the returned
|
|
98
|
+
* `RouteGroup` is already prefixed/guarded/named per `options`, and chainable
|
|
99
|
+
* for anything more.
|
|
100
|
+
*/
|
|
101
|
+
protected routes(register: (router: Router) => void, options?: PackageRouteOptions): RouteGroup;
|
|
102
|
+
/**
|
|
103
|
+
* Serve a directory of static files (a bundled UI, images, …) under a URL
|
|
104
|
+
* prefix. Node-only. Mounts one wildcard route on the `Router`.
|
|
105
|
+
*/
|
|
106
|
+
protected assets(urlPrefix: string, dir: string, options?: PackageAssetOptions): void;
|
|
107
|
+
/** Contribute migrations, run by `keel migrate` with the app's own. */
|
|
108
|
+
protected migrations(list: Migration[]): void;
|
|
109
|
+
/** Contribute console commands, mounted on `keel` after the app boots. */
|
|
110
|
+
protected commands(list: PackageCommand[]): void;
|
|
111
|
+
/**
|
|
112
|
+
* Declare files a consuming app can copy into itself with
|
|
113
|
+
* `keel vendor:publish` (optionally `--tag <tag>`): config stubs, views, etc.
|
|
114
|
+
* Keys are source paths (usually inside the package), values app-relative
|
|
115
|
+
* destinations.
|
|
116
|
+
*/
|
|
117
|
+
protected publishes(files: Record<string, string>, tag?: string): void;
|
|
118
|
+
}
|
|
119
|
+
/** A middleware alias, re-exported so packages needn't reach into `hono`. */
|
|
120
|
+
export type PackageMiddleware = MiddlewareHandler;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package system. Keel's `ServiceProvider` is already its plugin unit —
|
|
3
|
+
* `PackageProvider` is the same thing with batteries for the jobs a *shippable*
|
|
4
|
+
* package does over and over: merge its config defaults, register its routes and
|
|
5
|
+
* bundled UI assets, contribute migrations and console commands, and declare
|
|
6
|
+
* files a consuming app can publish. Each helper is a thin wrapper over an
|
|
7
|
+
* existing Keel primitive; the value is the convention, not new machinery.
|
|
8
|
+
*
|
|
9
|
+
* export class WatchServiceProvider extends PackageProvider {
|
|
10
|
+
* readonly name = "watch";
|
|
11
|
+
* register() {
|
|
12
|
+
* this.mergeConfig("watch", defaultConfig);
|
|
13
|
+
* this.migrations([watchMigration]);
|
|
14
|
+
* this.publishes({ [stub]: "config/watch.ts" }, "watch-config");
|
|
15
|
+
* }
|
|
16
|
+
* boot() {
|
|
17
|
+
* this.assets("watch/assets", uiDir, { immutable: true });
|
|
18
|
+
* this.routes((r) => registerWatchRoutes(r), { prefix: "watch", as: "watch" });
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* A note on lifecycle: register/boot run *before* the app's HTTP kernel is bound
|
|
23
|
+
* (see `bootstrap/app.ts`), so a package must not reach for the kernel. That's
|
|
24
|
+
* why `routes()` and `assets()` go through the `Router` (bound in the
|
|
25
|
+
* Application constructor) — routes are compiled onto the kernel later, at build.
|
|
26
|
+
*/
|
|
27
|
+
import { getMimeType } from "hono/utils/mime";
|
|
28
|
+
import { ServiceProvider } from "./provider.js";
|
|
29
|
+
import { NotFoundException } from "./exceptions.js";
|
|
30
|
+
/* ------------------------------- registries ------------------------------- */
|
|
31
|
+
/**
|
|
32
|
+
* Every migration a package contributes, gathered in one place so `keel migrate`
|
|
33
|
+
* can run them alongside the app's own. Bound as a singleton on the Application.
|
|
34
|
+
*/
|
|
35
|
+
export class MigrationRegistry {
|
|
36
|
+
list = [];
|
|
37
|
+
add(migrations) {
|
|
38
|
+
this.list.push(...migrations);
|
|
39
|
+
}
|
|
40
|
+
all() {
|
|
41
|
+
return [...this.list];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Package-contributed console commands, mounted by the CLI after boot. */
|
|
45
|
+
export class CommandRegistry {
|
|
46
|
+
list = [];
|
|
47
|
+
add(commands) {
|
|
48
|
+
this.list.push(...commands);
|
|
49
|
+
}
|
|
50
|
+
all() {
|
|
51
|
+
return [...this.list];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** What `keel vendor:publish` copies into a consuming app. */
|
|
55
|
+
export class PublishRegistry {
|
|
56
|
+
entries = [];
|
|
57
|
+
add(entry) {
|
|
58
|
+
this.entries.push(entry);
|
|
59
|
+
}
|
|
60
|
+
all(tag) {
|
|
61
|
+
return tag ? this.entries.filter((e) => e.tag === tag) : [...this.entries];
|
|
62
|
+
}
|
|
63
|
+
tags() {
|
|
64
|
+
return [...new Set(this.entries.map((e) => e.tag).filter((t) => !!t))];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/* --------------------------------- helpers -------------------------------- */
|
|
68
|
+
/** Deep-merge two plain objects; `override` wins, arrays are replaced wholesale. */
|
|
69
|
+
function deepMerge(base, override) {
|
|
70
|
+
const out = { ...base };
|
|
71
|
+
for (const [key, value] of Object.entries(override)) {
|
|
72
|
+
const existing = out[key];
|
|
73
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
74
|
+
out[key] = deepMerge(existing, value);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
out[key] = value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function isPlainObject(value) {
|
|
83
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A route handler that serves files from `dir` for requests under `prefix`. Used
|
|
87
|
+
* by `PackageProvider.assets()` so a package can ship a built UI without the
|
|
88
|
+
* consuming app copying anything into `public/`. Node-only (uses `node:fs`);
|
|
89
|
+
* a miss becomes a 404 so it doesn't shadow real routes.
|
|
90
|
+
*/
|
|
91
|
+
function packageAssetHandler(prefix, dir, options) {
|
|
92
|
+
const root = dir.replace(/\/+$/, "");
|
|
93
|
+
return async (c) => {
|
|
94
|
+
const pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
95
|
+
const rel = pathname.slice(prefix.length).replace(/^\/+/, "");
|
|
96
|
+
if (rel.includes(".."))
|
|
97
|
+
throw new NotFoundException();
|
|
98
|
+
const { stat, readFile } = await import("node:fs/promises");
|
|
99
|
+
const filePath = `${root}/${rel}`;
|
|
100
|
+
const stats = await stat(filePath).catch(() => null);
|
|
101
|
+
if (!stats?.isFile())
|
|
102
|
+
throw new NotFoundException();
|
|
103
|
+
const headers = {
|
|
104
|
+
"Content-Type": getMimeType(filePath) ?? "application/octet-stream",
|
|
105
|
+
};
|
|
106
|
+
if (options.maxAge != null) {
|
|
107
|
+
headers["Cache-Control"] =
|
|
108
|
+
`public, max-age=${options.maxAge}${options.immutable ? ", immutable" : ""}`;
|
|
109
|
+
}
|
|
110
|
+
return new Response(await readFile(filePath), { headers });
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/* ----------------------------- PackageProvider ---------------------------- */
|
|
114
|
+
export class PackageProvider extends ServiceProvider {
|
|
115
|
+
constructor(app, options) {
|
|
116
|
+
super(app, options);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Set config defaults under `key` without clobbering what the app already
|
|
120
|
+
* configured. Config files load before providers, so the app's `config/<key>.ts`
|
|
121
|
+
* (if any) is deep-merged *over* these defaults — the app always wins.
|
|
122
|
+
*/
|
|
123
|
+
mergeConfig(key, defaults) {
|
|
124
|
+
const cfg = this.app.config();
|
|
125
|
+
const existing = cfg.get(key);
|
|
126
|
+
cfg.set(key, isPlainObject(existing) ? deepMerge(defaults, existing) : defaults);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Register a group of routes. The callback receives the `Router`; the returned
|
|
130
|
+
* `RouteGroup` is already prefixed/guarded/named per `options`, and chainable
|
|
131
|
+
* for anything more.
|
|
132
|
+
*/
|
|
133
|
+
routes(register, options = {}) {
|
|
134
|
+
const router = this.app.router();
|
|
135
|
+
const group = router.group(() => register(router));
|
|
136
|
+
if (options.prefix)
|
|
137
|
+
group.prefix(options.prefix);
|
|
138
|
+
if (options.middleware)
|
|
139
|
+
group.middleware(options.middleware);
|
|
140
|
+
if (options.as)
|
|
141
|
+
group.as(options.as);
|
|
142
|
+
return group;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Serve a directory of static files (a bundled UI, images, …) under a URL
|
|
146
|
+
* prefix. Node-only. Mounts one wildcard route on the `Router`.
|
|
147
|
+
*/
|
|
148
|
+
assets(urlPrefix, dir, options = {}) {
|
|
149
|
+
const prefix = "/" + urlPrefix.replace(/^\/|\/$/g, "");
|
|
150
|
+
this.app.router().get(`${prefix}/*`, packageAssetHandler(prefix, dir, options));
|
|
151
|
+
}
|
|
152
|
+
/** Contribute migrations, run by `keel migrate` with the app's own. */
|
|
153
|
+
migrations(list) {
|
|
154
|
+
this.app.make(MigrationRegistry).add(list);
|
|
155
|
+
}
|
|
156
|
+
/** Contribute console commands, mounted on `keel` after the app boots. */
|
|
157
|
+
commands(list) {
|
|
158
|
+
this.app.make(CommandRegistry).add(list);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Declare files a consuming app can copy into itself with
|
|
162
|
+
* `keel vendor:publish` (optionally `--tag <tag>`): config stubs, views, etc.
|
|
163
|
+
* Keys are source paths (usually inside the package), values app-relative
|
|
164
|
+
* destinations.
|
|
165
|
+
*/
|
|
166
|
+
publishes(files, tag) {
|
|
167
|
+
this.app.make(PublishRegistry).add({ package: this.name, ...(tag ? { tag } : {}), files });
|
|
168
|
+
}
|
|
169
|
+
}
|