@lunora/cli 0.0.0 → 1.0.0-alpha.10
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 +105 -0
- package/README.md +109 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/bin.mjs +11 -0
- package/dist/index.d.mts +956 -0
- package/dist/index.d.ts +956 -0
- package/dist/index.mjs +19 -0
- package/dist/packem_chunks/handler.mjs +150 -0
- package/dist/packem_chunks/handler10.mjs +22 -0
- package/dist/packem_chunks/handler11.mjs +192 -0
- package/dist/packem_chunks/handler12.mjs +131 -0
- package/dist/packem_chunks/handler13.mjs +65 -0
- package/dist/packem_chunks/handler14.mjs +58 -0
- package/dist/packem_chunks/handler15.mjs +79 -0
- package/dist/packem_chunks/handler16.mjs +43 -0
- package/dist/packem_chunks/handler17.mjs +105 -0
- package/dist/packem_chunks/handler18.mjs +170 -0
- package/dist/packem_chunks/handler19.mjs +89 -0
- package/dist/packem_chunks/handler2.mjs +114 -0
- package/dist/packem_chunks/handler20.mjs +94 -0
- package/dist/packem_chunks/handler21.mjs +311 -0
- package/dist/packem_chunks/handler3.mjs +204 -0
- package/dist/packem_chunks/handler4.mjs +33 -0
- package/dist/packem_chunks/handler5.mjs +49 -0
- package/dist/packem_chunks/handler6.mjs +91 -0
- package/dist/packem_chunks/handler7.mjs +42 -0
- package/dist/packem_chunks/handler8.mjs +174 -0
- package/dist/packem_chunks/handler9.mjs +16 -0
- package/dist/packem_chunks/planDevCommand.mjs +500 -0
- package/dist/packem_chunks/runCodegenCommand.mjs +52 -0
- package/dist/packem_chunks/runDeployCommand.mjs +504 -0
- package/dist/packem_chunks/runInitCommand.mjs +1498 -0
- package/dist/packem_chunks/runMigrateGenerateCommand.mjs +397 -0
- package/dist/packem_chunks/runResetCommand.mjs +41 -0
- package/dist/packem_chunks/runRpcCommand.mjs +68 -0
- package/dist/packem_shared/COMMANDS-D3h9Iwvl.mjs +944 -0
- package/dist/packem_shared/DEFAULT_IMPORT_BATCH_SIZE-Ck-2bU08.mjs +244 -0
- package/dist/packem_shared/admin-url-4UzT-CI4.mjs +19 -0
- package/dist/packem_shared/api-spec-CtA6ilu4.mjs +13 -0
- package/dist/packem_shared/buildRegistryIndex-BcYe607_.mjs +38 -0
- package/dist/packem_shared/command-BC30oSBW.mjs +14 -0
- package/dist/packem_shared/commands-DPKWlqqX.mjs +812 -0
- package/dist/packem_shared/createLogger-B40gPzQo.mjs +78 -0
- package/dist/packem_shared/createRecordingSpawner-DxI3mebw.mjs +43 -0
- package/dist/packem_shared/detect-package-manager-DYp7n3mJ.mjs +61 -0
- package/dist/packem_shared/diffSnapshots-BeDvvNiF.mjs +161 -0
- package/dist/packem_shared/docker-hMQ97KSQ.mjs +21 -0
- package/dist/packem_shared/insertSchemaExtension-BuzF6-t2.mjs +59 -0
- package/dist/packem_shared/open-url-Dfq6fAyT.mjs +41 -0
- package/dist/packem_shared/output-format-wUvAN6AL.mjs +17 -0
- package/dist/packem_shared/parseArgs-YXFuKdEk.mjs +56 -0
- package/dist/packem_shared/parseManifest--vZf2FY1.mjs +94 -0
- package/dist/packem_shared/resolve-target-qbsJ_5sF.mjs +16 -0
- package/dist/packem_shared/runAddCommand-CTRA_JlL.mjs +4 -0
- package/dist/packem_shared/schema-drift-gate-BtBt0as0.mjs +79 -0
- package/dist/packem_shared/schemaIrToSnapshot-DdsljJT-.mjs +43 -0
- package/dist/packem_shared/storage-2RJBhUC4.mjs +84 -0
- package/dist/packem_shared/tui-prompts-DEiPCKV-.mjs +661 -0
- package/dist/packem_shared/wrangler-name-cy4yhm9j.mjs +12 -0
- package/package.json +62 -18
- package/skills/README.md +29 -0
- package/skills/lunora/SKILL.md +83 -0
- package/skills/lunora-create-package/SKILL.md +129 -0
- package/skills/lunora-deploy/SKILL.md +150 -0
- package/skills/lunora-functions/SKILL.md +182 -0
- package/skills/lunora-migration-helper/SKILL.md +194 -0
- package/skills/lunora-performance-audit/SKILL.md +143 -0
- package/skills/lunora-quickstart/SKILL.md +240 -0
- package/skills/lunora-realtime/SKILL.md +177 -0
- package/skills/lunora-setup-auth/SKILL.md +170 -0
- package/skills/lunora-setup-hyperdrive/SKILL.md +154 -0
- package/skills/lunora-setup-hyperdrive-global/SKILL.md +171 -0
- package/skills/lunora-setup-mail/SKILL.md +151 -0
- package/skills/lunora-setup-scheduler/SKILL.md +157 -0
- package/skills/lunora-setup-storage/SKILL.md +158 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lunora-setup-mail
|
|
3
|
+
description: Adds transactional email to a Lunora app. Use for sending mail (verification, password reset, invites, notifications) via `lunora registry add mail`, the `sendEmail` / `queueEmail` actions, the `SEND_EMAIL` Cloudflare Email Workers binding, Resend, React email templates, and the dev mail catcher.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lunora Setup Mail
|
|
7
|
+
|
|
8
|
+
Wire transactional email into a Lunora app using the `mail` registry item, which
|
|
9
|
+
is built on `@lunora/mail` (a Cloudflare Email Workers transport with
|
|
10
|
+
header-injection-safe address handling) and exposes a `sendEmail` action plus a
|
|
11
|
+
fire-and-forget `queueEmail` action. In dev, every send is captured into the
|
|
12
|
+
Studio Mail tab instead of going out.
|
|
13
|
+
|
|
14
|
+
## When to Use
|
|
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.
|
|
32
|
+
|
|
33
|
+
## Step 1: Add the item
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
lunora registry add mail
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This:
|
|
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`.
|
|
48
|
+
|
|
49
|
+
## Step 2: Configure delivery
|
|
50
|
+
|
|
51
|
+
| Name | Where | Notes |
|
|
52
|
+
| ---------------- | ------------------------------------ | --------------------------------------------------------------------------------- |
|
|
53
|
+
| `SEND_EMAIL` | `wrangler.jsonc` → `send_email[]` | Cloudflare Email Workers binding. Single-recipient; only verified destinations. |
|
|
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`. |
|
|
56
|
+
|
|
57
|
+
For production with Cloudflare Email Workers, set up
|
|
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`.
|
|
62
|
+
|
|
63
|
+
In `lunora dev` (`WORKER_ENV=development`) the scaffold swaps in `@lunora/mail`'s
|
|
64
|
+
**capture transport** automatically: every send — including `@lunora/auth`'s
|
|
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.
|
|
67
|
+
|
|
68
|
+
## Step 3: Regenerate types
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
lunora codegen
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The functions surface in the generated `api` as `api.mail.sendEmail` and
|
|
75
|
+
`api.mail.queueEmail`.
|
|
76
|
+
|
|
77
|
+
## Step 4: Send mail
|
|
78
|
+
|
|
79
|
+
### From another function
|
|
80
|
+
|
|
81
|
+
`sendEmail` is an **action** (sending is non-transactional network I/O). From a
|
|
82
|
+
mutation, schedule it as a follow-up so the request is not blocked:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { mutation, v } from "@lunora/server";
|
|
86
|
+
|
|
87
|
+
import { api } from "./_generated/api";
|
|
88
|
+
|
|
89
|
+
export const inviteUser = mutation.input({ email: v.string() }).mutation(async ({ ctx, args: { email } }) => {
|
|
90
|
+
// ...persist the invite, then send the mail as a follow-up action
|
|
91
|
+
await ctx.scheduler.runAfter(0, api.mail.sendEmail, {
|
|
92
|
+
to: email,
|
|
93
|
+
subject: "You're invited",
|
|
94
|
+
html: "<p>Click the link to join.</p>",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### From a client
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
await client.action("mail/sendEmail", {
|
|
103
|
+
to: "alice@example.com",
|
|
104
|
+
subject: "Welcome",
|
|
105
|
+
text: "Thanks for signing up!",
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### With a React email template
|
|
110
|
+
|
|
111
|
+
React elements are not JSON-serializable across the RPC boundary, so the
|
|
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`:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { createMailer } from "@lunora/mail";
|
|
118
|
+
import { env } from "cloudflare:workers";
|
|
119
|
+
|
|
120
|
+
import { WelcomeEmail } from "./emails/Welcome";
|
|
121
|
+
|
|
122
|
+
await createMailer({ apiKey: env.RESEND_API_KEY as string, from: env.MAIL_FROM as string }).send({
|
|
123
|
+
to: "alice@example.com",
|
|
124
|
+
subject: "Welcome",
|
|
125
|
+
react: <WelcomeEmail name="Alice" />,
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Common Pitfalls
|
|
130
|
+
|
|
131
|
+
1. **Expecting prod email to "just work".** Dev captures into the Studio;
|
|
132
|
+
production needs the `SEND_EMAIL` binding (a verified destination) or
|
|
133
|
+
`RESEND_API_KEY`.
|
|
134
|
+
2. **Calling `sendEmail` as a query/mutation.** It is an action — invoke it via
|
|
135
|
+
`ctx.runAction` / `ctx.scheduler.runAfter` / `client.action`, never `ctx.db`.
|
|
136
|
+
3. **Using `queueEmail` without a Queue binding.** It requires a Cloudflare
|
|
137
|
+
Queue producer binding; until you add one, `@lunora/mail` throws
|
|
138
|
+
`` `queue` binding is required for mailer.queue() ``. The item does not add
|
|
139
|
+
the Queue for you — see the `mail` README's "Queueing" section.
|
|
140
|
+
4. **Passing a React element through the action args.** Render it inside the
|
|
141
|
+
mailer (`mailer.send({ react })`), not across the RPC boundary.
|
|
142
|
+
|
|
143
|
+
## Checklist
|
|
144
|
+
|
|
145
|
+
- [ ] `lunora registry add mail` run, `pnpm install` done.
|
|
146
|
+
- [ ] `SEND_EMAIL` binding configured (verified destination) or
|
|
147
|
+
`RESEND_API_KEY` set; `MAIL_FROM` set.
|
|
148
|
+
- [ ] `lunora codegen` run so `api.mail.*` is generated.
|
|
149
|
+
- [ ] Mail sent from a function (`ctx.scheduler.runAfter`/`ctx.runAction` with
|
|
150
|
+
`api.mail.sendEmail`) or the client (`client.action("mail/sendEmail", …)`).
|
|
151
|
+
- [ ] Verified the send appears in the Studio Mail tab in dev.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lunora-setup-scheduler
|
|
3
|
+
description: Schedules deferred and recurring work in a Lunora app. Use for `ctx.scheduler.runAfter` / `runAt` (delayed function dispatch), cron jobs via `lunora registry add crons` (the `cronJobs()` builder), the `SchedulerDO` / `SCHEDULER` binding, retries, and the workpool for bounded concurrency.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lunora Setup Scheduler
|
|
7
|
+
|
|
8
|
+
Schedule work in Lunora two ways, both backed by `@lunora/scheduler` (re-exported
|
|
9
|
+
from `@lunora/server`):
|
|
10
|
+
|
|
11
|
+
- **Deferred dispatch** — `ctx.scheduler.runAfter` / `runAt` from any function,
|
|
12
|
+
to run a function later. Built into the runtime; **no registry install**.
|
|
13
|
+
- **Recurring jobs (crons)** — declare them in `lunora/crons.ts` with
|
|
14
|
+
`cronJobs()`. Add the starter with `lunora registry add crons`.
|
|
15
|
+
|
|
16
|
+
Both run jobs through the `SchedulerDO` Durable Object (binding `SCHEDULER`),
|
|
17
|
+
which owns the alarm and durable storage.
|
|
18
|
+
|
|
19
|
+
## When to Use
|
|
20
|
+
|
|
21
|
+
- Running a function after a delay or at a specific time
|
|
22
|
+
(`runAfter` / `runAt`).
|
|
23
|
+
- Declaring recurring jobs (sweep, digest, report) on a cron schedule.
|
|
24
|
+
- Bounding concurrency for many enqueued jobs (a workpool).
|
|
25
|
+
|
|
26
|
+
## When Not to Use
|
|
27
|
+
|
|
28
|
+
- The project has no Lunora backend yet — use `lunora-quickstart` first.
|
|
29
|
+
- You just need to react to live data changes — use a reactive `query` /
|
|
30
|
+
subscription (`lunora-realtime`), not a scheduled job.
|
|
31
|
+
|
|
32
|
+
## Deferred dispatch — `runAfter` / `runAt`
|
|
33
|
+
|
|
34
|
+
Available on `ctx.scheduler` in any function. Target functions are passed by
|
|
35
|
+
reference from the generated `api` / `internal` proxy:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { mutation, v } from "@lunora/server";
|
|
39
|
+
|
|
40
|
+
import { internal } from "./_generated/api";
|
|
41
|
+
|
|
42
|
+
export const startTrial = mutation.input({ userId: v.string() }).mutation(async ({ ctx, args: { userId } }) => {
|
|
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 });
|
|
45
|
+
|
|
46
|
+
return { jobId: id };
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `runAfter(delayMs, fnRef, args, options?)` — run after a delay (`delayMs` must
|
|
51
|
+
be a non-negative finite number). `runAt(date, fnRef, args, options?)` — run at
|
|
52
|
+
a `Date` or epoch-ms timestamp.
|
|
53
|
+
- Both return `{ id, scheduledFor }`. Cancel with `ctx.scheduler.cancel(id)`;
|
|
54
|
+
inspect with `ctx.scheduler.get(id)` / `ctx.scheduler.list()`.
|
|
55
|
+
- `options` accepts a `retry` policy (`{ maxAttempts, backoff, baseMs, maxMs }`;
|
|
56
|
+
DO defaults: `maxAttempts: 5`, `backoff: "exponential"`, `baseMs: 30_000`) and
|
|
57
|
+
a `shardKey` routing hint. On retry exhaustion the job is dead-lettered, never
|
|
58
|
+
silently dropped.
|
|
59
|
+
- The `SchedulerDO` binding (`SCHEDULER`) is **auto-inferred and reconciled**
|
|
60
|
+
into `wrangler.jsonc` by `@lunora/config` once `@lunora/scheduler` is in use —
|
|
61
|
+
run `lunora codegen` / `lunora doctor` to confirm. Scheduled jobs run with no
|
|
62
|
+
end-user identity, so target **internal** functions (`internal.*`).
|
|
63
|
+
|
|
64
|
+
## Recurring jobs — crons
|
|
65
|
+
|
|
66
|
+
### Step 1: Add the starter
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
lunora registry add crons
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
This adds `@lunora/server` to `package.json` (run `pnpm install`) and copies
|
|
73
|
+
`lunora/crons.ts` (a `cronJobs()` registry with one illustrative job) and
|
|
74
|
+
`lunora/crons/jobs.ts` (the example `run` internal mutation it fires) into your
|
|
75
|
+
project — both **yours** to edit. No extra DO binding is required.
|
|
76
|
+
|
|
77
|
+
### Step 2: Declare jobs
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { cronJobs } from "@lunora/server";
|
|
81
|
+
|
|
82
|
+
import { internal } from "./_generated/api";
|
|
83
|
+
|
|
84
|
+
const crons = cronJobs();
|
|
85
|
+
|
|
86
|
+
crons.interval("sweep presence", { minutes: 5 }, internal.presence.sweep, { roomId: "lobby" });
|
|
87
|
+
crons.daily("digest", { hourUTC: 9, minuteUTC: 0 }, internal.email.digest, {});
|
|
88
|
+
crons.weekly("report", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.reports.weekly, {});
|
|
89
|
+
crons.monthly("invoice", { day: 1, hourUTC: 0, minuteUTC: 0 }, internal.billing.invoice, {});
|
|
90
|
+
crons.cron("custom", "0 */6 * * *", internal.foo.bar, {}); // raw cron escape hatch
|
|
91
|
+
|
|
92
|
+
export default crons;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `name` must be a non-empty **string literal**, unique across the project.
|
|
96
|
+
- `fnRef` must be a static two-segment access on the proxy
|
|
97
|
+
(`internal.<file>.<fn>` or `api.<file>.<fn>`) so codegen can discover it.
|
|
98
|
+
Cron targets must be **internal** functions — a client can never invoke them.
|
|
99
|
+
- All schedules are UTC and validated at definition time
|
|
100
|
+
(`hourUTC: 25` throws immediately).
|
|
101
|
+
|
|
102
|
+
### Step 3: Regenerate types and the schedule
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
lunora codegen
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Codegen discovers each registration by AST, compiles the schedule to a cron
|
|
109
|
+
expression, and emits `lunora/_generated/crons.ts` (the dispatcher the Worker's
|
|
110
|
+
`scheduled()` handler consumes) plus the matching `triggers.crons` entry in
|
|
111
|
+
`wrangler.jsonc`. You never hand-edit the wrangler schedule array.
|
|
112
|
+
|
|
113
|
+
## Bounded concurrency — workpool (optional)
|
|
114
|
+
|
|
115
|
+
For many enqueued jobs that must not all run at once, use a workpool — a named
|
|
116
|
+
logical pool inside the same `SchedulerDO` (no extra binding):
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { createWorkpool } from "@lunora/scheduler";
|
|
120
|
+
|
|
121
|
+
const pool = createWorkpool({
|
|
122
|
+
namespace: env.SCHEDULER,
|
|
123
|
+
originUrl: "https://my-app.example.com",
|
|
124
|
+
name: "imports",
|
|
125
|
+
maxConcurrency: 3,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await pool.enqueue(internal.imports.processRow, { rowId });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The DO caps simultaneous dispatch at `maxConcurrency` and queues the rest
|
|
132
|
+
durably. A Cloudflare-Queues-backed variant (`createQueueWorkpool`) leans on
|
|
133
|
+
queue config for concurrency/retries instead — reach for the DO workpool when you
|
|
134
|
+
need per-job cancel / status.
|
|
135
|
+
|
|
136
|
+
## Common Pitfalls
|
|
137
|
+
|
|
138
|
+
1. **Targeting a non-internal function from a job/cron.** Scheduled dispatch has
|
|
139
|
+
no end-user identity — target `internal.*` functions.
|
|
140
|
+
2. **Non-static `fnRef` or `name` in `cronJobs()`.** Codegen discovers them by
|
|
141
|
+
AST; a dynamic reference or computed name can't be found.
|
|
142
|
+
3. **Forgetting `lunora codegen` after editing crons.** The `triggers.crons`
|
|
143
|
+
array and the dispatcher map are codegen output — re-run it.
|
|
144
|
+
4. **Non-idempotent job handlers.** A missed tick may be retried and a slow tick
|
|
145
|
+
can overlap the next — make handlers idempotent.
|
|
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.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lunora-setup-storage
|
|
3
|
+
description: Adds R2-backed file storage to a Lunora app. Use for uploads/downloads via `lunora registry add storage`, signed PUT/GET URLs, the `UPLOADS` R2 bucket binding, `STORAGE_SIGNING_SECRET`, per-tenant key scoping, and verifying downloads in the Worker.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lunora Setup Storage
|
|
7
|
+
|
|
8
|
+
Wire R2-backed file storage into a Lunora app using the `storage` registry item,
|
|
9
|
+
which is built on `@lunora/storage` (an R2 adapter plus HMAC signed-URL helpers)
|
|
10
|
+
and exposes idiomatic Lunora functions for direct browser uploads, gated
|
|
11
|
+
downloads, delete, and list — so the bytes never proxy through your Worker.
|
|
12
|
+
|
|
13
|
+
## When to Use
|
|
14
|
+
|
|
15
|
+
- Uploading user files (avatars, attachments) straight to R2.
|
|
16
|
+
- Serving private/gated downloads via short-lived signed URLs.
|
|
17
|
+
- Listing or deleting a caller's stored objects.
|
|
18
|
+
|
|
19
|
+
## When Not to Use
|
|
20
|
+
|
|
21
|
+
- The project has no Lunora backend yet — use `lunora-quickstart` first.
|
|
22
|
+
- Storage is already installed and you just want to upload — call
|
|
23
|
+
`client.action("storage/generateUploadUrl", …)` and `PUT` to the returned URL.
|
|
24
|
+
|
|
25
|
+
## Workflow
|
|
26
|
+
|
|
27
|
+
1. Add the `storage` item.
|
|
28
|
+
2. Configure the `UPLOADS` R2 bucket binding and the signing secret.
|
|
29
|
+
3. Regenerate types with `lunora codegen`.
|
|
30
|
+
4. Verify signed downloads in the Worker's `GET /storage/:key` route.
|
|
31
|
+
5. Upload/download from the client.
|
|
32
|
+
|
|
33
|
+
## Step 1: Add the item
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
lunora registry add storage
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This:
|
|
40
|
+
|
|
41
|
+
1. Adds `@lunora/storage` and `@lunora/server` to `package.json` (run
|
|
42
|
+
`pnpm install` afterwards).
|
|
43
|
+
2. Adds an R2 bucket binding to `wrangler.jsonc` (`r2_buckets`, binding
|
|
44
|
+
**`UPLOADS`**, `bucket_name: "replace-me-uploads"` — rename it to a real
|
|
45
|
+
bucket). It **merges** into any existing `r2_buckets`.
|
|
46
|
+
3. Scaffolds `STORAGE_SIGNING_SECRET` (a secret) and `STORAGE_PUBLIC_BASE_URL`
|
|
47
|
+
into `.dev.vars`.
|
|
48
|
+
4. Copies `lunora/storage/index.ts` (the `generateUploadUrl` /
|
|
49
|
+
`getDownloadUrl` / `deleteObject` / `listObjects` functions) into your
|
|
50
|
+
project — it is **yours** to edit.
|
|
51
|
+
|
|
52
|
+
## Step 2: Configure the binding + secrets
|
|
53
|
+
|
|
54
|
+
| Name | Where | Notes |
|
|
55
|
+
| ------------------------- | ------------------------------------ | ------------------------------------------------------------------------ |
|
|
56
|
+
| `UPLOADS` | `wrangler.jsonc` → `r2_buckets[]` | The R2 bucket binding. Point `bucket_name` at a real bucket. |
|
|
57
|
+
| `STORAGE_SIGNING_SECRET` | secret (`.dev.vars` / `secret put`) | HMAC secret for signed URLs. Min 32 chars; never share across buckets. |
|
|
58
|
+
| `STORAGE_PUBLIC_BASE_URL` | var (`.dev.vars` / `wrangler.jsonc`) | Public host/route that fronts the bucket and serves `GET /storage/:key`. |
|
|
59
|
+
|
|
60
|
+
Generate a real signing secret with `openssl rand -base64 32` and write it with
|
|
61
|
+
`wrangler secret put STORAGE_SIGNING_SECRET` for production.
|
|
62
|
+
|
|
63
|
+
## Step 3: Regenerate types
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
lunora codegen
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The functions surface in the generated `api` as `api.storage.generateUploadUrl`,
|
|
70
|
+
`api.storage.getDownloadUrl`, `api.storage.deleteObject`, and
|
|
71
|
+
`api.storage.listObjects`.
|
|
72
|
+
|
|
73
|
+
## Step 4: Verify downloads in the Worker
|
|
74
|
+
|
|
75
|
+
Signed URLs are only as safe as the route that checks them. Gate
|
|
76
|
+
`GET /storage/:key` with `verifySignedUrl` before streaming the R2 body
|
|
77
|
+
(`@lunora/server` also ships `serveStorageObject` to do this):
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { verifySignedUrl } from "@lunora/storage";
|
|
81
|
+
|
|
82
|
+
export default {
|
|
83
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
84
|
+
const url = new URL(request.url);
|
|
85
|
+
|
|
86
|
+
if (url.pathname.startsWith("/storage/")) {
|
|
87
|
+
const result = await verifySignedUrl(url, env.STORAGE_SIGNING_SECRET);
|
|
88
|
+
|
|
89
|
+
if (!result.valid || result.key === undefined) {
|
|
90
|
+
// Expose only `valid` — a precise reason is a signing oracle.
|
|
91
|
+
return new Response("forbidden", { status: 403 });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const object = await env.UPLOADS.get(result.key);
|
|
95
|
+
|
|
96
|
+
if (!object) {
|
|
97
|
+
return new Response("not found", { status: 404 });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return new Response(object.body, {
|
|
101
|
+
headers: { "content-type": object.httpMetadata?.contentType ?? "application/octet-stream" },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ... your Lunora handler
|
|
106
|
+
return new Response("not found", { status: 404 });
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`verifySignedUrl` checks expiry, then the HMAC. On a host-rewrite / CDN topology
|
|
112
|
+
pass `{ expectedHost }` (the `STORAGE_PUBLIC_BASE_URL` host) so the signature
|
|
113
|
+
canonicalizes against the host it was minted for.
|
|
114
|
+
|
|
115
|
+
## Step 5: Upload / download from the client
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
// 1. ask the server for a signed PUT URL
|
|
119
|
+
const { key, url } = await client.action("storage/generateUploadUrl", {
|
|
120
|
+
key: "avatar.png",
|
|
121
|
+
contentType: file.type,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// 2. upload straight to R2 (no Worker proxy)
|
|
125
|
+
await fetch(url, { method: "PUT", headers: { "content-type": file.type }, body: file });
|
|
126
|
+
|
|
127
|
+
// 3. later, get a signed GET URL to display it
|
|
128
|
+
const { url: downloadUrl } = await client.action("storage/getDownloadUrl", { key: "avatar.png" });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Every key is scoped per-tenant with `scopeKey(tenantPrefix(ctx.auth.userId),
|
|
132
|
+
key)`, so a client-supplied key can never address another user's data. The
|
|
133
|
+
functions return the **scoped** key (`<userId>/avatar.png`) alongside the URL;
|
|
134
|
+
persist that, and pass the bare key back in — the component re-scopes it.
|
|
135
|
+
|
|
136
|
+
## Common Pitfalls
|
|
137
|
+
|
|
138
|
+
1. **Skipping `verifySignedUrl` on the download route.** Without it, anyone can
|
|
139
|
+
read any key. Always verify before streaming.
|
|
140
|
+
2. **Placeholder bucket name.** `lunora init` and `lunora add storage` prompt for
|
|
141
|
+
the bucket name (or take `--bucket <name>`), but the low-level
|
|
142
|
+
`lunora registry add storage` writes the placeholder
|
|
143
|
+
`bucket_name: "replace-me-uploads"` — rename it to a real R2 bucket. (R2 names
|
|
144
|
+
are lowercase alphanumeric + hyphens, 3–63 chars; wrangler rejects anything
|
|
145
|
+
else on `dev`/`deploy`.)
|
|
146
|
+
3. **Short / shared signing secret.** Use ≥32 chars and a distinct secret per
|
|
147
|
+
bucket; reusing it lets one bucket's URLs sign for another.
|
|
148
|
+
4. **Proxying bytes through the Worker.** The design uploads/downloads directly
|
|
149
|
+
to R2 via signed URLs — don't re-route the file body through a function.
|
|
150
|
+
|
|
151
|
+
## Checklist
|
|
152
|
+
|
|
153
|
+
- [ ] `lunora registry add storage` run, `pnpm install` done.
|
|
154
|
+
- [ ] `UPLOADS` bucket bound to a real bucket; `STORAGE_SIGNING_SECRET` (≥32
|
|
155
|
+
chars) and `STORAGE_PUBLIC_BASE_URL` set.
|
|
156
|
+
- [ ] `lunora codegen` run so `api.storage.*` is generated.
|
|
157
|
+
- [ ] `GET /storage/:key` route verifies signed URLs before streaming.
|
|
158
|
+
- [ ] Verified a client upload → signed download round-trip.
|