@lunora/cli 1.0.0-alpha.45 → 1.0.0-alpha.451
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +33 -0
- package/README.md +3 -1
- package/dist/bin.mjs +3 -10
- package/dist/cli.d.mts +31 -0
- package/dist/cli.d.ts +31 -0
- package/dist/cli.mjs +1 -0
- package/dist/index.d.mts +1096 -386
- package/dist/index.d.ts +1096 -386
- package/dist/index.mjs +1 -19
- package/dist/packem_chunks/cli.mjs +2 -0
- package/dist/packem_chunks/dispatch.mjs +1 -0
- package/dist/packem_chunks/handler.mjs +17 -158
- package/dist/packem_chunks/handler10.mjs +1 -16
- package/dist/packem_chunks/handler11.mjs +1 -22
- package/dist/packem_chunks/handler12.mjs +1 -192
- package/dist/packem_chunks/handler13.mjs +1 -131
- package/dist/packem_chunks/handler14.mjs +3 -65
- package/dist/packem_chunks/handler15.mjs +1 -58
- package/dist/packem_chunks/handler16.mjs +1 -80
- package/dist/packem_chunks/handler17.mjs +1 -43
- package/dist/packem_chunks/handler18.mjs +1 -105
- package/dist/packem_chunks/handler19.mjs +7 -170
- package/dist/packem_chunks/handler2.mjs +1 -114
- package/dist/packem_chunks/handler20.mjs +2 -94
- package/dist/packem_chunks/handler21.mjs +1 -94
- package/dist/packem_chunks/handler22.mjs +3 -0
- package/dist/packem_chunks/handler23.mjs +3 -0
- package/dist/packem_chunks/handler24.mjs +1 -0
- package/dist/packem_chunks/handler25.mjs +2 -0
- package/dist/packem_chunks/handler26.mjs +1 -0
- package/dist/packem_chunks/handler27.mjs +99 -0
- package/dist/packem_chunks/handler28.mjs +15 -0
- package/dist/packem_chunks/handler29.mjs +1 -0
- package/dist/packem_chunks/handler3.mjs +1 -204
- package/dist/packem_chunks/handler4.mjs +1 -33
- package/dist/packem_chunks/handler5.mjs +3 -49
- package/dist/packem_chunks/handler6.mjs +1 -91
- package/dist/packem_chunks/handler7.mjs +1 -42
- package/dist/packem_chunks/handler8.mjs +1 -174
- package/dist/packem_chunks/handler9.mjs +2 -315
- package/dist/packem_chunks/runCodegenCommand.mjs +4 -52
- package/dist/packem_chunks/runDevCommand.mjs +6 -0
- package/dist/packem_chunks/runInitCommand.mjs +212 -1432
- package/dist/packem_chunks/runResetCommand.mjs +1 -41
- package/dist/packem_chunks/runRpcCommand.mjs +1 -68
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-5xxKz-zB.mjs +1 -0
- package/dist/packem_shared/EXIT_CODE-DVjUgIaG.mjs +1 -0
- package/dist/packem_shared/admin-token-VdUnvnKW.mjs +1 -0
- package/dist/packem_shared/admin-url-DJB8P6Jh.mjs +1 -0
- package/dist/packem_shared/api-spec-lYyq4vL6.mjs +1 -0
- package/dist/packem_shared/binding-manifest-file-CQ1eQYy1.mjs +2 -0
- package/dist/packem_shared/buildRegistryIndex-Dc8D8AR6.mjs +1 -0
- package/dist/packem_shared/cap-error-body-YBKO32BF.mjs +1 -0
- package/dist/packem_shared/catalog-CahzmDLV.mjs +1 -0
- package/dist/packem_shared/cli-manifest-DoQ_3bzs.mjs +1 -0
- package/dist/packem_shared/cloudflare-credentials-BVmZnDZk.mjs +1 -0
- package/dist/packem_shared/codegen-error-AmH54ofi.mjs +3 -0
- package/dist/packem_shared/command-B6vu47iR.mjs +1 -0
- package/dist/packem_shared/commands-DC3ZX1yk.mjs +13 -0
- package/dist/packem_shared/createLogger-4TujevyB.mjs +2 -0
- package/dist/packem_shared/createRecordingSpawner-01ZngIwK.mjs +1 -0
- package/dist/packem_shared/deploy-target-resolve-BTCW1Iv4.mjs +1 -0
- package/dist/packem_shared/diffSnapshots-htqNZT8Q.mjs +7 -0
- package/dist/packem_shared/docker-DpVxvYpL.mjs +1 -0
- package/dist/packem_shared/free-port-DjbFhaaT.mjs +1 -0
- package/dist/packem_shared/health-probe-Bw9JFfIy.mjs +1 -0
- package/dist/packem_shared/import-DoAUdyfU.mjs +13 -0
- package/dist/packem_shared/index-oHZBGBwB.mjs +1 -0
- package/dist/packem_shared/insertSchemaExtension-CbTH3lCQ.mjs +10 -0
- package/dist/packem_shared/lint-ignore-report-DKZagpqk.mjs +2 -0
- package/dist/packem_shared/mcp-config-file-GR1Y_CVw.mjs +2 -0
- package/dist/packem_shared/open-url-EnKy--w-.mjs +1 -0
- package/dist/packem_shared/output-format-DWe_tkTR.mjs +2 -0
- package/dist/packem_shared/parseManifest-CwPTKdtS.mjs +1 -0
- package/dist/packem_shared/path-containment-CgxYZggb.mjs +1 -0
- package/dist/packem_shared/plan-BH4ZfiFs.mjs +5 -0
- package/dist/packem_shared/planDevCommand-I8-NB_CN.mjs +1 -0
- package/dist/packem_shared/platform-diagnostics-Cn2g6Jh-.mjs +4 -0
- package/dist/packem_shared/prompt-cancelled-BvsNxg_Q.mjs +1 -0
- package/dist/packem_shared/render-lunora-error-DUo0pA61.mjs +2 -0
- package/dist/packem_shared/resolve-Bdc9KEUv.mjs +5 -0
- package/dist/packem_shared/resolve-target-C_ZloTvd.mjs +1 -0
- package/dist/packem_shared/runAddCommand-CTblwN9O.mjs +1 -0
- package/dist/packem_shared/runDeployCommand-C5L6ZhGV.mjs +1 -0
- package/dist/packem_shared/runExportCommand-DiMCQ-Il.mjs +5 -0
- package/dist/packem_shared/runMigrateGenerateCommand-Bd2W9t1k.mjs +11 -0
- package/dist/packem_shared/schemaIrToSnapshot-BHuNOfgs.mjs +1 -0
- package/dist/packem_shared/shared-D-zCOmgY.mjs +1 -0
- package/dist/packem_shared/storage-BLLoZRJa.mjs +1 -0
- package/dist/packem_shared/tui-prompts-B3YwUhGw.mjs +4 -0
- package/dist/packem_shared/variables-CPYyJtsL.mjs +1 -0
- package/dist/packem_shared/vectorize-metadata-DOB1akh6.mjs +2 -0
- package/dist/packem_shared/wrangler-name-Dsk5K1f-.mjs +1 -0
- package/dist/packem_shared/wrangler-secrets-DlSlWvRH.mjs +1 -0
- package/package.json +43 -19
- package/skills/README.md +36 -17
- package/skills/lunora/SKILL.md +158 -65
- package/skills/lunora-cloudflare-audit/SKILL.md +163 -0
- package/skills/lunora-create-package/SKILL.md +97 -84
- package/skills/lunora-deploy/SKILL.md +175 -105
- package/skills/lunora-functions/SKILL.md +367 -60
- package/skills/lunora-migration-helper/SKILL.md +168 -137
- package/skills/lunora-performance-audit/SKILL.md +137 -95
- package/skills/lunora-quickstart/SKILL.md +136 -136
- package/skills/lunora-realtime/SKILL.md +217 -97
- package/skills/lunora-setup-auth/SKILL.md +203 -101
- package/skills/lunora-setup-hyperdrive/SKILL.md +138 -109
- package/skills/lunora-setup-hyperdrive-global/SKILL.md +125 -92
- package/skills/lunora-setup-mail/SKILL.md +85 -113
- package/skills/lunora-setup-scheduler/SKILL.md +132 -96
- package/skills/lunora-setup-storage/SKILL.md +189 -95
- package/dist/packem_chunks/planDevCommand.mjs +0 -541
- package/dist/packem_chunks/runDeployCommand.mjs +0 -594
- package/dist/packem_chunks/runMigrateGenerateCommand.mjs +0 -397
- package/dist/packem_shared/COMMANDS-B0ftFD_3.mjs +0 -948
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +0 -244
- package/dist/packem_shared/admin-url-4UzT-CI4.mjs +0 -19
- package/dist/packem_shared/api-spec-CtA6ilu4.mjs +0 -13
- package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +0 -38
- package/dist/packem_shared/codegen-error-DJG-ghs_.mjs +0 -31
- package/dist/packem_shared/command-D3lB_4Az.mjs +0 -19
- package/dist/packem_shared/commands-B-gR09Z_.mjs +0 -845
- package/dist/packem_shared/createLogger-B40gPzQo.mjs +0 -78
- package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +0 -43
- package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +0 -61
- package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +0 -161
- package/dist/packem_shared/docker-hMQ97KSQ.mjs +0 -21
- package/dist/packem_shared/insertSchemaExtension-DAqbfr9Z.mjs +0 -64
- package/dist/packem_shared/open-url-Dfq6fAyT.mjs +0 -41
- package/dist/packem_shared/output-format-wUvAN6AL.mjs +0 -17
- package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +0 -56
- package/dist/packem_shared/parseManifest--vZf2FY1.mjs +0 -94
- package/dist/packem_shared/prompt-cancelled-APzX1Im-.mjs +0 -9
- package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +0 -16
- package/dist/packem_shared/runAddCommand-bnY6-HKb.mjs +0 -4
- package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +0 -79
- package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +0 -43
- package/dist/packem_shared/storage-BIsph-Vk.mjs +0 -84
- package/dist/packem_shared/tui-prompts-BjEN8XgP.mjs +0 -658
- package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +0 -12
- package/dist/packem_shared/wrangler-secrets-P2_ZUR-k.mjs +0 -47
|
@@ -1,151 +1,123 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lunora-setup-mail
|
|
3
|
-
description: Adds transactional email to a Lunora app
|
|
3
|
+
description: Adds transactional email to a Lunora app with the `mail` registry item (`@lunora/mail`). Covers the server-only `internal.mail.sendEmail` / `queueEmail` actions, the `SEND_EMAIL` Cloudflare Email Workers binding, Resend via `RESEND_API_KEY`, `react-email` templates via `renderEmail`, the dev mail catcher (Studio Mail tab), E2E inbox helpers, and inbound mail. Use when the user wants to send verification, password-reset, invite or notification email, runs `lunora add email` or `lunora registry add mail`, edits `lunora/mail/index.ts`, asks why mail isn't delivered or doesn't show up in the Studio, or wants to receive email.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Lunora Setup Mail
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
Studio Mail tab
|
|
8
|
+
The `mail` registry item wraps `@lunora/mail`, which validates addresses against
|
|
9
|
+
header injection before sending. It exposes two `internalAction`s, `sendEmail`
|
|
10
|
+
and `queueEmail`. Both are server-only on purpose: a mailer that lets the client
|
|
11
|
+
pick the recipient, subject and body is an open relay for phishing through your
|
|
12
|
+
verified domain. In dev, every send is captured into the Studio Mail tab and
|
|
13
|
+
nothing is delivered.
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- Sending app→user mail: invites, notifications, receipts.
|
|
17
|
-
- Delivering verification / password-reset mail from `@lunora/auth`.
|
|
18
|
-
- Using a React (`react-email`) template or a hosted provider (Resend).
|
|
19
|
-
|
|
20
|
-
## When Not to Use
|
|
21
|
-
|
|
22
|
-
- The project has no Lunora backend yet — use `lunora-quickstart` first.
|
|
23
|
-
- Mail is already installed and you just want to send — call
|
|
24
|
-
`ctx.runAction(api.mail.sendEmail, …)` or `client.action("mail/sendEmail", …)`.
|
|
25
|
-
|
|
26
|
-
## Workflow
|
|
27
|
-
|
|
28
|
-
1. Add the `mail` item.
|
|
29
|
-
2. Configure the `SEND_EMAIL` binding (or a provider) and `MAIL_FROM`.
|
|
30
|
-
3. Regenerate types with `lunora codegen`.
|
|
31
|
-
4. Send mail from a function (or the client); render a React template if needed.
|
|
15
|
+
If the project has no Lunora backend yet, start with `lunora-quickstart`.
|
|
32
16
|
|
|
33
17
|
## Step 1: Add the item
|
|
34
18
|
|
|
35
19
|
```bash
|
|
36
|
-
lunora
|
|
20
|
+
lunora add email # asks for the verified destination address
|
|
21
|
+
lunora add email --mail-to ops@example.com
|
|
22
|
+
lunora registry add mail # low-level: leaves the REPLACE_ME@example.com placeholder
|
|
37
23
|
```
|
|
38
24
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
1. Adds `@lunora/mail` and `@lunora/server` to `package.json` (run
|
|
42
|
-
`pnpm install` afterwards).
|
|
43
|
-
2. Copies `lunora/mail/index.ts` (the `sendEmail` / `queueEmail` actions) into
|
|
44
|
-
your project — it is **yours** to edit.
|
|
45
|
-
3. Adds a `send_email` binding (`SEND_EMAIL`, with a `destination_address`
|
|
46
|
-
placeholder) to `wrangler.jsonc` and scaffolds `MAIL_FROM` (the default
|
|
47
|
-
sender) into `.dev.vars`.
|
|
25
|
+
Then run `pnpm install` and `lunora codegen`. The item does the following:
|
|
48
26
|
|
|
49
|
-
|
|
27
|
+
1. Adds `@lunora/mail` and `@lunora/server`.
|
|
28
|
+
2. Copies `lunora/mail/index.ts` into the project. The project owns this file. It defines `sendEmail`, which returns `{ id }`, and `queueEmail`, which returns `{ queued: true }`.
|
|
29
|
+
3. Adds a `send_email` binding named `SEND_EMAIL` (with `destination_address`) to `wrangler.jsonc`.
|
|
30
|
+
4. Writes `MAIL_FROM` to `.dev.vars`.
|
|
50
31
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
| `MAIL_FROM` | var (`.dev.vars` / `wrangler.jsonc`) | Default sender address. |
|
|
55
|
-
| `RESEND_API_KEY` | secret (optional) | Use a hosted provider instead — pass `apiKey` to `createMailer` in `lunora/mail`. |
|
|
32
|
+
Codegen emits the two functions as `internal.mail.sendEmail` and
|
|
33
|
+
`internal.mail.queueEmail`. They are not in the client-reachable `api`. A client
|
|
34
|
+
call to them returns `FUNCTION_NOT_FOUND`.
|
|
56
35
|
|
|
57
|
-
|
|
58
|
-
[Email Routing](https://developers.cloudflare.com/email-routing/): verify a
|
|
59
|
-
destination address and replace the `REPLACE_ME@example.com` placeholder. Prefer
|
|
60
|
-
Resend? Pass `apiKey` (or a custom `transport`) to `createMailer` in your copied
|
|
61
|
-
`lunora/mail/index.ts`.
|
|
36
|
+
## Step 2: Choose the delivery path
|
|
62
37
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
verification and forgot-password mail — is intercepted and surfaced in the
|
|
66
|
-
**Studio Mail tab**. Nothing leaves your machine and no provider setup is needed.
|
|
38
|
+
The copied file builds its mailer with `createMailerFromEnv`. That function
|
|
39
|
+
picks the transport in this order:
|
|
67
40
|
|
|
68
|
-
|
|
41
|
+
| Condition | Transport |
|
|
42
|
+
| ----------------------------------------------------- | --------------------------------------------------- |
|
|
43
|
+
| `LUNORA_MAIL_CAPTURE=1`, or a dev environment (below) | **Capture** into the Studio Mail tab |
|
|
44
|
+
| `SEND_EMAIL` binding present | Cloudflare Email Workers |
|
|
45
|
+
| no binding, `RESEND_API_KEY` secret set | Resend |
|
|
46
|
+
| none of the above | throws on send (production never captures silently) |
|
|
69
47
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
The functions surface in the generated `api` as `api.mail.sendEmail` and
|
|
75
|
-
`api.mail.queueEmail`.
|
|
48
|
+
- **Cloudflare Email Workers.** The binding sends to a single recipient, and only to verified [Email Routing](https://developers.cloudflare.com/email-routing/) destinations. That fits app-to-operator mail. Verify the address and replace the placeholder.
|
|
49
|
+
- **Resend.** Use this to send to arbitrary users. Run `wrangler secret put RESEND_API_KEY` and remove the `send_email` binding. While the binding exists it takes precedence over Resend.
|
|
50
|
+
- **Capture.** Capture turns on when every env-name var (`WORKER_ENV`, `NODE_ENV`, …) looks like dev. `lunora dev` sets `WORKER_ENV=development`, so capture is on in dev. Capture writes to the root shard. If `SHARD` or `LUNORA_ADMIN_TOKEN` is missing, captured mail is discarded and a one-time warning is logged. To deliver for real from dev, set `LUNORA_MAIL_CAPTURE=0`.
|
|
76
51
|
|
|
77
|
-
|
|
52
|
+
`MAIL_FROM` is required on every path. It accepts `Name <addr@host>` or a bare
|
|
53
|
+
address.
|
|
78
54
|
|
|
79
|
-
|
|
55
|
+
## Step 3: Send from a server function
|
|
80
56
|
|
|
81
|
-
`sendEmail` is an
|
|
82
|
-
mutation, schedule it
|
|
57
|
+
`sendEmail` is an action because sending is non-transactional network I/O. From
|
|
58
|
+
a mutation, schedule it so the send runs only after the mutation commits:
|
|
83
59
|
|
|
84
60
|
```ts
|
|
85
|
-
import {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
await ctx.scheduler.runAfter(0,
|
|
92
|
-
to: email,
|
|
61
|
+
import { internal } from "#lunora/_generated/internal.js";
|
|
62
|
+
import { mutation, v } from "#lunora/_generated/server.js";
|
|
63
|
+
|
|
64
|
+
export const inviteMember = mutation.input({ teamId: v.id("teams"), email: v.string() }).mutation(async ({ ctx, args }) => {
|
|
65
|
+
// Authorize the caller and persist the invite first. The server decides
|
|
66
|
+
// the recipient and the content: never forward a client-chosen to/from/html.
|
|
67
|
+
await ctx.scheduler.runAfter(0, internal.mail.sendEmail, {
|
|
68
|
+
to: args.email,
|
|
93
69
|
subject: "You're invited",
|
|
94
70
|
html: "<p>Click the link to join.</p>",
|
|
95
71
|
});
|
|
96
72
|
});
|
|
97
73
|
```
|
|
98
74
|
|
|
99
|
-
|
|
75
|
+
Sending to an arbitrary `args.email` needs the Resend path in production: the
|
|
76
|
+
`SEND_EMAIL` binding only reaches its verified destination.
|
|
100
77
|
|
|
101
|
-
|
|
102
|
-
await client.action("mail/sendEmail", {
|
|
103
|
-
to: "alice@example.com",
|
|
104
|
-
subject: "Welcome",
|
|
105
|
-
text: "Thanks for signing up!",
|
|
106
|
-
});
|
|
107
|
-
```
|
|
78
|
+
From an action, call `await ctx.runAction(internal.mail.sendEmail, { … })`.
|
|
108
79
|
|
|
109
|
-
|
|
80
|
+
If a client needs to trigger a send, write a public action for that one purpose.
|
|
81
|
+
It should take only business inputs (for example `{ orderId }`), check
|
|
82
|
+
`ctx.auth`, derive the recipient on the server, rate-limit the call
|
|
83
|
+
(`@lunora/ratelimit`), and then call `internal.mail.sendEmail`.
|
|
110
84
|
|
|
111
|
-
React
|
|
112
|
-
`sendEmail` args take `html` / `text`. To use a `react-email` template, render it
|
|
113
|
-
where you call the mailer — edit `lunora/mail/index.ts` to pass `react` straight
|
|
114
|
-
into `mailer.send`:
|
|
85
|
+
### React templates
|
|
115
86
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
import { env } from "cloudflare:workers";
|
|
87
|
+
A React element can't be serialized across the RPC boundary, so render it to
|
|
88
|
+
`html` and `text` first. Rendering this way keeps dev capture working:
|
|
119
89
|
|
|
120
|
-
|
|
90
|
+
```tsx
|
|
91
|
+
import { renderEmail } from "@lunora/mail"; // needs `react` installed
|
|
121
92
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
});
|
|
93
|
+
import { WelcomeEmail } from "./emails/welcome";
|
|
94
|
+
|
|
95
|
+
const { html, text } = await renderEmail(<WelcomeEmail name={name} />);
|
|
96
|
+
await ctx.runAction(internal.mail.sendEmail, { to, subject: "Welcome", html, text });
|
|
127
97
|
```
|
|
128
98
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
99
|
+
For the auth flows, `lunora add auth-emails` adds ready-made templates.
|
|
100
|
+
|
|
101
|
+
### Queueing (optional)
|
|
102
|
+
|
|
103
|
+
`queueEmail` requires a Cloudflare Queue producer binding passed as
|
|
104
|
+
`createMailerFromEnv(env, { queue })` in `mailer()`, and the item doesn't add one. Without it, the call
|
|
105
|
+
throws `` `queue` binding is required for mailer.queue() ``, except under
|
|
106
|
+
capture, where it sends straight to the capture inbox so dev flows keep working.
|
|
107
|
+
A consumer builds its mailer with `createMailerFromEnv` too, drains the queue
|
|
108
|
+
with `consumeQueuedSend(mailer, message.body)` and then calls `message.ack()`
|
|
109
|
+
for each message. With a queue bound in dev, the message still goes through the
|
|
110
|
+
queue and the consumer's mailer captures it into the inbox. Queues deliver at least once: with
|
|
111
|
+
per-message acks a later throw retries only the unacknowledged messages, while
|
|
112
|
+
without them a single throw resends the whole batch. The full wiring is in the "Queueing"
|
|
113
|
+
section of the item README.
|
|
114
|
+
|
|
115
|
+
## Testing and inbound
|
|
116
|
+
|
|
117
|
+
- **E2E.** `@lunora/mail/testing` exports `waitForMail({ baseUrl, adminToken, to, subjectMatch })` and `extractLink(mail, { match })`. Together they let you read the captured inbox and pull out a verification or reset link.
|
|
118
|
+
- **Receiving mail.** `@lunora/mail/inbound` provides `createInboundEmailHandler`, `parseInboundEmail` and `dispatchToLunoraFunction`. They route Email Routing messages into a Lunora function. Mount them with the generated builder's `.onEmail(...)`.
|
|
119
|
+
|
|
120
|
+
## Verify
|
|
121
|
+
|
|
122
|
+
1. Run `lunora dev` and trigger a send. With `SHARD` and `LUNORA_ADMIN_TOKEN` set, the message should appear in the Studio Mail tab; if either is missing, capture discards it and logs a warning.
|
|
123
|
+
2. Before deploying, run `lunora doctor`. It flags the placeholder destination. Confirm that `MAIL_FROM` is set, and that either the binding uses a verified destination or `RESEND_API_KEY` is set with the binding removed.
|
|
@@ -1,157 +1,193 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lunora-setup-scheduler
|
|
3
|
-
description: Schedules deferred and recurring work in a Lunora app.
|
|
3
|
+
description: Schedules deferred and recurring work in a Lunora app. Covers `ctx.scheduler.runAfter` / `runAt` (delayed dispatch of a function or workflow), cron jobs in `lunora/crons.ts` via `lunora registry add crons` and the `cronJobs()` builder, the `SchedulerDO` / `SCHEDULER` binding and `LUNORA_ORIGIN_URL`, retries and dead-lettering, and `createWorkpool` for bounded concurrency. Use when the user wants to "run this later", "send a reminder in 3 days", "run every night / every 5 minutes", add a cron, cancel a scheduled job, or sees `ORIGIN_NOT_CONFIGURED` or a missing `SchedulerDO` export.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Lunora Setup Scheduler
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
from `@lunora/server`):
|
|
8
|
+
Lunora schedules work in two ways, both implemented in `@lunora/scheduler`:
|
|
10
9
|
|
|
11
|
-
- **Deferred dispatch
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
- **Deferred dispatch**: `ctx.scheduler.runAfter` / `runAt` run a function (or
|
|
11
|
+
start a workflow) later. Jobs are stored in the `SchedulerDO` Durable Object
|
|
12
|
+
(binding `SCHEDULER`), which owns the alarm, the retries and the dead-letter list.
|
|
13
|
+
- **Recurring jobs (crons)**: declared in `lunora/crons.ts` with `cronJobs()`.
|
|
14
|
+
Codegen compiles them into Cloudflare Cron Triggers, and the Worker's
|
|
15
|
+
`scheduled()` handler dispatches them directly. They don't go through the
|
|
16
|
+
`SchedulerDO`.
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
which owns the alarm and durable storage.
|
|
18
|
+
## When Not to Use
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
- The project has no Lunora backend yet: use `lunora-quickstart` first.
|
|
21
|
+
- You only need to react to data changes: use a reactive `query` /
|
|
22
|
+
subscription (`lunora-realtime`).
|
|
23
|
+
- Multi-step orchestration with sleeps and waits belongs in a durable workflow.
|
|
24
|
+
A scheduler or cron can start one, but don't build step logic on top of jobs.
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
(`runAfter` / `runAt`).
|
|
23
|
-
- Declaring recurring jobs (sweep, digest, report) on a cron schedule.
|
|
24
|
-
- Bounding concurrency for many enqueued jobs (a workpool).
|
|
26
|
+
## Deferred dispatch: `runAfter` / `runAt`
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
### Wire it once
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
```bash
|
|
31
|
+
pnpm add @lunora/scheduler
|
|
32
|
+
lunora codegen
|
|
33
|
+
```
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
Codegen needs `@lunora/scheduler` installed as soon as `ctx.scheduler` is used,
|
|
36
|
+
because the generated `_generated/app.ts` imports `createScheduler`. Once the
|
|
37
|
+
dependency is present, codegen emits `_generated/scheduler.ts` (re-exporting
|
|
38
|
+
`SchedulerDO`) and a `.scheduler()` method on the app builder.
|
|
33
39
|
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
- **Vite-first apps** (generated worker entry): the plugin forwards
|
|
41
|
+
`SchedulerDO` and calls `.scheduler()` for you.
|
|
42
|
+
- **Hand-written worker entry** (`src/server.ts` with `defineApp`): wire it yourself.
|
|
43
|
+
`wrangler.jsonc`'s `SCHEDULER` binding is reconciled from this export:
|
|
36
44
|
|
|
37
45
|
```ts
|
|
38
|
-
|
|
46
|
+
const app = defineApp<Env>()
|
|
47
|
+
.shard((env) => env.SHARD)
|
|
48
|
+
.scheduler({ namespace: (env) => env.SCHEDULER })
|
|
49
|
+
.build();
|
|
50
|
+
|
|
51
|
+
export const ShardDO = app.ShardDO;
|
|
52
|
+
export { SchedulerDO } from "../lunora/_generated/scheduler.js";
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The `SchedulerDO` calls back into the Worker at `LUNORA_ORIGIN_URL`. It reads
|
|
56
|
+
that value from its own env and never from the request, so a caller can't point
|
|
57
|
+
dispatch at an arbitrary URL. Without it, every `runAfter`/`runAt` fails with
|
|
58
|
+
`ORIGIN_NOT_CONFIGURED`, and nothing provisions it for you:
|
|
39
59
|
|
|
40
|
-
|
|
60
|
+
- dev: `LUNORA_ORIGIN_URL="http://localhost:5173"` in `.dev.vars` (your dev
|
|
61
|
+
server's URL)
|
|
62
|
+
- prod: `vars.LUNORA_ORIGIN_URL` in `wrangler.jsonc`, or
|
|
63
|
+
`wrangler secret put LUNORA_ORIGIN_URL` (per `--env`)
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
// run an internal action 14 days from now
|
|
44
|
-
const { id } = await ctx.scheduler.runAfter(14 * 24 * 60 * 60 * 1000, internal.billing.endTrial, { userId });
|
|
65
|
+
`lunora doctor` warns when the scheduler binding exists and the variable is unset.
|
|
45
66
|
|
|
46
|
-
|
|
67
|
+
### Schedule from a mutation or action
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { LunoraError } from "lunorash/server";
|
|
71
|
+
|
|
72
|
+
import { internal } from "#lunora/_generated/internal.js";
|
|
73
|
+
import { mutation } from "#lunora/_generated/server.js";
|
|
74
|
+
|
|
75
|
+
export const startTrial = mutation.mutation(async ({ ctx }) => {
|
|
76
|
+
// the caller's own id, never one the client passes in
|
|
77
|
+
const { userId } = ctx.auth;
|
|
78
|
+
if (!userId) {
|
|
79
|
+
throw new LunoraError("UNAUTHORIZED", "not signed in");
|
|
80
|
+
}
|
|
81
|
+
// run an internal action 14 days from now; the id is a plain string
|
|
82
|
+
const jobId = await ctx.scheduler.runAfter(14 * 24 * 60 * 60 * 1000, internal.billing.endTrial, { userId });
|
|
83
|
+
|
|
84
|
+
return { jobId };
|
|
47
85
|
});
|
|
48
86
|
```
|
|
49
87
|
|
|
50
|
-
- `runAfter(delayMs,
|
|
51
|
-
|
|
52
|
-
a `
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
- The full `ctx.scheduler` surface is `runAfter(delayMs, target, args?)`,
|
|
89
|
+
`runAt(timestampMs, target, args?)` (epoch ms), `cancel(id)`, `get(id)`, and `list()`.
|
|
90
|
+
It is not available in a `query`, because queries are deterministic and re-run.
|
|
91
|
+
- `target` is a generated `internal.*` / `api.*` mutation or action reference, a
|
|
92
|
+
`"file:fn"` string, or a `workflows.<name>` / `agents.<name>` reference.
|
|
93
|
+
A workflow or agent reference starts a fresh instance when the job fires, with
|
|
94
|
+
`args` as its params. Scheduled jobs run with no end-user identity, so target
|
|
95
|
+
`internal.*` functions.
|
|
96
|
+
- **Inside a mutation, scheduling is transactional.** The call is buffered and
|
|
97
|
+
dispatched only after the mutation commits. If the mutation rolls back (OCC
|
|
98
|
+
conflict, validation error), the job is dropped too. The returned id is final
|
|
99
|
+
immediately, so you can store it on the row you're writing and `cancel` it later.
|
|
100
|
+
- Retries use the DO defaults: 5 retries, exponential backoff from 30 s. A job
|
|
101
|
+
that exhausts them is dead-lettered (visible in the studio), not dropped. To set
|
|
102
|
+
a per-job `retry` policy (`{ maxAttempts, backoff: "exponential" | "linear",
|
|
103
|
+
baseMs, maxMs }`) or a `shardKey`, build your own client with
|
|
104
|
+
`createScheduler({ namespace })` from `@lunora/scheduler`. Its `runAfter`/`runAt`
|
|
105
|
+
take a fourth `RunOptions` argument and add `dead()` / `deadRetry(id)`.
|
|
106
|
+
|
|
107
|
+
## Recurring jobs: crons
|
|
65
108
|
|
|
66
109
|
### Step 1: Add the starter
|
|
67
110
|
|
|
68
111
|
```bash
|
|
69
112
|
lunora registry add crons
|
|
113
|
+
pnpm install
|
|
70
114
|
```
|
|
71
115
|
|
|
72
|
-
This adds `@lunora/server` to `package.json`
|
|
73
|
-
`lunora/crons.ts` (a `cronJobs()` registry
|
|
74
|
-
`lunora/crons/jobs.ts` (
|
|
75
|
-
project — both **yours** to edit. No extra DO binding is required.
|
|
116
|
+
This adds `@lunora/server` + `@lunora/scheduler` to `package.json` and copies two
|
|
117
|
+
files you own: `lunora/crons.ts` (a `cronJobs()` registry) and
|
|
118
|
+
`lunora/crons/jobs.ts` (an example `internalMutation`, `internal.crons.jobs.run`).
|
|
76
119
|
|
|
77
120
|
### Step 2: Declare jobs
|
|
78
121
|
|
|
79
122
|
```ts
|
|
80
123
|
import { cronJobs } from "@lunora/server";
|
|
81
124
|
|
|
82
|
-
import { internal } from "
|
|
125
|
+
import { internal } from "#lunora/_generated/internal.js";
|
|
83
126
|
|
|
84
127
|
const crons = cronJobs();
|
|
85
128
|
|
|
86
129
|
crons.interval("sweep presence", { minutes: 5 }, internal.presence.sweep, { roomId: "lobby" });
|
|
87
|
-
crons.
|
|
88
|
-
crons.
|
|
89
|
-
crons.
|
|
90
|
-
crons.
|
|
130
|
+
crons.hourly("rollup", { minuteUTC: 17 }, internal.stats.rollup);
|
|
131
|
+
crons.daily("digest", { hourUTC: 9, minuteUTC: 0 }, internal.email.digest);
|
|
132
|
+
crons.weekly("report", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.reports.weekly);
|
|
133
|
+
crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice);
|
|
134
|
+
crons.cron("custom", "0 */6 * * *", internal.foo.bar); // raw cron escape hatch
|
|
91
135
|
|
|
92
136
|
export default crons;
|
|
93
137
|
```
|
|
94
138
|
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
139
|
+
- Names must be non-empty string literals, unique across the project.
|
|
140
|
+
- Targets must be static property accesses (`internal.<…path>.<fn>`, one segment
|
|
141
|
+
per folder and file, e.g. `internal.crons.jobs.run`) or a `workflows.<name>` /
|
|
142
|
+
`agents.<name>` reference. Codegen resolves them from the AST, so a computed
|
|
143
|
+
reference fails codegen. `args` is optional and must be a literal.
|
|
144
|
+
- Schedules are UTC and validated when the job is defined. `interval` takes exactly one of
|
|
145
|
+
`{ minutes }` (must divide 60) or `{ hours }` (must divide 24). `{ seconds }`
|
|
146
|
+
is rejected because Cron Triggers have a one-minute floor. For sub-minute work,
|
|
147
|
+
use `runAfter`.
|
|
148
|
+
- Use `hourly` with an offset minute to spread jobs away from `:00`.
|
|
101
149
|
|
|
102
|
-
### Step 3: Regenerate
|
|
150
|
+
### Step 3: Regenerate
|
|
103
151
|
|
|
104
152
|
```bash
|
|
105
153
|
lunora codegen
|
|
106
154
|
```
|
|
107
155
|
|
|
108
|
-
Codegen
|
|
109
|
-
|
|
110
|
-
`
|
|
111
|
-
|
|
156
|
+
Codegen emits `lunora/_generated/crons.ts` (`LUNORA_CRON_TRIGGERS` plus the
|
|
157
|
+
`LUNORA_CRONS` dispatcher map, which `defineApp` wires into `scheduled()`) and
|
|
158
|
+
syncs `triggers.crons` in `wrangler.jsonc`. Don't hand-edit that array.
|
|
159
|
+
|
|
160
|
+
**Verify:** the studio's scheduled-jobs view lists each cron and has a "Run now"
|
|
161
|
+
action, which calls the same dispatch path as a real tick.
|
|
112
162
|
|
|
113
|
-
## Bounded concurrency
|
|
163
|
+
## Bounded concurrency: workpool (optional)
|
|
114
164
|
|
|
115
|
-
|
|
116
|
-
|
|
165
|
+
A workpool is a named pool inside the same `SchedulerDO`. It needs no extra
|
|
166
|
+
binding. Build it in Worker code that has the raw `env`:
|
|
117
167
|
|
|
118
168
|
```ts
|
|
119
169
|
import { createWorkpool } from "@lunora/scheduler";
|
|
120
170
|
|
|
121
|
-
const pool = createWorkpool({
|
|
122
|
-
namespace: env.SCHEDULER,
|
|
123
|
-
originUrl: "https://my-app.example.com",
|
|
124
|
-
name: "imports",
|
|
125
|
-
maxConcurrency: 3,
|
|
126
|
-
});
|
|
171
|
+
const pool = createWorkpool({ namespace: env.SCHEDULER, name: "imports", maxConcurrency: 3 });
|
|
127
172
|
|
|
128
|
-
await pool.enqueue(internal.imports.processRow, { rowId });
|
|
173
|
+
await pool.enqueue(internal.imports.processRow, { rowId }, { retry: { maxAttempts: 3 } });
|
|
174
|
+
// pool.cancel(id), pool.status() → { inFlight, queued, maxConcurrency }
|
|
129
175
|
```
|
|
130
176
|
|
|
131
|
-
The DO
|
|
132
|
-
durably.
|
|
133
|
-
|
|
134
|
-
|
|
177
|
+
The DO dispatches at most `maxConcurrency` jobs at once and queues the rest
|
|
178
|
+
durably. One alarm drain keeps at most six dispatches in flight (the DO connection limit),
|
|
179
|
+
so a long-running job holds a slot for its whole duration. For individually long jobs
|
|
180
|
+
(LLM calls, exports), prefer `createQueueWorkpool`, which is backed by Cloudflare Queues.
|
|
181
|
+
You give up the hard cap, per-job cancel and per-job status in exchange.
|
|
135
182
|
|
|
136
183
|
## Common Pitfalls
|
|
137
184
|
|
|
138
|
-
1.
|
|
139
|
-
|
|
140
|
-
2.
|
|
141
|
-
|
|
142
|
-
3. **
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
## Checklist
|
|
148
|
-
|
|
149
|
-
- [ ] Deferred work uses `ctx.scheduler.runAfter` / `runAt` against
|
|
150
|
-
`internal.*` functions.
|
|
151
|
-
- [ ] `SCHEDULER` (SchedulerDO) binding present in `wrangler.jsonc`
|
|
152
|
-
(`lunora doctor` clean) when using the scheduler.
|
|
153
|
-
- [ ] Recurring jobs declared in `lunora/crons.ts` via `cronJobs()` (after
|
|
154
|
-
`lunora registry add crons`).
|
|
155
|
-
- [ ] `lunora codegen` run so `_generated/crons.ts` + `triggers.crons` are
|
|
156
|
-
synced.
|
|
157
|
-
- [ ] Job handlers are idempotent.
|
|
185
|
+
1. **`ORIGIN_NOT_CONFIGURED`**: `LUNORA_ORIGIN_URL` is unset for the `SchedulerDO`
|
|
186
|
+
(see above).
|
|
187
|
+
2. **`SchedulerDO` not exported** from a hand-written worker entry: wrangler
|
|
188
|
+
only binds classes the entry exports, so `SCHEDULER` is never provisioned.
|
|
189
|
+
3. **Dynamic `name` or target in `cronJobs()`**: codegen cannot discover it.
|
|
190
|
+
4. **Editing crons without re-running `lunora codegen`**: `triggers.crons` and the
|
|
191
|
+
dispatcher map are generated output and go stale.
|
|
192
|
+
5. **Non-idempotent handlers**: a failed job is retried, and a slow cron tick
|
|
193
|
+
can overlap the next one.
|