@moku-labs/worker 0.5.0 → 0.6.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/README.md +10 -9
- package/dist/cli-Dc0q0hIy.cjs +2988 -0
- package/dist/cli-DgZv5A0G.mjs +2888 -0
- package/dist/cli.cjs +3 -1900
- package/dist/cli.d.cts +1 -270
- package/dist/cli.d.mts +1 -270
- package/dist/cli.mjs +1 -1875
- package/dist/index-VZ99IAMv.d.cts +353 -0
- package/dist/index-VZ99IAMv.d.mts +353 -0
- package/dist/index.cjs +61 -63
- package/dist/index.d.cts +8 -4
- package/dist/index.d.mts +8 -4
- package/dist/index.mjs +49 -53
- package/package.json +1 -1
- package/dist/config-BYPJvEbl.d.cts +0 -88
- package/dist/config-BYPJvEbl.d.mts +0 -88
- package/dist/storage-COo-F38H.mjs +0 -884
- package/dist/storage-CgXl-dUA.cjs +0 -949
|
@@ -0,0 +1,2888 @@
|
|
|
1
|
+
import { envPlugin, logPlugin } from "@moku-labs/common";
|
|
2
|
+
import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
|
|
3
|
+
import { brandedSink, createBrandConsole, createBrandPrompts } from "@moku-labs/common/cli";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { readdir, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
/**
|
|
9
|
+
* stage core plugin — deployment-stage / dev-mode detection, flat-injected on
|
|
10
|
+
* every regular plugin's context as `ctx.stage` (spec/02 §6). No state, no
|
|
11
|
+
* events, no depends, no lifecycle hooks.
|
|
12
|
+
*
|
|
13
|
+
* @see README.md
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* // Inside any regular plugin's api factory:
|
|
17
|
+
* api: (ctx) => ({
|
|
18
|
+
* errorBody: (e: Error) =>
|
|
19
|
+
* ctx.stage.isDev() ? e.stack ?? e.message : "Internal Error",
|
|
20
|
+
* })
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
const stagePlugin = createCorePlugin("stage", {
|
|
24
|
+
config: { stage: "production" },
|
|
25
|
+
/**
|
|
26
|
+
* Builds the stage accessor surface from the resolved stage.
|
|
27
|
+
*
|
|
28
|
+
* @param ctx - Core plugin context (spec/02 §6 — `{ config, state }` only;
|
|
29
|
+
* no `global`, `emit`, or `require`). `state` is unused by this plugin.
|
|
30
|
+
* @param ctx.config - The resolved plugin config containing the deployment stage.
|
|
31
|
+
* @returns The `ctx.stage` API: `isDev`, `isProduction`, `current`.
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const api = stagePlugin.spec.api({ config: { stage: "development" }, state: {} });
|
|
35
|
+
* api.isDev(); // true
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
api: ({ config }) => ({
|
|
39
|
+
/**
|
|
40
|
+
* Whether this Worker runs in the development stage.
|
|
41
|
+
*
|
|
42
|
+
* @returns True iff `stage === "development"`.
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* if (ctx.stage.isDev()) return Response.json({ stack: err.stack });
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
isDev: () => config.stage === "development",
|
|
49
|
+
/**
|
|
50
|
+
* Whether this Worker runs in the production stage. Note: false in "test".
|
|
51
|
+
*
|
|
52
|
+
* @returns True iff `stage === "production"`.
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* const cc = ctx.stage.isProduction() ? "public, max-age=31536000" : "no-store";
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
isProduction: () => config.stage === "production",
|
|
59
|
+
/**
|
|
60
|
+
* The raw deployment stage, as the literal union (not `string`).
|
|
61
|
+
*
|
|
62
|
+
* @returns The resolved stage.
|
|
63
|
+
* @example
|
|
64
|
+
* ```typescript
|
|
65
|
+
* ctx.log.info("startup", { stage: ctx.stage.current() });
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
current: () => config.stage
|
|
69
|
+
})
|
|
70
|
+
});
|
|
71
|
+
const coreConfig = createCoreConfig("moku-worker", {
|
|
72
|
+
config: {
|
|
73
|
+
stage: "production",
|
|
74
|
+
name: "moku-worker",
|
|
75
|
+
compatibilityDate: ""
|
|
76
|
+
},
|
|
77
|
+
plugins: [
|
|
78
|
+
logPlugin,
|
|
79
|
+
envPlugin,
|
|
80
|
+
stagePlugin
|
|
81
|
+
]
|
|
82
|
+
});
|
|
83
|
+
const { createPlugin, createCore } = coreConfig;
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/plugins/bindings/api.ts
|
|
86
|
+
/**
|
|
87
|
+
* Checks whether a value read from an env object is nullish (null or undefined).
|
|
88
|
+
* Cloudflare supplies either form when a binding is absent, so both must be caught.
|
|
89
|
+
*
|
|
90
|
+
* @param value - The value read from the env object.
|
|
91
|
+
* @returns True when the value is null or undefined.
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* isNullish(undefined); // true
|
|
95
|
+
* isNullish(0); // false — falsy but bound
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
const isNullish = (value) => value === void 0 || value === null;
|
|
99
|
+
/**
|
|
100
|
+
* Resolves binding `name` off a request-supplied env object, narrowed to T.
|
|
101
|
+
* Throws a `[moku-worker]`-prefixed error when the binding is nullish.
|
|
102
|
+
* The env argument is read but never retained.
|
|
103
|
+
*
|
|
104
|
+
* @param env - The Cloudflare request env object passed to fetch/scheduled/queue.
|
|
105
|
+
* @param name - The binding name to resolve.
|
|
106
|
+
* @returns The binding value narrowed to T.
|
|
107
|
+
* @throws {Error} With a `[moku-worker]` prefix when the binding is null or undefined.
|
|
108
|
+
* @example
|
|
109
|
+
* ```typescript
|
|
110
|
+
* const kv = requireBinding<KVNamespace>(env, "MY_KV");
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
const requireBinding = (env, name) => {
|
|
114
|
+
const value = env[name];
|
|
115
|
+
if (isNullish(value)) throw new Error(`[moku-worker] binding "${name}" is not bound.\n Declare it in wrangler config and pass it in via the request env.`);
|
|
116
|
+
return value;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Returns true when `name` resolves to a non-nullish value on the request env.
|
|
120
|
+
* Never throws. Use for optional-binding branching without forcing an error.
|
|
121
|
+
*
|
|
122
|
+
* @param env - The Cloudflare request env object passed to fetch/scheduled/queue.
|
|
123
|
+
* @param name - The binding name to check.
|
|
124
|
+
* @returns Whether the binding is present and non-nullish.
|
|
125
|
+
* @example
|
|
126
|
+
* ```typescript
|
|
127
|
+
* const ok = hasBinding(env, "DB"); // false if DB is not bound
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
const hasBinding = (env, name) => !isNullish(env[name]);
|
|
131
|
+
/**
|
|
132
|
+
* Builds the app.bindings API surface. The factory receives a context but does
|
|
133
|
+
* not use it — bindings holds no state (F4) and all resolution is argument-local.
|
|
134
|
+
*
|
|
135
|
+
* @param _ctx - Plugin context (unused; bindings is stateless — F4).
|
|
136
|
+
* @returns BindingsApi with `require` and `has` methods.
|
|
137
|
+
* @example
|
|
138
|
+
* ```typescript
|
|
139
|
+
* const api = createBindingsApi(ctx);
|
|
140
|
+
* const kv = api.require<KVNamespace>(env, "MY_KV");
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
const createBindingsApi = (_ctx) => ({
|
|
144
|
+
require: requireBinding,
|
|
145
|
+
has: hasBinding
|
|
146
|
+
});
|
|
147
|
+
/**
|
|
148
|
+
* Standard-tier stateless resolver — the binding-family dependency root.
|
|
149
|
+
*
|
|
150
|
+
* Exposes `require<T>(env, name)` and `has(env, name)` off a per-request env
|
|
151
|
+
* object. Regular plugin so downstream binding plugins can declare
|
|
152
|
+
* `depends: [bindingsPlugin]` and reach it via `ctx.require(bindingsPlugin)`.
|
|
153
|
+
*
|
|
154
|
+
* @see README.md
|
|
155
|
+
*/
|
|
156
|
+
const bindingsPlugin = createPlugin("bindings", {
|
|
157
|
+
config: { required: [] },
|
|
158
|
+
api: createBindingsApi
|
|
159
|
+
});
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/plugins/d1/api.ts
|
|
162
|
+
/**
|
|
163
|
+
* Create the d1 api. Each method resolves the D1Database off the request
|
|
164
|
+
* `env` via the bindings plugin, then forwards to the native D1 call. The
|
|
165
|
+
* binding is never cached, so concurrent requests stay isolated (SB4).
|
|
166
|
+
*
|
|
167
|
+
* The return is intentionally NOT annotated `: Api`. Annotating it would
|
|
168
|
+
* collapse the per-method call-site generic `<T>` on `query`/`first` to
|
|
169
|
+
* `unknown`; instead the implementation forwards `<T>` to `all<T>()` /
|
|
170
|
+
* `first<T>()` and `types.ts#Api` remains the public-surface source of truth.
|
|
171
|
+
*
|
|
172
|
+
* @param {D1Ctx} ctx - Plugin context (own config + require).
|
|
173
|
+
* @returns {object} The d1 public api (query, first, run, batch, prepare, deployManifest).
|
|
174
|
+
* @example
|
|
175
|
+
* ```typescript
|
|
176
|
+
* const api = createD1Api(ctx);
|
|
177
|
+
* const { results } = await api.query<Product>(env, "SELECT * FROM products");
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
const createD1Api = (ctx) => {
|
|
181
|
+
const db = (env) => ctx.require(bindingsPlugin).require(env, ctx.config.binding);
|
|
182
|
+
return {
|
|
183
|
+
/**
|
|
184
|
+
* Run a statement and return all rows. Forwards the call-site generic to
|
|
185
|
+
* `all<T>()` so the result type is not widened to `unknown`.
|
|
186
|
+
*
|
|
187
|
+
* @param {WorkerEnv} env - Per-request Cloudflare bindings object.
|
|
188
|
+
* @param {string} sql - SQL text with `?` placeholders.
|
|
189
|
+
* @param {unknown[]} params - Bind parameters, in placeholder order.
|
|
190
|
+
* @returns {Promise<D1Result<T>>} All rows (`.results` is `T[]`).
|
|
191
|
+
* @example
|
|
192
|
+
* ```typescript
|
|
193
|
+
* const { results } = await api.query<Product>(env, "SELECT * FROM products WHERE active = ?", 1);
|
|
194
|
+
* ```
|
|
195
|
+
*/
|
|
196
|
+
query: (env, sql, ...params) => db(env).prepare(sql).bind(...params).all(),
|
|
197
|
+
/**
|
|
198
|
+
* Run a statement and return the first row, or `null` if there are none.
|
|
199
|
+
* Forwards the call-site generic to `first<T>()`.
|
|
200
|
+
*
|
|
201
|
+
* @param {WorkerEnv} env - Per-request Cloudflare bindings object.
|
|
202
|
+
* @param {string} sql - SQL text with `?` placeholders.
|
|
203
|
+
* @param {unknown[]} params - Bind parameters, in placeholder order.
|
|
204
|
+
* @returns {Promise<T | null>} The first row, or `null` if none matched.
|
|
205
|
+
* @example
|
|
206
|
+
* ```typescript
|
|
207
|
+
* const row = await api.first<Product>(env, "SELECT * FROM products WHERE id = ?", id);
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
first: (env, sql, ...params) => db(env).prepare(sql).bind(...params).first(),
|
|
211
|
+
/**
|
|
212
|
+
* Run a write/DDL statement (INSERT/UPDATE/DELETE/DDL) and return the
|
|
213
|
+
* D1 result carrying `.meta` (e.g. `rows_written`, `last_row_id`).
|
|
214
|
+
*
|
|
215
|
+
* @param {WorkerEnv} env - Per-request Cloudflare bindings object.
|
|
216
|
+
* @param {string} sql - SQL text with `?` placeholders.
|
|
217
|
+
* @param {unknown[]} params - Bind parameters, in placeholder order.
|
|
218
|
+
* @returns {Promise<D1Result>} Result carrying `.meta`.
|
|
219
|
+
* @example
|
|
220
|
+
* ```typescript
|
|
221
|
+
* const res = await api.run(env, "INSERT INTO products (name) VALUES (?)", name);
|
|
222
|
+
* const id = res.meta.last_row_id;
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
run: (env, sql, ...params) => db(env).prepare(sql).bind(...params).run(),
|
|
226
|
+
/**
|
|
227
|
+
* Execute caller-built prepared statements atomically in one round-trip,
|
|
228
|
+
* returning one result per statement in order.
|
|
229
|
+
*
|
|
230
|
+
* @param {WorkerEnv} env - Per-request Cloudflare bindings object.
|
|
231
|
+
* @param {D1PreparedStatement[]} stmts - Statements built from prepare(env).
|
|
232
|
+
* @returns {Promise<D1Result[]>} One result per statement, order preserved.
|
|
233
|
+
* @example
|
|
234
|
+
* ```typescript
|
|
235
|
+
* const handle = api.prepare(env);
|
|
236
|
+
* await api.batch(env, [handle.prepare("INSERT INTO a VALUES (1)").bind()]);
|
|
237
|
+
* ```
|
|
238
|
+
*/
|
|
239
|
+
batch: (env, stmts) => db(env).batch(stmts),
|
|
240
|
+
/**
|
|
241
|
+
* Resolve the request-scoped D1Database so callers can build prepared
|
|
242
|
+
* statements for batch(). Issues no query itself.
|
|
243
|
+
*
|
|
244
|
+
* @param {WorkerEnv} env - Per-request Cloudflare bindings object.
|
|
245
|
+
* @returns {D1Database} The request-resolved database handle.
|
|
246
|
+
* @example
|
|
247
|
+
* ```typescript
|
|
248
|
+
* const handle = api.prepare(env);
|
|
249
|
+
* const stmt = handle.prepare("SELECT * FROM t").bind();
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
prepare: (env) => db(env),
|
|
253
|
+
/**
|
|
254
|
+
* Return this plugin's deploy metadata for the deploy plugin to read.
|
|
255
|
+
* Build-time only — takes no `env`. The return is typed `DeployManifest`
|
|
256
|
+
* (from types.ts), which pins `kind` to the literal `"d1"` without an
|
|
257
|
+
* inline `as` assertion.
|
|
258
|
+
*
|
|
259
|
+
* @returns {DeployManifest} Deploy manifest entry `{ kind: "d1", binding, migrations }`.
|
|
260
|
+
* @example
|
|
261
|
+
* ```typescript
|
|
262
|
+
* const m = api.deployManifest();
|
|
263
|
+
* // => { kind: "d1", binding: "DB", migrations: "./migrations" }
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
deployManifest: () => ({
|
|
267
|
+
kind: "d1",
|
|
268
|
+
binding: ctx.config.binding,
|
|
269
|
+
migrations: ctx.config.migrations
|
|
270
|
+
})
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* Standard tier — Cloudflare D1 SQL access (thin typed wrappers, not an ORM).
|
|
275
|
+
*
|
|
276
|
+
* Exposes `query`, `first`, `run`, `batch`, `prepare`, and `deployManifest`.
|
|
277
|
+
* Resolves the D1 binding off the per-request `env` via the bindings plugin.
|
|
278
|
+
* No state, no events, no lifecycle hooks (request-scoped, spec/06 §3).
|
|
279
|
+
*
|
|
280
|
+
* @see README.md
|
|
281
|
+
*/
|
|
282
|
+
const d1Plugin = createPlugin("d1", {
|
|
283
|
+
depends: [bindingsPlugin],
|
|
284
|
+
config: {
|
|
285
|
+
binding: "DB",
|
|
286
|
+
migrations: ""
|
|
287
|
+
},
|
|
288
|
+
api: (ctx) => createD1Api(ctx)
|
|
289
|
+
});
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/plugins/durable-objects/api.ts
|
|
292
|
+
/**
|
|
293
|
+
* Builds the `app.durableObjects` API surface — `get` and `deployManifest`.
|
|
294
|
+
*
|
|
295
|
+
* All namespace resolution uses the per-call `env` argument: `env` is threaded, never
|
|
296
|
+
* stored (SB4 / design §1a). The config bindings map is frozen and read-only. No state
|
|
297
|
+
* is held on the plugin between calls (stateless — `Record<string, never>`).
|
|
298
|
+
*
|
|
299
|
+
* @param ctx - Plugin context with `config.bindings`, `require(bindingsPlugin)`, and core APIs.
|
|
300
|
+
* @returns The durableObjects API: `{ get, deployManifest }`.
|
|
301
|
+
* @example
|
|
302
|
+
* ```typescript
|
|
303
|
+
* const api = createDoApi(ctx);
|
|
304
|
+
* const stub = api.get(env, "counter", "room-42");
|
|
305
|
+
* const manifest = api.deployManifest(); // { kind: "do", bindings: { counter: "COUNTER" } }
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
const createDoApi = (ctx) => ({
|
|
309
|
+
/**
|
|
310
|
+
* Resolves a `DurableObjectStub` off the per-request env.
|
|
311
|
+
*
|
|
312
|
+
* Maps `logicalName` → `config.bindings[logicalName]` (falling back to `logicalName`
|
|
313
|
+
* itself when unmapped), derives a deterministic id via `namespace.idFromName(idName)`,
|
|
314
|
+
* and returns the addressed stub. Synchronous — returns a stub, not a Promise.
|
|
315
|
+
* Throws (via the bindings resolver) when the binding is not present on `env`.
|
|
316
|
+
*
|
|
317
|
+
* @param env - Per-request Cloudflare bindings object (Worker fetch/queue/scheduled env).
|
|
318
|
+
* @param logicalName - Logical DO name used in code (e.g. `"counter"`).
|
|
319
|
+
* @param idName - Stable id name passed to `idFromName` (e.g. `"room-42"`).
|
|
320
|
+
* @returns The addressed `DurableObjectStub`.
|
|
321
|
+
* @throws {Error} With `[moku-worker]` prefix when the binding is not bound on `env`.
|
|
322
|
+
* @example
|
|
323
|
+
* ```typescript
|
|
324
|
+
* const stub = app.durableObjects.get(env, "counter", "room-42");
|
|
325
|
+
* const res = await stub.fetch("https://do/increment");
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
328
|
+
get: (env, logicalName, idName) => {
|
|
329
|
+
const binding = ctx.config.bindings[logicalName] ?? logicalName;
|
|
330
|
+
const ns = ctx.require(bindingsPlugin).require(env, binding);
|
|
331
|
+
return ns.get(ns.idFromName(idName));
|
|
332
|
+
},
|
|
333
|
+
/**
|
|
334
|
+
* Returns this plugin's deploy metadata — read by the `deploy` plugin via
|
|
335
|
+
* `ctx.require(durableObjectsPlugin)`. Never reads sibling `pluginConfigs` (F6;
|
|
336
|
+
* spec/08 §5, §7). Pure synchronous read of `ctx.config.bindings`.
|
|
337
|
+
*
|
|
338
|
+
* @returns `{ kind: "do", bindings }` reflecting the frozen plugin config.
|
|
339
|
+
* @example
|
|
340
|
+
* ```typescript
|
|
341
|
+
* const manifest = app.durableObjects.deployManifest();
|
|
342
|
+
* // → { kind: "do", bindings: { counter: "COUNTER" } }
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
345
|
+
deployManifest: () => ({
|
|
346
|
+
kind: "do",
|
|
347
|
+
bindings: ctx.config.bindings
|
|
348
|
+
})
|
|
349
|
+
});
|
|
350
|
+
//#endregion
|
|
351
|
+
//#region src/plugins/durable-objects/helpers.ts
|
|
352
|
+
/**
|
|
353
|
+
* Returns a base class the consumer extends and exports from `worker.ts`.
|
|
354
|
+
*
|
|
355
|
+
* PURE (spec/03 §1): takes no `ctx`, has no side effects, and may be called before
|
|
356
|
+
* `createApp`. The static `doName` property captures `name` for diagnostics and
|
|
357
|
+
* binding correlation. The constructor stores `(state, env)` as `this.ctx` / `this.env`,
|
|
358
|
+
* satisfying the Cloudflare Durable Object constructor contract. The plugin NEVER
|
|
359
|
+
* generates the final exported class — the consumer owns that class.
|
|
360
|
+
*
|
|
361
|
+
* @param name - Logical DO name; captured as `static doName` for diagnostics.
|
|
362
|
+
* @returns A base class (constructor) the consumer extends.
|
|
363
|
+
* @example
|
|
364
|
+
* ```typescript
|
|
365
|
+
* // src/counter.ts
|
|
366
|
+
* import { defineDurableObject } from "@moku-labs/worker";
|
|
367
|
+
*
|
|
368
|
+
* export class Counter extends defineDurableObject("Counter") {
|
|
369
|
+
* async fetch(): Promise<Response> {
|
|
370
|
+
* const n = ((await this.ctx.storage.get<number>("n")) ?? 0) + 1;
|
|
371
|
+
* await this.ctx.storage.put("n", n);
|
|
372
|
+
* return Response.json({ n });
|
|
373
|
+
* }
|
|
374
|
+
* }
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
const defineDurableObject = (name) => {
|
|
378
|
+
/**
|
|
379
|
+
* Base implementation of the Cloudflare Durable Object constructor contract.
|
|
380
|
+
* Stores `(ctx, env)` as readonly properties for consumer subclasses to use.
|
|
381
|
+
*/
|
|
382
|
+
class DurableObjectBaseImpl {
|
|
383
|
+
/**
|
|
384
|
+
* Cloudflare per-object storage/alarm context (DurableObjectState).
|
|
385
|
+
* Use `this.ctx.storage` to read/write durable storage and `this.ctx.id` to inspect the DO id.
|
|
386
|
+
*/
|
|
387
|
+
ctx;
|
|
388
|
+
/**
|
|
389
|
+
* Per-object Cloudflare bindings (per-request WorkerEnv).
|
|
390
|
+
* Mirrors the env passed at construction time; never cached across requests.
|
|
391
|
+
*/
|
|
392
|
+
env;
|
|
393
|
+
/**
|
|
394
|
+
* Logical DO name captured from `defineDurableObject(name)`.
|
|
395
|
+
* Used for diagnostics and binding correlation.
|
|
396
|
+
*/
|
|
397
|
+
static doName = name;
|
|
398
|
+
/**
|
|
399
|
+
* Constructs the base Durable Object with Cloudflare's required signature.
|
|
400
|
+
*
|
|
401
|
+
* @param ctx - Cloudflare DurableObjectState (storage, id, blockConcurrencyWhile, …).
|
|
402
|
+
* @param env - Per-request Cloudflare bindings object (WorkerEnv).
|
|
403
|
+
* @example
|
|
404
|
+
* ```typescript
|
|
405
|
+
* class Counter extends Base {
|
|
406
|
+
* constructor(ctx: DurableObjectState, env: WorkerEnv) { super(ctx, env); }
|
|
407
|
+
* }
|
|
408
|
+
* ```
|
|
409
|
+
*/
|
|
410
|
+
constructor(ctx, env) {
|
|
411
|
+
this.ctx = ctx;
|
|
412
|
+
this.env = env;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return DurableObjectBaseImpl;
|
|
416
|
+
};
|
|
417
|
+
/**
|
|
418
|
+
* Cloudflare Durable Objects plugin — Standard tier.
|
|
419
|
+
*
|
|
420
|
+
* Exposes `get(env, logicalName, idName)` (synchronous stub accessor, threaded env) and
|
|
421
|
+
* `deployManifest()` (build-time metadata). Depends on `bindingsPlugin` for namespace
|
|
422
|
+
* resolution. The `defineDurableObject` helper is mounted under `helpers` and re-exported
|
|
423
|
+
* at the top level for consumer use.
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* ```typescript
|
|
427
|
+
* // Consumer endpoint handler:
|
|
428
|
+
* const stub = app.durableObjects.get(env, "counter", params.room!);
|
|
429
|
+
* const res = await stub.fetch("https://do/increment");
|
|
430
|
+
* // Consumer DO class:
|
|
431
|
+
* export class Counter extends defineDurableObject("Counter") {
|
|
432
|
+
* async fetch(): Promise<Response> { return new Response("ok"); }
|
|
433
|
+
* }
|
|
434
|
+
* ```
|
|
435
|
+
* @see README.md
|
|
436
|
+
*/
|
|
437
|
+
const durableObjectsPlugin = createPlugin("durableObjects", {
|
|
438
|
+
depends: [bindingsPlugin],
|
|
439
|
+
config: { bindings: {} },
|
|
440
|
+
api: createDoApi,
|
|
441
|
+
helpers: { defineDurableObject }
|
|
442
|
+
});
|
|
443
|
+
//#endregion
|
|
444
|
+
//#region src/plugins/kv/api.ts
|
|
445
|
+
/**
|
|
446
|
+
* Builds the app.kv.* api. Resolves the KV namespace off the REQUEST-SUPPLIED env
|
|
447
|
+
* on every call — env is threaded, never stored (design §1a / SB4).
|
|
448
|
+
*
|
|
449
|
+
* @param ctx - The kv plugin context (own config + merged events).
|
|
450
|
+
* @returns The app.kv api: get / put / delete / list / deployManifest.
|
|
451
|
+
* @example
|
|
452
|
+
* ```typescript
|
|
453
|
+
* const api = createKvApi(ctx);
|
|
454
|
+
* const value = await api.get(env, "key");
|
|
455
|
+
* ```
|
|
456
|
+
*/
|
|
457
|
+
const createKvApi = (ctx) => {
|
|
458
|
+
const ns = (env) => ctx.require(bindingsPlugin).require(env, ctx.config.binding);
|
|
459
|
+
return {
|
|
460
|
+
/**
|
|
461
|
+
* Reads a value by key from the KV namespace. Returns null when absent.
|
|
462
|
+
*
|
|
463
|
+
* @param env - The per-request Cloudflare env (threaded, never stored).
|
|
464
|
+
* @param key - The key to read.
|
|
465
|
+
* @returns The stored value, or null when absent.
|
|
466
|
+
* @example
|
|
467
|
+
* ```typescript
|
|
468
|
+
* const value = await api.get(env, "feature-flags");
|
|
469
|
+
* ```
|
|
470
|
+
*/
|
|
471
|
+
get: async (env, key) => ns(env).get(key),
|
|
472
|
+
/**
|
|
473
|
+
* Writes a string value under a key, optionally with KV put options.
|
|
474
|
+
*
|
|
475
|
+
* @param env - The per-request Cloudflare env.
|
|
476
|
+
* @param key - The key to write.
|
|
477
|
+
* @param value - The string value to store.
|
|
478
|
+
* @param opts - Optional expiration / metadata.
|
|
479
|
+
* @returns Resolves once the write is acknowledged.
|
|
480
|
+
* @example
|
|
481
|
+
* ```typescript
|
|
482
|
+
* await api.put(env, "session:1", "data", { expirationTtl: 3600 });
|
|
483
|
+
* ```
|
|
484
|
+
*/
|
|
485
|
+
put: async (env, key, value, opts) => ns(env).put(key, value, opts),
|
|
486
|
+
/**
|
|
487
|
+
* Removes a key from the namespace (no-op if absent).
|
|
488
|
+
*
|
|
489
|
+
* @param env - The per-request Cloudflare env.
|
|
490
|
+
* @param key - The key to delete.
|
|
491
|
+
* @returns Resolves once the delete is acknowledged.
|
|
492
|
+
* @example
|
|
493
|
+
* ```typescript
|
|
494
|
+
* await api.delete(env, "session:expired");
|
|
495
|
+
* ```
|
|
496
|
+
*/
|
|
497
|
+
delete: async (env, key) => ns(env).delete(key),
|
|
498
|
+
/**
|
|
499
|
+
* Lists keys in the namespace, optionally filtered/paginated via opts.
|
|
500
|
+
*
|
|
501
|
+
* @param env - The per-request Cloudflare env.
|
|
502
|
+
* @param opts - Optional prefix / cursor / limit.
|
|
503
|
+
* @returns The list result from the KV namespace.
|
|
504
|
+
* @example
|
|
505
|
+
* ```typescript
|
|
506
|
+
* const { keys } = await api.list(env, { prefix: "session:" });
|
|
507
|
+
* ```
|
|
508
|
+
*/
|
|
509
|
+
list: async (env, opts) => ns(env).list(opts),
|
|
510
|
+
/**
|
|
511
|
+
* Returns this plugin's own deploy metadata, read by the deploy plugin via
|
|
512
|
+
* require (design §6 / F6). Build-time only — takes no env.
|
|
513
|
+
*
|
|
514
|
+
* @returns The kv deploy descriptor with kind literal and binding name.
|
|
515
|
+
* @example
|
|
516
|
+
* ```typescript
|
|
517
|
+
* const manifest = api.deployManifest(); // { kind: "kv", binding: "KV" }
|
|
518
|
+
* ```
|
|
519
|
+
*/
|
|
520
|
+
deployManifest: () => ({
|
|
521
|
+
kind: "kv",
|
|
522
|
+
binding: ctx.config.binding
|
|
523
|
+
})
|
|
524
|
+
};
|
|
525
|
+
};
|
|
526
|
+
/**
|
|
527
|
+
* Micro tier — thin env-first wrapper over a Cloudflare KV namespace.
|
|
528
|
+
*
|
|
529
|
+
* Resolves the KV namespace per request via `ctx.require(bindingsPlugin)`;
|
|
530
|
+
* never stores env in state (design §1a / SB4). No lifecycle hooks —
|
|
531
|
+
* request-scoped; nothing to open or close.
|
|
532
|
+
*
|
|
533
|
+
* @see README.md
|
|
534
|
+
*/
|
|
535
|
+
const kvPlugin = createPlugin("kv", {
|
|
536
|
+
depends: [bindingsPlugin],
|
|
537
|
+
config: { binding: "KV" },
|
|
538
|
+
api: createKvApi
|
|
539
|
+
});
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/plugins/queues/api.ts
|
|
542
|
+
/**
|
|
543
|
+
* @file queues plugin — API factory (send, sendBatch, consume, deployManifest).
|
|
544
|
+
*
|
|
545
|
+
* All binding-resolving methods take the per-request `env` as the first argument
|
|
546
|
+
* and resolve the `Queue` via `ctx.require(bindingsPlugin).require<Queue>(env, name)`.
|
|
547
|
+
* The `env` is never stored (SB4 / design §1a) — resolved fresh on every call.
|
|
548
|
+
*/
|
|
549
|
+
/**
|
|
550
|
+
* Builds app.queues.* — read by worker.ts queue() delegation (design §1d; spec/02 §7).
|
|
551
|
+
*
|
|
552
|
+
* Resolves Queue bindings off the request env per call (never stored — SB4).
|
|
553
|
+
* Emits `queue:message` for observability after each consumed message (F8).
|
|
554
|
+
*
|
|
555
|
+
* @param ctx - Plugin context (own config + require + emit).
|
|
556
|
+
* @returns The queues API surface: send, sendBatch, consume, deployManifest.
|
|
557
|
+
* @example
|
|
558
|
+
* ```ts
|
|
559
|
+
* // Worker entry (design §1d)
|
|
560
|
+
* export default {
|
|
561
|
+
* queue: (b, e, c) => app.queues.consume(b, e, c),
|
|
562
|
+
* };
|
|
563
|
+
* ```
|
|
564
|
+
*/
|
|
565
|
+
const createQueuesApi = (ctx) => {
|
|
566
|
+
/**
|
|
567
|
+
* Resolves a named Queue binding from the per-request env.
|
|
568
|
+
* Throws a [moku-worker]-prefixed error when the binding is absent.
|
|
569
|
+
*
|
|
570
|
+
* @param env - Per-request Cloudflare bindings.
|
|
571
|
+
* @param name - Queue binding name.
|
|
572
|
+
* @returns The resolved Queue instance.
|
|
573
|
+
* @example
|
|
574
|
+
* ```ts
|
|
575
|
+
* const q = queue(env, "ORDERS");
|
|
576
|
+
* ```
|
|
577
|
+
*/
|
|
578
|
+
const queue = (env, name) => ctx.require(bindingsPlugin).require(env, name);
|
|
579
|
+
return {
|
|
580
|
+
/**
|
|
581
|
+
* Enqueue a single message onto the named queue.
|
|
582
|
+
*
|
|
583
|
+
* Resolves the Queue binding fresh from `env` on every call (SB4).
|
|
584
|
+
* Request/response work → api method, never emit (F8).
|
|
585
|
+
*
|
|
586
|
+
* @param env - Per-request Cloudflare bindings object.
|
|
587
|
+
* @param q - Target queue binding name in `env`.
|
|
588
|
+
* @param body - Message body to enqueue.
|
|
589
|
+
* @returns Resolves once the message is enqueued.
|
|
590
|
+
* @throws {Error} With a `[moku-worker]` prefix if the binding is missing.
|
|
591
|
+
* @example
|
|
592
|
+
* ```ts
|
|
593
|
+
* await app.queues.send(env, "ORDERS", { orderId: "123" });
|
|
594
|
+
* ```
|
|
595
|
+
*/
|
|
596
|
+
send: async (env, q, body) => {
|
|
597
|
+
await queue(env, q).send(body);
|
|
598
|
+
},
|
|
599
|
+
/**
|
|
600
|
+
* Enqueue many messages in one call; each element becomes one message.
|
|
601
|
+
*
|
|
602
|
+
* Maps each body to `{ body }` before calling `Queue.sendBatch` (design §4.3).
|
|
603
|
+
*
|
|
604
|
+
* @param env - Per-request Cloudflare bindings object.
|
|
605
|
+
* @param q - Target queue binding name in `env`.
|
|
606
|
+
* @param bodies - Array of message bodies; each becomes one message.
|
|
607
|
+
* @returns Resolves once all messages are enqueued.
|
|
608
|
+
* @throws {Error} With a `[moku-worker]` prefix if the binding is missing.
|
|
609
|
+
* @example
|
|
610
|
+
* ```ts
|
|
611
|
+
* await app.queues.sendBatch(env, "ORDERS", orders);
|
|
612
|
+
* ```
|
|
613
|
+
*/
|
|
614
|
+
sendBatch: async (env, q, bodies) => {
|
|
615
|
+
await queue(env, q).sendBatch(bodies.map((body) => ({ body })));
|
|
616
|
+
},
|
|
617
|
+
/**
|
|
618
|
+
* Consumer dispatch — the Worker's `queue()` export delegates here.
|
|
619
|
+
*
|
|
620
|
+
* Iterates `batch.messages`, **awaits** `config.onMessage(message, env)` per message
|
|
621
|
+
* (so Cloudflare gets a settled promise and the handler controls ack/retry; F8,
|
|
622
|
+
* spec/07 §3 — never emit for awaited work), then fire-and-forget emits `queue:message`
|
|
623
|
+
* for observability. Returns a promise the Worker **must** await so the isolate is not
|
|
624
|
+
* killed mid-batch.
|
|
625
|
+
*
|
|
626
|
+
* @param batch - The incoming message batch from Cloudflare.
|
|
627
|
+
* @param env - Per-request Cloudflare bindings object.
|
|
628
|
+
* @param _exec - waitUntil / passThroughOnException (reserved for future use).
|
|
629
|
+
* @returns Resolves after all messages in the batch are processed.
|
|
630
|
+
* @throws {Error} Re-throws any error from `config.onMessage` so Cloudflare can retry.
|
|
631
|
+
* @example
|
|
632
|
+
* ```ts
|
|
633
|
+
* // Worker entry
|
|
634
|
+
* queue: (b, e, c) => app.queues.consume(b, e, c),
|
|
635
|
+
* ```
|
|
636
|
+
*/
|
|
637
|
+
consume: async (batch, env, _exec) => {
|
|
638
|
+
for (const m of batch.messages) {
|
|
639
|
+
await ctx.config.onMessage(m, env);
|
|
640
|
+
ctx.emit("queue:message", {
|
|
641
|
+
queue: batch.queue,
|
|
642
|
+
messageId: m.id
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
/**
|
|
647
|
+
* Returns this plugin's deploy metadata, read by the deploy plugin via
|
|
648
|
+
* `ctx.require(queuesPlugin).deployManifest()` (F6 — never reads sibling config).
|
|
649
|
+
*
|
|
650
|
+
* @returns Deploy manifest entry `{ kind: "queue", producers }`.
|
|
651
|
+
* @example
|
|
652
|
+
* ```ts
|
|
653
|
+
* const manifest = ctx.require(queuesPlugin).deployManifest();
|
|
654
|
+
* // → { kind: "queue", producers: ["orders"] }
|
|
655
|
+
* ```
|
|
656
|
+
*/
|
|
657
|
+
deployManifest: () => ({
|
|
658
|
+
kind: "queue",
|
|
659
|
+
producers: ctx.config.producers
|
|
660
|
+
})
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
/**
|
|
664
|
+
* Standard tier — Cloudflare Queues producer + consumer dispatch.
|
|
665
|
+
*
|
|
666
|
+
* `events` is declared first and via `register.map<QueueEvents>` so the plugin's own events infer
|
|
667
|
+
* into the factory context; the api wiring is therefore arrow-wrapped (contextually typed).
|
|
668
|
+
*
|
|
669
|
+
* Emits the plugin-local `queue:message` event after each consumed message.
|
|
670
|
+
*
|
|
671
|
+
* @see README.md
|
|
672
|
+
*/
|
|
673
|
+
const queuesPlugin = createPlugin("queues", {
|
|
674
|
+
events: (register) => register.map({ "queue:message": "A queue message was processed" }),
|
|
675
|
+
depends: [bindingsPlugin],
|
|
676
|
+
config: {
|
|
677
|
+
producers: [],
|
|
678
|
+
onMessage: async () => {}
|
|
679
|
+
},
|
|
680
|
+
api: (ctx) => createQueuesApi(ctx)
|
|
681
|
+
});
|
|
682
|
+
//#endregion
|
|
683
|
+
//#region src/plugins/storage/providers/r2.ts
|
|
684
|
+
/**
|
|
685
|
+
* Build a StorageProvider backed by the real R2Bucket resolved off the
|
|
686
|
+
* per-request env via the bindings plugin. The bucket is resolved fresh on
|
|
687
|
+
* EVERY method call — never cached, so concurrent requests stay isolated
|
|
688
|
+
* (worker-api-design SB4; spec/08 §6).
|
|
689
|
+
*
|
|
690
|
+
* Each method is `async` so that synchronous throws from `bindings.require`
|
|
691
|
+
* (e.g. missing binding) are automatically wrapped in rejected Promises —
|
|
692
|
+
* callers can always use `await` / `.catch` instead of `try/catch`.
|
|
693
|
+
*
|
|
694
|
+
* @param bindings - The bindings plugin API (provides `require<T>`).
|
|
695
|
+
* @param env - The per-request Cloudflare bindings object.
|
|
696
|
+
* @param bucket - The R2 bucket binding name (e.g. "ASSETS").
|
|
697
|
+
* @returns {StorageProvider} A provider that delegates to the resolved R2Bucket.
|
|
698
|
+
* @example
|
|
699
|
+
* ```typescript
|
|
700
|
+
* const provider = resolveR2Provider(ctx.require(bindingsPlugin), env, ctx.config.bucket);
|
|
701
|
+
* const body = await provider.get("my-object");
|
|
702
|
+
* ```
|
|
703
|
+
*/
|
|
704
|
+
const resolveR2Provider = (bindings, env, bucket) => {
|
|
705
|
+
/**
|
|
706
|
+
* Resolve the R2Bucket for this request's env. Throws on missing binding.
|
|
707
|
+
*
|
|
708
|
+
* @returns {R2Bucket} The resolved R2Bucket binding.
|
|
709
|
+
* @example
|
|
710
|
+
* ```typescript
|
|
711
|
+
* const bucket = b();
|
|
712
|
+
* ```
|
|
713
|
+
*/
|
|
714
|
+
const b = () => bindings.require(env, bucket);
|
|
715
|
+
return {
|
|
716
|
+
/**
|
|
717
|
+
* Read an object from the bucket.
|
|
718
|
+
*
|
|
719
|
+
* @param key - The object key.
|
|
720
|
+
* @returns {Promise<R2ObjectBody | null>} The R2ObjectBody, or null if the key is absent.
|
|
721
|
+
* @example
|
|
722
|
+
* ```typescript
|
|
723
|
+
* const body = await provider.get("assets/logo.png");
|
|
724
|
+
* ```
|
|
725
|
+
*/
|
|
726
|
+
async get(key) {
|
|
727
|
+
return b().get(key);
|
|
728
|
+
},
|
|
729
|
+
/**
|
|
730
|
+
* Write an object to the bucket.
|
|
731
|
+
*
|
|
732
|
+
* @param key - The object key.
|
|
733
|
+
* @param value - The object contents (any R2-accepted type).
|
|
734
|
+
* @returns {Promise<R2Object>} The R2Object metadata for the written object.
|
|
735
|
+
* @example
|
|
736
|
+
* ```typescript
|
|
737
|
+
* const obj = await provider.put("assets/logo.png", buffer);
|
|
738
|
+
* ```
|
|
739
|
+
*/
|
|
740
|
+
async put(key, value) {
|
|
741
|
+
return b().put(key, value);
|
|
742
|
+
},
|
|
743
|
+
/**
|
|
744
|
+
* Remove one or more objects from the bucket. No-op when a key is absent.
|
|
745
|
+
*
|
|
746
|
+
* @param key - A single key or array of keys to remove.
|
|
747
|
+
* @returns {Promise<void>} Resolves once removed.
|
|
748
|
+
* @example
|
|
749
|
+
* ```typescript
|
|
750
|
+
* await provider.delete("assets/old.png");
|
|
751
|
+
* ```
|
|
752
|
+
*/
|
|
753
|
+
async delete(key) {
|
|
754
|
+
return b().delete(key);
|
|
755
|
+
},
|
|
756
|
+
/**
|
|
757
|
+
* List objects, optionally filtered by R2ListOptions.
|
|
758
|
+
*
|
|
759
|
+
* @param opts - Optional list options (prefix, limit, cursor, delimiter).
|
|
760
|
+
* @returns {Promise<R2Objects>} The R2Objects list result.
|
|
761
|
+
* @example
|
|
762
|
+
* ```typescript
|
|
763
|
+
* const { objects } = await provider.list({ prefix: "images/" });
|
|
764
|
+
* ```
|
|
765
|
+
*/
|
|
766
|
+
async list(opts) {
|
|
767
|
+
return b().list(opts);
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
};
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/plugins/storage/api.ts
|
|
773
|
+
/**
|
|
774
|
+
* Build the env-first storage API. Each runtime method resolves the bucket
|
|
775
|
+
* provider fresh from the per-request `env` — nothing is stored, so concurrent
|
|
776
|
+
* requests stay isolated (worker-api-design SB4; spec/08 §6,§7).
|
|
777
|
+
*
|
|
778
|
+
* The `deployManifest()` method is build-time only: it reads from `ctx.config`
|
|
779
|
+
* and never touches `env` or R2.
|
|
780
|
+
*
|
|
781
|
+
* @param ctx - Plugin context (config + require for bindings resolution).
|
|
782
|
+
* @returns {StorageApi} The env-first storage API surface.
|
|
783
|
+
* @example
|
|
784
|
+
* ```typescript
|
|
785
|
+
* const api = createStorageApi(ctx);
|
|
786
|
+
* const body = await api.get(env, "my-object");
|
|
787
|
+
* ```
|
|
788
|
+
*/
|
|
789
|
+
const createStorageApi = (ctx) => {
|
|
790
|
+
/**
|
|
791
|
+
* Resolve the StorageProvider for the given per-request env. Called on every
|
|
792
|
+
* method invocation — the bucket binding is never cached across calls.
|
|
793
|
+
*
|
|
794
|
+
* @param env - The per-request Cloudflare bindings object.
|
|
795
|
+
* @returns {StorageProvider} A StorageProvider delegating to the resolved R2Bucket.
|
|
796
|
+
* @example
|
|
797
|
+
* ```typescript
|
|
798
|
+
* const p = provider(env);
|
|
799
|
+
* const body = await p.get("key");
|
|
800
|
+
* ```
|
|
801
|
+
*/
|
|
802
|
+
const provider = (env) => resolveR2Provider(ctx.require(bindingsPlugin), env, ctx.config.bucket);
|
|
803
|
+
return {
|
|
804
|
+
/**
|
|
805
|
+
* Read an object from the bucket; resolves null when the key is absent.
|
|
806
|
+
*
|
|
807
|
+
* @param env - Per-request Cloudflare bindings.
|
|
808
|
+
* @param key - Object key.
|
|
809
|
+
* @returns {Promise<R2ObjectBody | null>} The R2ObjectBody, or null.
|
|
810
|
+
* @example
|
|
811
|
+
* ```typescript
|
|
812
|
+
* const body = await api.get(env, "assets/logo.png");
|
|
813
|
+
* ```
|
|
814
|
+
*/
|
|
815
|
+
get: (env, key) => provider(env).get(key),
|
|
816
|
+
/**
|
|
817
|
+
* Write an object to the bucket.
|
|
818
|
+
*
|
|
819
|
+
* @param env - Per-request Cloudflare bindings.
|
|
820
|
+
* @param key - Object key.
|
|
821
|
+
* @param value - Object contents (ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null).
|
|
822
|
+
* @returns {Promise<R2Object>} The R2Object metadata for the written object.
|
|
823
|
+
* @example
|
|
824
|
+
* ```typescript
|
|
825
|
+
* const obj = await api.put(env, "assets/logo.png", buffer);
|
|
826
|
+
* ```
|
|
827
|
+
*/
|
|
828
|
+
put: (env, key, value) => provider(env).put(key, value),
|
|
829
|
+
/**
|
|
830
|
+
* Remove an object (or array of keys) from the bucket. No-op when absent.
|
|
831
|
+
*
|
|
832
|
+
* @param env - Per-request Cloudflare bindings.
|
|
833
|
+
* @param key - Object key or array of keys.
|
|
834
|
+
* @returns {Promise<void>} Resolves once removed.
|
|
835
|
+
* @example
|
|
836
|
+
* ```typescript
|
|
837
|
+
* await api.delete(env, "assets/old.png");
|
|
838
|
+
* ```
|
|
839
|
+
*/
|
|
840
|
+
delete: (env, key) => provider(env).delete(key),
|
|
841
|
+
/**
|
|
842
|
+
* List objects, optionally filtered by R2ListOptions.
|
|
843
|
+
*
|
|
844
|
+
* @param env - Per-request Cloudflare bindings.
|
|
845
|
+
* @param opts - Optional R2ListOptions (prefix, limit, cursor, delimiter).
|
|
846
|
+
* @returns {Promise<R2Objects>} The R2Objects list result.
|
|
847
|
+
* @example
|
|
848
|
+
* ```typescript
|
|
849
|
+
* const { objects } = await api.list(env, { prefix: "images/" });
|
|
850
|
+
* ```
|
|
851
|
+
*/
|
|
852
|
+
list: (env, opts) => provider(env).list(opts),
|
|
853
|
+
/**
|
|
854
|
+
* Return this plugin's deploy metadata. Build-time only — does not touch
|
|
855
|
+
* `env` or R2. The deploy plugin reads this via `ctx.require(storagePlugin).deployManifest()`.
|
|
856
|
+
*
|
|
857
|
+
* @returns {StorageManifest} Deploy manifest entry `{ kind: "r2", bucket, upload }`.
|
|
858
|
+
* @example
|
|
859
|
+
* ```typescript
|
|
860
|
+
* const manifest = api.deployManifest();
|
|
861
|
+
* // { kind: "r2", bucket: "ASSETS", upload: "./public" }
|
|
862
|
+
* ```
|
|
863
|
+
*/
|
|
864
|
+
deployManifest: () => ({
|
|
865
|
+
kind: "r2",
|
|
866
|
+
bucket: ctx.config.bucket,
|
|
867
|
+
upload: ctx.config.upload
|
|
868
|
+
})
|
|
869
|
+
};
|
|
870
|
+
};
|
|
871
|
+
/**
|
|
872
|
+
* Complex tier — Cloudflare R2 object storage behind a provider adapter seam.
|
|
873
|
+
*
|
|
874
|
+
* Exposes `get`, `put`, `delete`, `list` (all env-first) and `deployManifest()`
|
|
875
|
+
* (build-time). Depends on `bindingsPlugin` to resolve the `R2Bucket` binding
|
|
876
|
+
* per request. No state, no events, no lifecycle hooks.
|
|
877
|
+
*
|
|
878
|
+
* @see README.md
|
|
879
|
+
*/
|
|
880
|
+
const storagePlugin = createPlugin("storage", {
|
|
881
|
+
depends: [bindingsPlugin],
|
|
882
|
+
config: {
|
|
883
|
+
upload: "",
|
|
884
|
+
bucket: "ASSETS"
|
|
885
|
+
},
|
|
886
|
+
api: createStorageApi
|
|
887
|
+
});
|
|
888
|
+
//#endregion
|
|
889
|
+
//#region src/plugins/deploy/auth/permissions.ts
|
|
890
|
+
/** Permission groups every deploy needs, regardless of resources. */
|
|
891
|
+
const ALWAYS = [{
|
|
892
|
+
group: "Account · Workers Scripts",
|
|
893
|
+
scope: "Edit",
|
|
894
|
+
reason: "deploy",
|
|
895
|
+
inBaseTemplate: true
|
|
896
|
+
}, {
|
|
897
|
+
group: "Account · Account Settings",
|
|
898
|
+
scope: "Read",
|
|
899
|
+
reason: "account",
|
|
900
|
+
inBaseTemplate: true
|
|
901
|
+
}];
|
|
902
|
+
/**
|
|
903
|
+
* Per-resource-kind permission group. `do` needs nothing extra (Durable Objects ship with the
|
|
904
|
+
* Worker script, covered by Workers Scripts · Edit). `d1`/`queue` are NOT in the stock template.
|
|
905
|
+
*/
|
|
906
|
+
const BY_KIND = {
|
|
907
|
+
kv: {
|
|
908
|
+
group: "Account · Workers KV Storage",
|
|
909
|
+
scope: "Edit",
|
|
910
|
+
reason: "kv",
|
|
911
|
+
inBaseTemplate: true
|
|
912
|
+
},
|
|
913
|
+
r2: {
|
|
914
|
+
group: "Account · Workers R2 Storage",
|
|
915
|
+
scope: "Edit",
|
|
916
|
+
reason: "r2",
|
|
917
|
+
inBaseTemplate: true
|
|
918
|
+
},
|
|
919
|
+
d1: {
|
|
920
|
+
group: "Account · D1",
|
|
921
|
+
scope: "Edit",
|
|
922
|
+
reason: "d1",
|
|
923
|
+
inBaseTemplate: false
|
|
924
|
+
},
|
|
925
|
+
queue: {
|
|
926
|
+
group: "Account · Queues",
|
|
927
|
+
scope: "Edit",
|
|
928
|
+
reason: "queue",
|
|
929
|
+
inBaseTemplate: false
|
|
930
|
+
},
|
|
931
|
+
do: void 0
|
|
932
|
+
};
|
|
933
|
+
/**
|
|
934
|
+
* Derive the Cloudflare API token requirement from an app manifest: the full permission set plus
|
|
935
|
+
* the subset that must be ADDED to the stock "Edit Cloudflare Workers" template.
|
|
936
|
+
*
|
|
937
|
+
* @param manifest - The assembled deploy manifest.
|
|
938
|
+
* @returns The token requirement (base template, full required set, and groups to add).
|
|
939
|
+
* @example
|
|
940
|
+
* ```ts
|
|
941
|
+
* const { toAdd } = requiredToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
|
|
942
|
+
* // toAdd → [{ group: "Account · D1", scope: "Edit", … }]
|
|
943
|
+
* ```
|
|
944
|
+
*/
|
|
945
|
+
const requiredToken = (manifest) => {
|
|
946
|
+
const required = [...ALWAYS];
|
|
947
|
+
const seen = new Set(required.map((permission) => permission.group));
|
|
948
|
+
for (const resource of manifest.resources) {
|
|
949
|
+
const permission = BY_KIND[resource.kind];
|
|
950
|
+
if (permission !== void 0 && !seen.has(permission.group)) {
|
|
951
|
+
required.push(permission);
|
|
952
|
+
seen.add(permission.group);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
base: "Edit Cloudflare Workers",
|
|
957
|
+
required,
|
|
958
|
+
toAdd: required.filter((permission) => !permission.inBaseTemplate)
|
|
959
|
+
};
|
|
960
|
+
};
|
|
961
|
+
/** Permission every CI/automation redeploy needs: ship the Worker script. */
|
|
962
|
+
const CI_ALWAYS = [{
|
|
963
|
+
group: "Account · Workers Scripts",
|
|
964
|
+
scope: "Edit",
|
|
965
|
+
reason: "deploy",
|
|
966
|
+
inBaseTemplate: true
|
|
967
|
+
}];
|
|
968
|
+
/**
|
|
969
|
+
* Per-resource-kind permission for the CI/automation token. After a first LOCAL deploy has
|
|
970
|
+
* provisioned everything, CI only needs to LIST existing infra (the idempotent preflight) and
|
|
971
|
+
* ship — so data resources drop to `Read`; R2 stays `Edit` because asset upload writes objects.
|
|
972
|
+
*/
|
|
973
|
+
const CI_BY_KIND = {
|
|
974
|
+
kv: {
|
|
975
|
+
group: "Account · Workers KV Storage",
|
|
976
|
+
scope: "Read",
|
|
977
|
+
reason: "kv (preflight)",
|
|
978
|
+
inBaseTemplate: true
|
|
979
|
+
},
|
|
980
|
+
r2: {
|
|
981
|
+
group: "Account · Workers R2 Storage",
|
|
982
|
+
scope: "Edit",
|
|
983
|
+
reason: "r2 (asset upload)",
|
|
984
|
+
inBaseTemplate: true
|
|
985
|
+
},
|
|
986
|
+
d1: {
|
|
987
|
+
group: "Account · D1",
|
|
988
|
+
scope: "Read",
|
|
989
|
+
reason: "d1 (preflight)",
|
|
990
|
+
inBaseTemplate: false
|
|
991
|
+
},
|
|
992
|
+
queue: {
|
|
993
|
+
group: "Account · Queues",
|
|
994
|
+
scope: "Read",
|
|
995
|
+
reason: "queue (preflight)",
|
|
996
|
+
inBaseTemplate: false
|
|
997
|
+
},
|
|
998
|
+
do: void 0
|
|
999
|
+
};
|
|
1000
|
+
/**
|
|
1001
|
+
* Derive the REDUCED Cloudflare API token for CI/automation redeploys, from the same manifest.
|
|
1002
|
+
* Assumes a prior LOCAL deploy already provisioned the infra, so CI never creates: data resources
|
|
1003
|
+
* need only `Read` (the idempotent preflight lists them), R2 keeps `Edit` for asset upload, and no
|
|
1004
|
+
* `Account Settings · Read` is needed because CI pins `CLOUDFLARE_ACCOUNT_ID`. Pure: no network.
|
|
1005
|
+
*
|
|
1006
|
+
* @param manifest - The assembled deploy manifest.
|
|
1007
|
+
* @returns The minimum permission groups for a CI redeploy token (deduped, manifest-scoped).
|
|
1008
|
+
* @example
|
|
1009
|
+
* ```ts
|
|
1010
|
+
* const groups = ciToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
|
|
1011
|
+
* // → [Workers Scripts·Edit, D1·Read]
|
|
1012
|
+
* ```
|
|
1013
|
+
*/
|
|
1014
|
+
const ciToken = (manifest) => {
|
|
1015
|
+
const groups = [...CI_ALWAYS];
|
|
1016
|
+
const seen = new Set(groups.map((permission) => permission.group));
|
|
1017
|
+
for (const resource of manifest.resources) {
|
|
1018
|
+
const permission = CI_BY_KIND[resource.kind];
|
|
1019
|
+
if (permission !== void 0 && !seen.has(permission.group)) {
|
|
1020
|
+
groups.push(permission);
|
|
1021
|
+
seen.add(permission.group);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return groups;
|
|
1025
|
+
};
|
|
1026
|
+
//#endregion
|
|
1027
|
+
//#region src/plugins/deploy/auth/setup.ts
|
|
1028
|
+
/** Cloudflare's dashboard path for creating API tokens. */
|
|
1029
|
+
const TOKENS_URL = "https://dash.cloudflare.com/profile/api-tokens";
|
|
1030
|
+
/**
|
|
1031
|
+
* Render the FULL local-first token section (the deploy that provisions everything): the permission
|
|
1032
|
+
* table flagging template-missing rows, the template + "add these" steps, and the `.env.local` lines.
|
|
1033
|
+
*
|
|
1034
|
+
* @param requirement - The full token requirement (from requiredToken()).
|
|
1035
|
+
* @returns The local-first section lines.
|
|
1036
|
+
* @example
|
|
1037
|
+
* ```ts
|
|
1038
|
+
* const lines = localSection(requiredToken(manifest));
|
|
1039
|
+
* ```
|
|
1040
|
+
*/
|
|
1041
|
+
const localSection = (requirement) => {
|
|
1042
|
+
const permissionRows = requirement.required.map((permission) => {
|
|
1043
|
+
const flag = permission.inBaseTemplate ? "" : " <- add to template";
|
|
1044
|
+
return ` - ${permission.group} : ${permission.scope} (${permission.reason})${flag}`;
|
|
1045
|
+
});
|
|
1046
|
+
const step3 = requirement.toAdd.length > 0 ? [` 3. Under Permissions, ADD: ${requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} -> ${permission.scope}`).join(", ")}`, " (the template omits these; everything else is already included)"] : [` 3. The "${requirement.base}" template covers everything — no changes needed.`];
|
|
1047
|
+
return [
|
|
1048
|
+
"LOCAL — first deploy (provisions infra). A Cloudflare API token with these permissions:",
|
|
1049
|
+
"",
|
|
1050
|
+
...permissionRows,
|
|
1051
|
+
"",
|
|
1052
|
+
"Fastest path:",
|
|
1053
|
+
` 1. ${TOKENS_URL} -> Create Token`,
|
|
1054
|
+
` 2. Start from the "${requirement.base}" template.`,
|
|
1055
|
+
...step3,
|
|
1056
|
+
" 4. Account Resources -> Include -> your account.",
|
|
1057
|
+
" 5. Create the token, copy it, then add it to .env.local:",
|
|
1058
|
+
" CLOUDFLARE_API_TOKEN=<paste your token>",
|
|
1059
|
+
" CLOUDFLARE_ACCOUNT_ID=<your account id>",
|
|
1060
|
+
" 6. Verify it with `auth` (app.deploy.verifyAuth())."
|
|
1061
|
+
];
|
|
1062
|
+
};
|
|
1063
|
+
/**
|
|
1064
|
+
* Render the REDUCED CI/automation token section (redeploy-only): the scoped permission table plus
|
|
1065
|
+
* the CI-secret + account-pin steps.
|
|
1066
|
+
*
|
|
1067
|
+
* @param groups - The CI permission groups (from ciToken()).
|
|
1068
|
+
* @returns The CI section lines.
|
|
1069
|
+
* @example
|
|
1070
|
+
* ```ts
|
|
1071
|
+
* const lines = ciSection(ciToken(manifest));
|
|
1072
|
+
* ```
|
|
1073
|
+
*/
|
|
1074
|
+
const ciSection = (groups) => {
|
|
1075
|
+
return [
|
|
1076
|
+
"CI — automation redeploy (infra already provisioned by a local deploy). A SCOPED token with:",
|
|
1077
|
+
"",
|
|
1078
|
+
...groups.map((permission) => ` - ${permission.group} : ${permission.scope} (${permission.reason})`),
|
|
1079
|
+
"",
|
|
1080
|
+
` 1. ${TOKENS_URL} -> Create Token -> Create Custom Token.`,
|
|
1081
|
+
" 2. Add exactly the permissions above (Read, not Edit, on data resources — CI never creates).",
|
|
1082
|
+
" 3. Account Resources -> Include -> your account.",
|
|
1083
|
+
" 4. Store it as the CLOUDFLARE_API_TOKEN secret in CI, and PIN the account so no account",
|
|
1084
|
+
" lookup (and no Account Settings -> Read) is needed:",
|
|
1085
|
+
" CLOUDFLARE_ACCOUNT_ID=<your account id>",
|
|
1086
|
+
" CI reuses the same idempotent pipeline — it lists existing infra and ships. To let CI also",
|
|
1087
|
+
" CREATE missing infra (self-heal), give it the LOCAL token above instead."
|
|
1088
|
+
];
|
|
1089
|
+
};
|
|
1090
|
+
/**
|
|
1091
|
+
* Render the `auth setup` instructions from the app manifest: the FULL local-first token (provisions
|
|
1092
|
+
* everything) followed by the REDUCED CI/automation token (redeploy-only).
|
|
1093
|
+
*
|
|
1094
|
+
* @param manifest - The assembled deploy manifest.
|
|
1095
|
+
* @returns A multi-line instruction string covering both tokens.
|
|
1096
|
+
* @example
|
|
1097
|
+
* ```ts
|
|
1098
|
+
* const text = tokenInstructions(manifest);
|
|
1099
|
+
* ```
|
|
1100
|
+
*/
|
|
1101
|
+
const tokenInstructions = (manifest) => [
|
|
1102
|
+
...localSection(requiredToken(manifest)),
|
|
1103
|
+
"",
|
|
1104
|
+
...ciSection(ciToken(manifest))
|
|
1105
|
+
].join("\n");
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/plugins/deploy/infra/cloudflare.ts
|
|
1108
|
+
/**
|
|
1109
|
+
* @file deploy plugin — Cloudflare REST discovery client (infra preflight).
|
|
1110
|
+
*
|
|
1111
|
+
* Lists what already exists in a Cloudflare account so the deploy pipeline can create only the
|
|
1112
|
+
* missing resources (idempotent provisioning) and recover real ids for existing kv/d1 bindings.
|
|
1113
|
+
* Authenticated with the `.env` API token (CLOUDFLARE_API_TOKEN) — never an interactive login.
|
|
1114
|
+
* Uses the global `fetch`; node-only, never imported by the runtime Worker bundle.
|
|
1115
|
+
*/
|
|
1116
|
+
const API_BASE = "https://api.cloudflare.com/client/v4";
|
|
1117
|
+
/**
|
|
1118
|
+
* GET a Cloudflare API path with the bearer token and unwrap the `result`.
|
|
1119
|
+
*
|
|
1120
|
+
* @param token - The Cloudflare API token (CLOUDFLARE_API_TOKEN).
|
|
1121
|
+
* @param path - API path beneath the v4 base (e.g. "/accounts").
|
|
1122
|
+
* @returns The unwrapped `result` payload, typed by the caller.
|
|
1123
|
+
* @throws {Error} When the HTTP request fails or the API reports `success: false`.
|
|
1124
|
+
* @example
|
|
1125
|
+
* ```ts
|
|
1126
|
+
* const accounts = await cfGet<Array<{ id: string }>>(token, "/accounts");
|
|
1127
|
+
* ```
|
|
1128
|
+
*/
|
|
1129
|
+
const cfGet = async (token, path) => {
|
|
1130
|
+
const response = await fetch(`${API_BASE}${path}`, { headers: {
|
|
1131
|
+
Authorization: `Bearer ${token}`,
|
|
1132
|
+
"Content-Type": "application/json"
|
|
1133
|
+
} });
|
|
1134
|
+
const body = await response.json();
|
|
1135
|
+
if (!response.ok || !body.success) {
|
|
1136
|
+
const detail = body.errors?.map((error) => error.message).join("; ") || `HTTP ${response.status}`;
|
|
1137
|
+
throw new Error(`[moku-worker] Cloudflare API request failed (${path}): ${detail}`);
|
|
1138
|
+
}
|
|
1139
|
+
return body.result;
|
|
1140
|
+
};
|
|
1141
|
+
/**
|
|
1142
|
+
* Resolve the Cloudflare account (id + display name) accessible to the token. Used when the
|
|
1143
|
+
* consumer did not pin CLOUDFLARE_ACCOUNT_ID; the first accessible account is chosen.
|
|
1144
|
+
*
|
|
1145
|
+
* @param token - The Cloudflare API token.
|
|
1146
|
+
* @returns The resolved account id and name.
|
|
1147
|
+
* @throws {Error} When the token can access no account.
|
|
1148
|
+
* @example
|
|
1149
|
+
* ```ts
|
|
1150
|
+
* const { id, name } = await resolveAccount(token);
|
|
1151
|
+
* ```
|
|
1152
|
+
*/
|
|
1153
|
+
const resolveAccount = async (token) => {
|
|
1154
|
+
const first = (await cfGet(token, "/accounts"))[0];
|
|
1155
|
+
if (!first) throw new Error("[moku-worker] No Cloudflare account is accessible with this API token.");
|
|
1156
|
+
return {
|
|
1157
|
+
id: first.id,
|
|
1158
|
+
name: first.name
|
|
1159
|
+
};
|
|
1160
|
+
};
|
|
1161
|
+
/**
|
|
1162
|
+
* Verify a Cloudflare API token via `GET /user/tokens/verify`. Returns its status (`"active"` for
|
|
1163
|
+
* a usable token); throws (via cfGet) when the token is rejected outright (401/invalid).
|
|
1164
|
+
*
|
|
1165
|
+
* @param token - The Cloudflare API token to verify.
|
|
1166
|
+
* @returns The token status string reported by Cloudflare.
|
|
1167
|
+
* @throws {Error} When the verify request fails (invalid/expired token).
|
|
1168
|
+
* @example
|
|
1169
|
+
* ```ts
|
|
1170
|
+
* const { status } = await verifyToken(token); // status === "active"
|
|
1171
|
+
* ```
|
|
1172
|
+
*/
|
|
1173
|
+
const verifyToken = async (token) => {
|
|
1174
|
+
return { status: (await cfGet(token, "/user/tokens/verify")).status };
|
|
1175
|
+
};
|
|
1176
|
+
/**
|
|
1177
|
+
* List the resources that already exist in the account, querying ONLY the kinds the app declares
|
|
1178
|
+
* (one request per declared kind, in parallel), indexed for the preflight diff. Scoping to the
|
|
1179
|
+
* declared kinds keeps the API token minimal — an app with only KV never lists (and so never needs
|
|
1180
|
+
* read permission on) D1, R2, or Queues.
|
|
1181
|
+
*
|
|
1182
|
+
* @param token - The Cloudflare API token.
|
|
1183
|
+
* @param accountId - The Cloudflare account id to scope the listings to.
|
|
1184
|
+
* @param kinds - The resource kinds present in the manifest (the only kinds queried).
|
|
1185
|
+
* @returns The existing resources, indexed by kind (un-queried kinds resolve empty).
|
|
1186
|
+
* @throws {Error} When any listing request fails.
|
|
1187
|
+
* @example
|
|
1188
|
+
* ```ts
|
|
1189
|
+
* const existing = await listExisting(token, accountId, new Set(["kv", "d1"]));
|
|
1190
|
+
* if (existing.kv.has("SESSIONS")) { ... }
|
|
1191
|
+
* ```
|
|
1192
|
+
*/
|
|
1193
|
+
const listExisting = async (token, accountId, kinds) => {
|
|
1194
|
+
const base = `/accounts/${accountId}`;
|
|
1195
|
+
const [kv, d1, r2, queues] = await Promise.all([
|
|
1196
|
+
kinds.has("kv") ? cfGet(token, `${base}/storage/kv/namespaces`) : Promise.resolve([]),
|
|
1197
|
+
kinds.has("d1") ? cfGet(token, `${base}/d1/database`) : Promise.resolve([]),
|
|
1198
|
+
kinds.has("r2") ? cfGet(token, `${base}/r2/buckets`) : Promise.resolve({}),
|
|
1199
|
+
kinds.has("queue") ? cfGet(token, `${base}/queues`) : Promise.resolve([])
|
|
1200
|
+
]);
|
|
1201
|
+
return {
|
|
1202
|
+
kv: new Map(kv.map((namespace) => [namespace.title, namespace.id])),
|
|
1203
|
+
d1: new Map(d1.map((database) => [database.name, database.uuid])),
|
|
1204
|
+
r2: new Set((r2.buckets ?? []).map((bucket) => bucket.name)),
|
|
1205
|
+
queue: new Set(queues.map((queue) => queue.queue_name))
|
|
1206
|
+
};
|
|
1207
|
+
};
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/plugins/deploy/auth/verify.ts
|
|
1210
|
+
/**
|
|
1211
|
+
* @file deploy plugin — `.env` token verification + account resolution.
|
|
1212
|
+
*
|
|
1213
|
+
* Reads CLOUDFLARE_API_TOKEN via ctx.env, verifies it is active against the Cloudflare API, and
|
|
1214
|
+
* resolves the account. Emits auth:verified. Throws a branded, actionable error (pointing at
|
|
1215
|
+
* `auth setup`) when the token is absent, invalid, or inactive — never an interactive login.
|
|
1216
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
1217
|
+
*/
|
|
1218
|
+
/** Branded hint appended to every auth failure so the user knows the next step. */
|
|
1219
|
+
const SETUP_HINT = "Run `auth setup` for the exact token to create.";
|
|
1220
|
+
/**
|
|
1221
|
+
* Verify the `.env` Cloudflare API token and resolve its account.
|
|
1222
|
+
*
|
|
1223
|
+
* @param ctx - The deploy plugin context (env + emit).
|
|
1224
|
+
* @returns The verified auth status (account + id).
|
|
1225
|
+
* @throws {Error} When the token is absent, invalid/expired, or not active.
|
|
1226
|
+
* @example
|
|
1227
|
+
* ```ts
|
|
1228
|
+
* const { account, accountId } = await verifyAuth(ctx);
|
|
1229
|
+
* ```
|
|
1230
|
+
*/
|
|
1231
|
+
const verifyAuth = async (ctx) => {
|
|
1232
|
+
const token = ctx.env.get("CLOUDFLARE_API_TOKEN");
|
|
1233
|
+
if (token === void 0 || token === "") throw new Error(`[moku-worker] CLOUDFLARE_API_TOKEN is not set. ${SETUP_HINT}`);
|
|
1234
|
+
let status;
|
|
1235
|
+
try {
|
|
1236
|
+
({status} = await verifyToken(token));
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
throw new Error(`[moku-worker] Cloudflare API token is invalid or expired. ${SETUP_HINT}`, { cause: error });
|
|
1239
|
+
}
|
|
1240
|
+
if (status !== "active") throw new Error(`[moku-worker] Cloudflare API token is "${status}", not active. ${SETUP_HINT}`);
|
|
1241
|
+
const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
|
|
1242
|
+
const account = pinnedAccountId === void 0 || pinnedAccountId === "" ? await resolveAccount(token) : {
|
|
1243
|
+
id: pinnedAccountId,
|
|
1244
|
+
name: pinnedAccountId
|
|
1245
|
+
};
|
|
1246
|
+
ctx.emit("auth:verified", {
|
|
1247
|
+
account: account.name,
|
|
1248
|
+
accountId: account.id,
|
|
1249
|
+
scopes: []
|
|
1250
|
+
});
|
|
1251
|
+
return {
|
|
1252
|
+
ok: true,
|
|
1253
|
+
account: account.name,
|
|
1254
|
+
accountId: account.id,
|
|
1255
|
+
scopes: []
|
|
1256
|
+
};
|
|
1257
|
+
};
|
|
1258
|
+
//#endregion
|
|
1259
|
+
//#region src/plugins/deploy/runner.ts
|
|
1260
|
+
/**
|
|
1261
|
+
* @file deploy plugin — wrangler subprocess wrapper (node:child_process).
|
|
1262
|
+
*
|
|
1263
|
+
* Spawns `wrangler` with the given args and resolves the deployed URL
|
|
1264
|
+
* (extracted from stdout for `wrangler deploy`), or the full stdout for other verbs.
|
|
1265
|
+
* This module is node-only; never imported by the runtime Worker bundle.
|
|
1266
|
+
*/
|
|
1267
|
+
/**
|
|
1268
|
+
* Extract the deployed URL from `wrangler deploy` stdout.
|
|
1269
|
+
* Wrangler prints a line like: "Published my-worker (1.23 sec) https://..."
|
|
1270
|
+
* or "Deployed my-worker (1.23 sec) https://...".
|
|
1271
|
+
*
|
|
1272
|
+
* @param output - The combined stdout from wrangler deploy.
|
|
1273
|
+
* @returns The deployed URL, or empty string when not found.
|
|
1274
|
+
* @example
|
|
1275
|
+
* ```ts
|
|
1276
|
+
* extractDeployedUrl("Deployed my-worker (0.5 sec) https://my-worker.workers.dev");
|
|
1277
|
+
* // "https://my-worker.workers.dev"
|
|
1278
|
+
* ```
|
|
1279
|
+
*/
|
|
1280
|
+
const extractDeployedUrl = (output) => {
|
|
1281
|
+
return /https:\/\/[^\s]+\.workers\.dev[^\s]*/u.exec(output)?.[0] ?? "";
|
|
1282
|
+
};
|
|
1283
|
+
/**
|
|
1284
|
+
* Spawn `wrangler` with the given args and resolve the output string.
|
|
1285
|
+
* For `wrangler deploy`, the resolved value is the deployed URL parsed from stdout.
|
|
1286
|
+
* For all other verbs (dev, kv namespace create, etc.), the resolved value is stdout.
|
|
1287
|
+
*
|
|
1288
|
+
* @param args - Wrangler CLI arguments (e.g. ["deploy", "--config", "wrangler.jsonc"]).
|
|
1289
|
+
* @returns Resolves with the deployed URL (deploy verb) or full stdout (other verbs).
|
|
1290
|
+
* @throws {Error} When wrangler exits with a non-zero code.
|
|
1291
|
+
* @example
|
|
1292
|
+
* ```ts
|
|
1293
|
+
* const url = await runWrangler(["deploy", "--config", "wrangler.jsonc"]);
|
|
1294
|
+
* await runWrangler(["kv", "namespace", "create", "CACHE"]);
|
|
1295
|
+
* ```
|
|
1296
|
+
*/
|
|
1297
|
+
const runWrangler = (args) => new Promise((resolve, reject) => {
|
|
1298
|
+
const chunks = [];
|
|
1299
|
+
const errChunks = [];
|
|
1300
|
+
const child = spawn("wrangler", args, {
|
|
1301
|
+
env: { ...process.env },
|
|
1302
|
+
stdio: [
|
|
1303
|
+
"ignore",
|
|
1304
|
+
"pipe",
|
|
1305
|
+
"pipe"
|
|
1306
|
+
]
|
|
1307
|
+
});
|
|
1308
|
+
child.stdout.on("data", (chunk) => {
|
|
1309
|
+
chunks.push(chunk);
|
|
1310
|
+
});
|
|
1311
|
+
child.stderr.on("data", (chunk) => {
|
|
1312
|
+
errChunks.push(chunk);
|
|
1313
|
+
});
|
|
1314
|
+
child.on("error", (err) => {
|
|
1315
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] Failed to spawn wrangler.\n ${err.message}`));
|
|
1316
|
+
});
|
|
1317
|
+
child.on("close", (code) => {
|
|
1318
|
+
const stdout = Buffer.concat(chunks).toString("utf8");
|
|
1319
|
+
const stderr = Buffer.concat(errChunks).toString("utf8");
|
|
1320
|
+
if (code !== 0) {
|
|
1321
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] wrangler exited with code ${String(code)}.\n ${stderr || stdout}`));
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
resolve(args[0] === "deploy" ? extractDeployedUrl(stdout) : stdout);
|
|
1325
|
+
});
|
|
1326
|
+
});
|
|
1327
|
+
/**
|
|
1328
|
+
* Spawn `wrangler` with the given args, inheriting stdio so its output streams live to the user's
|
|
1329
|
+
* terminal (used by the generic passthrough and long-lived commands like `tail`).
|
|
1330
|
+
*
|
|
1331
|
+
* @param args - Wrangler CLI arguments (e.g. ["kv", "namespace", "list"]).
|
|
1332
|
+
* @returns Resolves once wrangler exits successfully.
|
|
1333
|
+
* @throws {Error} When wrangler cannot be spawned or exits non-zero.
|
|
1334
|
+
* @example
|
|
1335
|
+
* ```ts
|
|
1336
|
+
* await runWranglerInherit(["kv", "namespace", "list"]);
|
|
1337
|
+
* ```
|
|
1338
|
+
*/
|
|
1339
|
+
const runWranglerInherit = (args) => {
|
|
1340
|
+
return new Promise((resolve, reject) => {
|
|
1341
|
+
const child = spawn("wrangler", args, { stdio: "inherit" });
|
|
1342
|
+
child.on("error", (error) => {
|
|
1343
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] Failed to spawn wrangler.\n ${error.message}`));
|
|
1344
|
+
});
|
|
1345
|
+
child.on("close", (code) => {
|
|
1346
|
+
if (code === 0) {
|
|
1347
|
+
resolve();
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] wrangler exited with code ${String(code)}.`));
|
|
1351
|
+
});
|
|
1352
|
+
});
|
|
1353
|
+
};
|
|
1354
|
+
//#endregion
|
|
1355
|
+
//#region src/plugins/deploy/dev/build.ts
|
|
1356
|
+
/**
|
|
1357
|
+
* @file deploy plugin — dev site-rebuild resolution.
|
|
1358
|
+
*
|
|
1359
|
+
* Resolves HOW to rebuild the Moku web site on change: the in-process `webBuild` hook (preferred,
|
|
1360
|
+
* fast, typed — passed call-time from the consumer's script or set as a config default) → a
|
|
1361
|
+
* `buildCommand` shell string → an auto-detected `scripts/build.ts`. When nothing is configured,
|
|
1362
|
+
* dev serves the worker only and says so. Subprocesses inherit the parent env by default.
|
|
1363
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
1364
|
+
*/
|
|
1365
|
+
/** Convention build script auto-detected when no webBuild/buildCommand is configured. */
|
|
1366
|
+
const AUTO_DETECT = "scripts/build.ts";
|
|
1367
|
+
/**
|
|
1368
|
+
* Opportunistically read a numeric `files` count off an arbitrary web build result. A real web
|
|
1369
|
+
* build returns its own summary shape (the worker framework cannot know it), so anything without a
|
|
1370
|
+
* numeric `files` field reports 0.
|
|
1371
|
+
*
|
|
1372
|
+
* @param result - The resolved value of a {@link WebBuild} hook (any shape).
|
|
1373
|
+
* @returns The `files` count when present and numeric, else 0.
|
|
1374
|
+
* @example
|
|
1375
|
+
* ```ts
|
|
1376
|
+
* fileCountOf({ files: 12 }); // 12
|
|
1377
|
+
* fileCountOf({ outDir: "dist", pageCount: 4 }); // 0
|
|
1378
|
+
* ```
|
|
1379
|
+
*/
|
|
1380
|
+
const fileCountOf = (result) => {
|
|
1381
|
+
if (typeof result === "object" && result !== null && "files" in result) {
|
|
1382
|
+
const { files } = result;
|
|
1383
|
+
return typeof files === "number" ? files : 0;
|
|
1384
|
+
}
|
|
1385
|
+
return 0;
|
|
1386
|
+
};
|
|
1387
|
+
/**
|
|
1388
|
+
* Run a shell build command, resolving on a zero exit and rejecting otherwise.
|
|
1389
|
+
*
|
|
1390
|
+
* @param command - The shell command to run (the consumer's own configured build).
|
|
1391
|
+
* @returns Resolves once the command exits successfully.
|
|
1392
|
+
* @throws {Error} When the command fails to start or exits non-zero.
|
|
1393
|
+
* @example
|
|
1394
|
+
* ```ts
|
|
1395
|
+
* await runShellBuild("bun run scripts/build.ts");
|
|
1396
|
+
* ```
|
|
1397
|
+
*/
|
|
1398
|
+
const runShellBuild = (command) => {
|
|
1399
|
+
return new Promise((resolve, reject) => {
|
|
1400
|
+
const child = spawn(command, {
|
|
1401
|
+
shell: true,
|
|
1402
|
+
stdio: "inherit"
|
|
1403
|
+
});
|
|
1404
|
+
child.on("error", (error) => {
|
|
1405
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] site build failed to start.\n ${error.message}`));
|
|
1406
|
+
});
|
|
1407
|
+
child.on("close", (code) => {
|
|
1408
|
+
if (code === 0) {
|
|
1409
|
+
resolve();
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] site build exited with code ${String(code)}.`));
|
|
1413
|
+
});
|
|
1414
|
+
});
|
|
1415
|
+
};
|
|
1416
|
+
/**
|
|
1417
|
+
* Rebuild the Moku web site using the resolved strategy: the call-time `webBuild` hook (the
|
|
1418
|
+
* script-driven path), else the `webBuild` config default, else the `buildCommand` shell string,
|
|
1419
|
+
* else an auto-detected `scripts/build.ts`. A hook's result is normalized to a `{ files }` count
|
|
1420
|
+
* (0 when the hook reports none, and for the shell path where it is unknown).
|
|
1421
|
+
*
|
|
1422
|
+
* @param ctx - The deploy plugin context (config + emit).
|
|
1423
|
+
* @param webBuild - Optional call-time web build hook (takes precedence over `ctx.config.webBuild`).
|
|
1424
|
+
* @returns The rebuilt file count (0 for the shell path / a countless hook).
|
|
1425
|
+
* @throws {Error} When the resolved shell build fails.
|
|
1426
|
+
* @example
|
|
1427
|
+
* ```ts
|
|
1428
|
+
* const { files } = await buildSite(ctx, () => web.cli.build());
|
|
1429
|
+
* ```
|
|
1430
|
+
*/
|
|
1431
|
+
const buildSite = async (ctx, webBuild) => {
|
|
1432
|
+
const hook = webBuild ?? ctx.config.webBuild;
|
|
1433
|
+
if (hook !== void 0) return { files: fileCountOf(await hook()) };
|
|
1434
|
+
const command = ctx.config.buildCommand || (existsSync(AUTO_DETECT) ? `bun run ${AUTO_DETECT}` : "");
|
|
1435
|
+
if (command === "") {
|
|
1436
|
+
ctx.emit("dev:error", { message: "No site build configured (pass webBuild or set buildCommand); serving worker only." });
|
|
1437
|
+
return { files: 0 };
|
|
1438
|
+
}
|
|
1439
|
+
await runShellBuild(command);
|
|
1440
|
+
return { files: 0 };
|
|
1441
|
+
};
|
|
1442
|
+
//#endregion
|
|
1443
|
+
//#region src/plugins/deploy/dev/watch.ts
|
|
1444
|
+
/**
|
|
1445
|
+
* @file deploy plugin — debounced filesystem watcher for dev.
|
|
1446
|
+
*
|
|
1447
|
+
* Watches the top-level directories implied by the config globs (recursive) and fires a debounced
|
|
1448
|
+
* change callback with the last changed path. Uses node:fs.watch — no extra dependency.
|
|
1449
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
1450
|
+
*/
|
|
1451
|
+
/**
|
|
1452
|
+
* Derive the set of top-level directories to watch from glob patterns.
|
|
1453
|
+
*
|
|
1454
|
+
* @param globs - Watch globs (e.g. ["src/**\/*.ts", "public/**\/*"]).
|
|
1455
|
+
* @returns The distinct top-level directories (e.g. ["src", "public"]).
|
|
1456
|
+
* @example
|
|
1457
|
+
* ```ts
|
|
1458
|
+
* watchDirectories(["src/**\/*.ts", "public/**\/*"]); // ["src", "public"]
|
|
1459
|
+
* ```
|
|
1460
|
+
*/
|
|
1461
|
+
const watchDirectories = (globs) => {
|
|
1462
|
+
const directories = /* @__PURE__ */ new Set();
|
|
1463
|
+
for (const glob of globs) {
|
|
1464
|
+
const globStart = glob.search(/[*?[{]/u);
|
|
1465
|
+
const top = (globStart === -1 ? path.dirname(glob) : glob.slice(0, globStart)).split(/[/\\]/u).find((segment) => segment !== "") ?? ".";
|
|
1466
|
+
directories.add(top);
|
|
1467
|
+
}
|
|
1468
|
+
return [...directories];
|
|
1469
|
+
};
|
|
1470
|
+
/**
|
|
1471
|
+
* Watch the directories implied by `globs` and fire `onChange` (debounced by `debounceMs`) with
|
|
1472
|
+
* the last changed path. Missing directories are skipped silently.
|
|
1473
|
+
*
|
|
1474
|
+
* @param globs - Watch globs.
|
|
1475
|
+
* @param debounceMs - Coalesce rapid changes into one callback within this window.
|
|
1476
|
+
* @param onChange - Called with the last changed path after the debounce settles.
|
|
1477
|
+
* @returns A handle whose close() stops all watchers and cancels any pending callback.
|
|
1478
|
+
* @example
|
|
1479
|
+
* ```ts
|
|
1480
|
+
* const handle = watchPaths(["src/**\/*.ts"], 120, p => rebuild(p));
|
|
1481
|
+
* handle.close();
|
|
1482
|
+
* ```
|
|
1483
|
+
*/
|
|
1484
|
+
const watchPaths = (globs, debounceMs, onChange) => {
|
|
1485
|
+
let timer;
|
|
1486
|
+
let lastPath = "";
|
|
1487
|
+
const fire = (changedPath) => {
|
|
1488
|
+
lastPath = changedPath;
|
|
1489
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1490
|
+
timer = setTimeout(() => {
|
|
1491
|
+
onChange(lastPath);
|
|
1492
|
+
}, debounceMs);
|
|
1493
|
+
};
|
|
1494
|
+
const watchers = [];
|
|
1495
|
+
for (const directory of watchDirectories(globs)) {
|
|
1496
|
+
if (!existsSync(directory)) continue;
|
|
1497
|
+
watchers.push(watch(directory, { recursive: true }, (_event, filename) => {
|
|
1498
|
+
if (filename !== null) fire(path.join(directory, filename.toString()));
|
|
1499
|
+
}));
|
|
1500
|
+
}
|
|
1501
|
+
return { close: () => {
|
|
1502
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1503
|
+
for (const watcher of watchers) watcher.close();
|
|
1504
|
+
} };
|
|
1505
|
+
};
|
|
1506
|
+
//#endregion
|
|
1507
|
+
//#region src/plugins/deploy/dev/runner.ts
|
|
1508
|
+
/**
|
|
1509
|
+
* @file deploy plugin — dev watch/recompile orchestrator.
|
|
1510
|
+
*
|
|
1511
|
+
* One long-lived session: cold-build the Moku site, optionally apply local D1 migrations, spawn
|
|
1512
|
+
* `wrangler dev --live-reload` ONCE, then watch the site sources and rebuild on change (wrangler's
|
|
1513
|
+
* asset server live-reloads the browser). Build failures keep the session serving the last good
|
|
1514
|
+
* build. Tears down cleanly on SIGINT. Side-effecting work is injected via DevDeps so the
|
|
1515
|
+
* orchestration is unit-testable without real processes, watchers, or signals.
|
|
1516
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
1517
|
+
*/
|
|
1518
|
+
/** Grace period (ms) before escalating a hung `wrangler dev` shutdown from SIGINT to SIGKILL. */
|
|
1519
|
+
const STOP_GRACE_MS = 4e3;
|
|
1520
|
+
/**
|
|
1521
|
+
* Spawn the long-lived `wrangler dev` child (inherits the parent env; non-blocking).
|
|
1522
|
+
*
|
|
1523
|
+
* `whenExited` settles when the child exits OR fails to spawn — the `error` listener is essential:
|
|
1524
|
+
* a missing/unexecutable wrangler emits `error` (not `exit`), which is otherwise unhandled (crashes
|
|
1525
|
+
* the process) and would leave `whenExited` pending forever, hanging `stop()`. `stop()` shuts
|
|
1526
|
+
* wrangler down the way its own Ctrl+C does — a graceful SIGINT, then a SIGKILL escalation if it has
|
|
1527
|
+
* not exited within {@link STOP_GRACE_MS} — resolving only once it is gone; a spawn failure is
|
|
1528
|
+
* surfaced as a thrown branded error so the caller can render it. Without the wait, the
|
|
1529
|
+
* inherited-stdio child can keep the parent alive after the watcher closes ("stuck on stopping").
|
|
1530
|
+
*
|
|
1531
|
+
* @param args - The `wrangler dev …` arguments.
|
|
1532
|
+
* @returns A handle: `whenExited` (settles on exit/spawn-failure) and `stop()` (resolves once gone).
|
|
1533
|
+
* @example
|
|
1534
|
+
* ```ts
|
|
1535
|
+
* const child = spawnWranglerDev(["dev", "--port", "8787"]);
|
|
1536
|
+
* await Promise.race([untilSignal(), child.whenExited]);
|
|
1537
|
+
* await child.stop();
|
|
1538
|
+
* ```
|
|
1539
|
+
*/
|
|
1540
|
+
const spawnWranglerDev = (args) => {
|
|
1541
|
+
const child = spawn("wrangler", args, { stdio: "inherit" });
|
|
1542
|
+
let spawnError;
|
|
1543
|
+
const whenExited = new Promise((resolve) => {
|
|
1544
|
+
child.once("exit", () => {
|
|
1545
|
+
resolve();
|
|
1546
|
+
});
|
|
1547
|
+
child.once("error", (error) => {
|
|
1548
|
+
spawnError = /* @__PURE__ */ new Error(`[moku-worker] Failed to spawn wrangler.\n ${error.message}`);
|
|
1549
|
+
resolve();
|
|
1550
|
+
});
|
|
1551
|
+
});
|
|
1552
|
+
const stop = async () => {
|
|
1553
|
+
if (spawnError !== void 0) throw spawnError;
|
|
1554
|
+
if (child.exitCode !== null || child.signalCode !== null || child.pid === void 0) return;
|
|
1555
|
+
child.kill("SIGINT");
|
|
1556
|
+
const forceKill = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
|
|
1557
|
+
await whenExited;
|
|
1558
|
+
clearTimeout(forceKill);
|
|
1559
|
+
};
|
|
1560
|
+
return {
|
|
1561
|
+
stop,
|
|
1562
|
+
whenExited
|
|
1563
|
+
};
|
|
1564
|
+
};
|
|
1565
|
+
/**
|
|
1566
|
+
* Resolve when the user first interrupts the dev session (SIGINT).
|
|
1567
|
+
*
|
|
1568
|
+
* @returns A promise that settles on the first SIGINT.
|
|
1569
|
+
* @example
|
|
1570
|
+
* ```ts
|
|
1571
|
+
* await waitForSigint();
|
|
1572
|
+
* ```
|
|
1573
|
+
*/
|
|
1574
|
+
const waitForSigint = () => {
|
|
1575
|
+
return new Promise((resolve) => {
|
|
1576
|
+
process.once("SIGINT", () => {
|
|
1577
|
+
resolve();
|
|
1578
|
+
});
|
|
1579
|
+
});
|
|
1580
|
+
};
|
|
1581
|
+
/**
|
|
1582
|
+
* Wall-clock timestamp in ms (extracted so realDevDeps holds only named references).
|
|
1583
|
+
*
|
|
1584
|
+
* @returns The current time in milliseconds.
|
|
1585
|
+
* @example
|
|
1586
|
+
* ```ts
|
|
1587
|
+
* const t = nowMs();
|
|
1588
|
+
* ```
|
|
1589
|
+
*/
|
|
1590
|
+
const nowMs = () => Date.now();
|
|
1591
|
+
/**
|
|
1592
|
+
* Build the real (side-effecting) dev deps used by api.dev(). Subprocesses inherit the parent env.
|
|
1593
|
+
*
|
|
1594
|
+
* @returns The production DevDeps (real spawn / fs.watch / SIGINT / Date.now).
|
|
1595
|
+
* @example
|
|
1596
|
+
* ```ts
|
|
1597
|
+
* await runDev(ctx, opts, realDevDeps());
|
|
1598
|
+
* ```
|
|
1599
|
+
*/
|
|
1600
|
+
const realDevDeps = () => ({
|
|
1601
|
+
build: buildSite,
|
|
1602
|
+
runWrangler,
|
|
1603
|
+
spawnDev: spawnWranglerDev,
|
|
1604
|
+
watch: watchPaths,
|
|
1605
|
+
untilSignal: waitForSigint,
|
|
1606
|
+
now: nowMs
|
|
1607
|
+
});
|
|
1608
|
+
/**
|
|
1609
|
+
* The d1 binding to migrate locally, when a d1 plugin is present in the app.
|
|
1610
|
+
*
|
|
1611
|
+
* @param ctx - The deploy plugin context.
|
|
1612
|
+
* @returns The d1 binding name, or undefined when no d1 plugin is present.
|
|
1613
|
+
* @example
|
|
1614
|
+
* ```ts
|
|
1615
|
+
* const binding = d1Binding(ctx); // "DB" | undefined
|
|
1616
|
+
* ```
|
|
1617
|
+
*/
|
|
1618
|
+
const d1Binding = (ctx) => ctx.has("d1") ? ctx.require(d1Plugin).deployManifest().binding : void 0;
|
|
1619
|
+
/**
|
|
1620
|
+
* Rebuild the site once and announce the result. A failed build keeps the session alive (it just
|
|
1621
|
+
* emits dev:error and serves the last good build).
|
|
1622
|
+
*
|
|
1623
|
+
* @param ctx - The deploy plugin context.
|
|
1624
|
+
* @param deps - The injected dev deps.
|
|
1625
|
+
* @param changedPath - The path that triggered the rebuild.
|
|
1626
|
+
* @param webBuild - Optional call-time web build hook threaded into the rebuild.
|
|
1627
|
+
* @returns Resolves once the rebuild attempt completes.
|
|
1628
|
+
* @example
|
|
1629
|
+
* ```ts
|
|
1630
|
+
* await rebuild(ctx, deps, "src/app.tsx", () => web.cli.build());
|
|
1631
|
+
* ```
|
|
1632
|
+
*/
|
|
1633
|
+
const rebuild = async (ctx, deps, changedPath, webBuild) => {
|
|
1634
|
+
ctx.emit("dev:phase", {
|
|
1635
|
+
phase: "rebuild",
|
|
1636
|
+
detail: changedPath
|
|
1637
|
+
});
|
|
1638
|
+
const started = deps.now();
|
|
1639
|
+
try {
|
|
1640
|
+
const { files } = await deps.build(ctx, webBuild);
|
|
1641
|
+
ctx.emit("dev:rebuilt", {
|
|
1642
|
+
files,
|
|
1643
|
+
ms: deps.now() - started
|
|
1644
|
+
});
|
|
1645
|
+
} catch (error) {
|
|
1646
|
+
ctx.emit("dev:error", { message: error instanceof Error ? error.message : String(error) });
|
|
1647
|
+
}
|
|
1648
|
+
};
|
|
1649
|
+
/**
|
|
1650
|
+
* Run a long-lived dev session: cold build → (local d1 migrate) → spawn `wrangler dev` →
|
|
1651
|
+
* watch + rebuild on change → teardown on signal.
|
|
1652
|
+
*
|
|
1653
|
+
* @param ctx - The deploy plugin context (config + emit + require/has).
|
|
1654
|
+
* @param opts - Optional options.
|
|
1655
|
+
* @param opts.port - Local dev port (default 8787).
|
|
1656
|
+
* @param opts.webBuild - Web build hook (re)run on cold build + each change (e.g. `() => web.cli.build()`).
|
|
1657
|
+
* @param deps - Injected side effects (real ones from realDevDeps in production).
|
|
1658
|
+
* @returns Resolves when the session ends (SIGINT).
|
|
1659
|
+
* @example
|
|
1660
|
+
* ```ts
|
|
1661
|
+
* await runDev(ctx, { port: 8787, webBuild: () => web.cli.build() }, realDevDeps());
|
|
1662
|
+
* ```
|
|
1663
|
+
*/
|
|
1664
|
+
const runDev = async (ctx, opts, deps) => {
|
|
1665
|
+
const port = opts?.port ?? 8787;
|
|
1666
|
+
const webBuild = opts?.webBuild;
|
|
1667
|
+
ctx.emit("dev:phase", {
|
|
1668
|
+
phase: "build",
|
|
1669
|
+
detail: "site"
|
|
1670
|
+
});
|
|
1671
|
+
await deps.build(ctx, webBuild);
|
|
1672
|
+
const binding = d1Binding(ctx);
|
|
1673
|
+
if (ctx.config.migrateLocal && binding !== void 0) {
|
|
1674
|
+
ctx.emit("dev:phase", {
|
|
1675
|
+
phase: "migrate",
|
|
1676
|
+
detail: "d1 (local)"
|
|
1677
|
+
});
|
|
1678
|
+
await deps.runWrangler([
|
|
1679
|
+
"d1",
|
|
1680
|
+
"migrations",
|
|
1681
|
+
"apply",
|
|
1682
|
+
binding,
|
|
1683
|
+
"--local"
|
|
1684
|
+
]);
|
|
1685
|
+
}
|
|
1686
|
+
ctx.emit("dev:phase", {
|
|
1687
|
+
phase: "serve",
|
|
1688
|
+
detail: `http://localhost:${String(port)}`
|
|
1689
|
+
});
|
|
1690
|
+
const child = deps.spawnDev([
|
|
1691
|
+
"dev",
|
|
1692
|
+
"--port",
|
|
1693
|
+
String(port),
|
|
1694
|
+
"--config",
|
|
1695
|
+
ctx.config.configFile,
|
|
1696
|
+
"--live-reload"
|
|
1697
|
+
]);
|
|
1698
|
+
const watcher = deps.watch(ctx.config.watch, ctx.config.debounceMs, (changedPath) => rebuild(ctx, deps, changedPath, webBuild));
|
|
1699
|
+
await Promise.race([deps.untilSignal(), child.whenExited]);
|
|
1700
|
+
ctx.emit("dev:phase", { phase: "stopping" });
|
|
1701
|
+
watcher.close();
|
|
1702
|
+
await child.stop();
|
|
1703
|
+
};
|
|
1704
|
+
//#endregion
|
|
1705
|
+
//#region src/plugins/deploy/infra/plan.ts
|
|
1706
|
+
/**
|
|
1707
|
+
* Decide whether a single declared resource already exists in the account, recovering its id
|
|
1708
|
+
* (kv/d1) when it does. Durable Objects are config-only (they ship with the script), so they are
|
|
1709
|
+
* always treated as "missing" — provisioning them is a no-op that just records the binding.
|
|
1710
|
+
*
|
|
1711
|
+
* @param resource - The declared resource descriptor.
|
|
1712
|
+
* @param existing - The indexed set of resources already in the account.
|
|
1713
|
+
* @returns Whether it exists, plus the captured id for kv/d1.
|
|
1714
|
+
* @example
|
|
1715
|
+
* ```ts
|
|
1716
|
+
* checkExisting({ kind: "kv", binding: "SESSIONS" }, existing); // { exists: true, id: "ns123" }
|
|
1717
|
+
* ```
|
|
1718
|
+
*/
|
|
1719
|
+
const checkExisting = (resource, existing) => {
|
|
1720
|
+
switch (resource.kind) {
|
|
1721
|
+
case "kv": {
|
|
1722
|
+
const id = existing.kv.get(resource.binding);
|
|
1723
|
+
return id === void 0 ? { exists: false } : {
|
|
1724
|
+
exists: true,
|
|
1725
|
+
id
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
case "d1": {
|
|
1729
|
+
const id = existing.d1.get(resource.binding);
|
|
1730
|
+
return id === void 0 ? { exists: false } : {
|
|
1731
|
+
exists: true,
|
|
1732
|
+
id
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
case "r2": return { exists: existing.r2.has(resource.bucket) };
|
|
1736
|
+
case "queue": return { exists: resource.producers.every((producer) => existing.queue.has(producer)) };
|
|
1737
|
+
case "do": return { exists: false };
|
|
1738
|
+
}
|
|
1739
|
+
};
|
|
1740
|
+
/**
|
|
1741
|
+
* Run the read-only infra preflight: resolve the account, list existing resources, diff against
|
|
1742
|
+
* the manifest, emit `provision:plan`, and return the plan. Writes nothing.
|
|
1743
|
+
*
|
|
1744
|
+
* @param ctx - The deploy plugin context (env + emit).
|
|
1745
|
+
* @param manifest - The assembled (or caller-supplied) deploy manifest.
|
|
1746
|
+
* @returns The infra plan: existing (with ids) vs missing resources.
|
|
1747
|
+
* @throws {Error} When the token is absent/invalid or a Cloudflare listing fails.
|
|
1748
|
+
* @example
|
|
1749
|
+
* ```ts
|
|
1750
|
+
* const plan = await planInfra(ctx, manifest);
|
|
1751
|
+
* ```
|
|
1752
|
+
*/
|
|
1753
|
+
const planInfra = async (ctx, manifest) => {
|
|
1754
|
+
const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
|
|
1755
|
+
const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
|
|
1756
|
+
const account = pinnedAccountId ? {
|
|
1757
|
+
id: pinnedAccountId,
|
|
1758
|
+
name: pinnedAccountId
|
|
1759
|
+
} : await resolveAccount(token);
|
|
1760
|
+
const kinds = /* @__PURE__ */ new Set();
|
|
1761
|
+
for (const resource of manifest.resources) if (resource.kind !== "do") kinds.add(resource.kind);
|
|
1762
|
+
const existing = await listExisting(token, account.id, kinds);
|
|
1763
|
+
const exists = [];
|
|
1764
|
+
const missing = [];
|
|
1765
|
+
for (const resource of manifest.resources) {
|
|
1766
|
+
const check = checkExisting(resource, existing);
|
|
1767
|
+
if (check.exists) exists.push(check.id === void 0 ? { resource } : {
|
|
1768
|
+
resource,
|
|
1769
|
+
id: check.id
|
|
1770
|
+
});
|
|
1771
|
+
else missing.push(resource);
|
|
1772
|
+
}
|
|
1773
|
+
ctx.emit("provision:plan", {
|
|
1774
|
+
exists: exists.length,
|
|
1775
|
+
missing: missing.length,
|
|
1776
|
+
account: account.name
|
|
1777
|
+
});
|
|
1778
|
+
return {
|
|
1779
|
+
account: account.name,
|
|
1780
|
+
accountId: account.id,
|
|
1781
|
+
exists,
|
|
1782
|
+
missing
|
|
1783
|
+
};
|
|
1784
|
+
};
|
|
1785
|
+
//#endregion
|
|
1786
|
+
//#region src/plugins/deploy/providers/d1.ts
|
|
1787
|
+
/**
|
|
1788
|
+
* @file deploy plugin — D1 provisioning adapter.
|
|
1789
|
+
*
|
|
1790
|
+
* Creates a Cloudflare D1 database via `wrangler d1 create <binding>`, captures the created
|
|
1791
|
+
* database id from wrangler's output (so writeWranglerConfig can write a real `database_id`
|
|
1792
|
+
* instead of an empty placeholder), and applies migrations when declared.
|
|
1793
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
1794
|
+
*/
|
|
1795
|
+
/**
|
|
1796
|
+
* Parse the created D1 database id from `wrangler d1 create` output.
|
|
1797
|
+
* Wrangler prints the new binding as JSON (`"database_id": "..."`) or TOML
|
|
1798
|
+
* (`database_id = "..."`); the leading boundary keeps the match anchored to the field name.
|
|
1799
|
+
*
|
|
1800
|
+
* @param output - Raw stdout from the wrangler create command.
|
|
1801
|
+
* @returns The database id, or undefined when none is found.
|
|
1802
|
+
* @example
|
|
1803
|
+
* ```ts
|
|
1804
|
+
* parseD1DatabaseId('{ "database_id": "uuid-1234" }'); // "uuid-1234"
|
|
1805
|
+
* ```
|
|
1806
|
+
*/
|
|
1807
|
+
const parseD1DatabaseId = (output) => {
|
|
1808
|
+
return /(?:^|[\s,{])"?database_id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
|
|
1809
|
+
};
|
|
1810
|
+
/**
|
|
1811
|
+
* Provision a D1 database via `wrangler d1 create`, capture its id, and apply migrations.
|
|
1812
|
+
*
|
|
1813
|
+
* @param manifest - The D1 resource descriptor.
|
|
1814
|
+
* @param _ci - Whether running non-interactively.
|
|
1815
|
+
* @returns The captured database id when wrangler reported one, else an empty outcome.
|
|
1816
|
+
* @example
|
|
1817
|
+
* ```ts
|
|
1818
|
+
* const { id } = await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
|
|
1819
|
+
* ```
|
|
1820
|
+
*/
|
|
1821
|
+
const provisionD1 = async (manifest, _ci) => {
|
|
1822
|
+
const id = parseD1DatabaseId(await runWrangler([
|
|
1823
|
+
"d1",
|
|
1824
|
+
"create",
|
|
1825
|
+
manifest.binding
|
|
1826
|
+
]));
|
|
1827
|
+
if (manifest.migrations) await runWrangler([
|
|
1828
|
+
"d1",
|
|
1829
|
+
"migrations",
|
|
1830
|
+
"apply",
|
|
1831
|
+
manifest.binding,
|
|
1832
|
+
"--local"
|
|
1833
|
+
]);
|
|
1834
|
+
return id ? { id } : {};
|
|
1835
|
+
};
|
|
1836
|
+
//#endregion
|
|
1837
|
+
//#region src/plugins/deploy/providers/do.ts
|
|
1838
|
+
/**
|
|
1839
|
+
* Provision Durable Object bindings. DOs are config-driven (no `wrangler do create` command
|
|
1840
|
+
* exists) — the actual binding entries are written by writeWranglerConfig. This function is
|
|
1841
|
+
* a resolved no-op for the dispatch step.
|
|
1842
|
+
*
|
|
1843
|
+
* @param _manifest - The Durable Objects resource descriptor.
|
|
1844
|
+
* @param _ci - Whether running non-interactively.
|
|
1845
|
+
* @returns Resolves immediately (DOs are config-only provisioning).
|
|
1846
|
+
* @example
|
|
1847
|
+
* ```ts
|
|
1848
|
+
* await provisionDurableObject({ kind: "do", bindings: { counter: "COUNTER" } }, false);
|
|
1849
|
+
* ```
|
|
1850
|
+
*/
|
|
1851
|
+
const provisionDurableObject = async (_manifest, _ci) => {};
|
|
1852
|
+
//#endregion
|
|
1853
|
+
//#region src/plugins/deploy/providers/kv.ts
|
|
1854
|
+
/**
|
|
1855
|
+
* @file deploy plugin — KV provisioning adapter.
|
|
1856
|
+
*
|
|
1857
|
+
* Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding>` and captures
|
|
1858
|
+
* the created namespace id from wrangler's output, so writeWranglerConfig can write a real `id`
|
|
1859
|
+
* (not an empty placeholder) into the generated wrangler config — otherwise the binding resolves
|
|
1860
|
+
* to nothing at runtime. Node-only; never imported by the runtime Worker bundle.
|
|
1861
|
+
*/
|
|
1862
|
+
/**
|
|
1863
|
+
* Parse the created KV namespace id from `wrangler kv namespace create` output.
|
|
1864
|
+
* Wrangler prints the new binding as JSON (`"id": "..."`) or TOML (`id = "..."`); the leading
|
|
1865
|
+
* boundary (start / whitespace / `{` / `,`) keeps the match off a longer identifier such as
|
|
1866
|
+
* `kv_namespace_id`.
|
|
1867
|
+
*
|
|
1868
|
+
* @param output - Raw stdout from the wrangler create command.
|
|
1869
|
+
* @returns The namespace id, or undefined when none is found.
|
|
1870
|
+
* @example
|
|
1871
|
+
* ```ts
|
|
1872
|
+
* parseKvNamespaceId('{ "id": "abc123" }'); // "abc123"
|
|
1873
|
+
* ```
|
|
1874
|
+
*/
|
|
1875
|
+
const parseKvNamespaceId = (output) => {
|
|
1876
|
+
return /(?:^|[\s,{])"?id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
|
|
1877
|
+
};
|
|
1878
|
+
/**
|
|
1879
|
+
* Provision a KV namespace via `wrangler kv namespace create` and capture its id.
|
|
1880
|
+
*
|
|
1881
|
+
* @param manifest - The KV resource descriptor.
|
|
1882
|
+
* @param _ci - Whether running non-interactively (passed through; wrangler respects env vars).
|
|
1883
|
+
* @returns The captured namespace id when wrangler reported one, else an empty outcome.
|
|
1884
|
+
* @example
|
|
1885
|
+
* ```ts
|
|
1886
|
+
* const { id } = await provisionKv({ kind: "kv", binding: "CACHE" }, false);
|
|
1887
|
+
* ```
|
|
1888
|
+
*/
|
|
1889
|
+
const provisionKv = async (manifest, _ci) => {
|
|
1890
|
+
const id = parseKvNamespaceId(await runWrangler([
|
|
1891
|
+
"kv",
|
|
1892
|
+
"namespace",
|
|
1893
|
+
"create",
|
|
1894
|
+
manifest.binding
|
|
1895
|
+
]));
|
|
1896
|
+
return id ? { id } : {};
|
|
1897
|
+
};
|
|
1898
|
+
//#endregion
|
|
1899
|
+
//#region src/plugins/deploy/providers/queues.ts
|
|
1900
|
+
/**
|
|
1901
|
+
* @file deploy plugin — Queues provisioning adapter.
|
|
1902
|
+
*
|
|
1903
|
+
* Creates Cloudflare Queues via `wrangler queues create <name>` for each producer.
|
|
1904
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
1905
|
+
*/
|
|
1906
|
+
/**
|
|
1907
|
+
* Provision queues via `wrangler queues create` for each declared producer.
|
|
1908
|
+
*
|
|
1909
|
+
* @param manifest - The queue resource descriptor.
|
|
1910
|
+
* @param _ci - Whether running non-interactively.
|
|
1911
|
+
* @returns Resolves once all queues are created.
|
|
1912
|
+
* @example
|
|
1913
|
+
* ```ts
|
|
1914
|
+
* await provisionQueue({ kind: "queue", producers: ["orders"] }, false);
|
|
1915
|
+
* ```
|
|
1916
|
+
*/
|
|
1917
|
+
const provisionQueue = async (manifest, _ci) => {
|
|
1918
|
+
for (const producer of manifest.producers) await runWrangler([
|
|
1919
|
+
"queues",
|
|
1920
|
+
"create",
|
|
1921
|
+
producer
|
|
1922
|
+
]);
|
|
1923
|
+
};
|
|
1924
|
+
//#endregion
|
|
1925
|
+
//#region src/plugins/deploy/providers/r2.ts
|
|
1926
|
+
/**
|
|
1927
|
+
* @file deploy plugin — R2 provisioning + asset upload adapter.
|
|
1928
|
+
*
|
|
1929
|
+
* Provides two exports:
|
|
1930
|
+
* - `provisionR2`: creates an R2 bucket via `wrangler r2 bucket create`.
|
|
1931
|
+
* - `uploadDirToR2`: walks a directory recursively and uploads each file via
|
|
1932
|
+
* `wrangler r2 object put`, returning the uploaded file count.
|
|
1933
|
+
*
|
|
1934
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
1935
|
+
*/
|
|
1936
|
+
/**
|
|
1937
|
+
* Provision an R2 bucket via `wrangler r2 bucket create`.
|
|
1938
|
+
*
|
|
1939
|
+
* @param manifest - The R2 resource descriptor.
|
|
1940
|
+
* @param _ci - Whether running non-interactively.
|
|
1941
|
+
* @returns Resolves once the bucket is created.
|
|
1942
|
+
* @example
|
|
1943
|
+
* ```ts
|
|
1944
|
+
* await provisionR2({ kind: "r2", bucket: "ASSETS" }, false);
|
|
1945
|
+
* ```
|
|
1946
|
+
*/
|
|
1947
|
+
const provisionR2 = async (manifest, _ci) => {
|
|
1948
|
+
await runWrangler([
|
|
1949
|
+
"r2",
|
|
1950
|
+
"bucket",
|
|
1951
|
+
"create",
|
|
1952
|
+
manifest.bucket
|
|
1953
|
+
]);
|
|
1954
|
+
};
|
|
1955
|
+
/**
|
|
1956
|
+
* Walk a directory recursively and return all file paths (absolute).
|
|
1957
|
+
*
|
|
1958
|
+
* @param directory - Directory path to walk.
|
|
1959
|
+
* @returns All file paths found under the directory.
|
|
1960
|
+
* @example
|
|
1961
|
+
* ```ts
|
|
1962
|
+
* const files = await walkDir("./public");
|
|
1963
|
+
* ```
|
|
1964
|
+
*/
|
|
1965
|
+
const walkDir = async (directory) => {
|
|
1966
|
+
const entries = await readdir(directory);
|
|
1967
|
+
const results = [];
|
|
1968
|
+
for (const entry of entries) {
|
|
1969
|
+
const fullPath = path.join(directory, entry);
|
|
1970
|
+
if ((await stat(fullPath)).isDirectory()) {
|
|
1971
|
+
const nested = await walkDir(fullPath);
|
|
1972
|
+
results.push(...nested);
|
|
1973
|
+
} else results.push(fullPath);
|
|
1974
|
+
}
|
|
1975
|
+
return results;
|
|
1976
|
+
};
|
|
1977
|
+
/**
|
|
1978
|
+
* Upload a directory to an R2 bucket and return the uploaded file count.
|
|
1979
|
+
* Each file is uploaded via `wrangler r2 object put <bucket>/<key> --file <path>`.
|
|
1980
|
+
*
|
|
1981
|
+
* @param bucket - The R2 bucket binding name.
|
|
1982
|
+
* @param directory - The directory to upload.
|
|
1983
|
+
* @returns The number of files uploaded.
|
|
1984
|
+
* @example
|
|
1985
|
+
* ```ts
|
|
1986
|
+
* const count = await uploadDirToR2("ASSETS", "./public");
|
|
1987
|
+
* ```
|
|
1988
|
+
*/
|
|
1989
|
+
const uploadDirToR2 = async (bucket, directory) => {
|
|
1990
|
+
const files = await walkDir(directory);
|
|
1991
|
+
for (const filePath of files) await runWrangler([
|
|
1992
|
+
"r2",
|
|
1993
|
+
"object",
|
|
1994
|
+
"put",
|
|
1995
|
+
`${bucket}/${path.relative(directory, filePath)}`,
|
|
1996
|
+
"--file",
|
|
1997
|
+
filePath
|
|
1998
|
+
]);
|
|
1999
|
+
return files.length;
|
|
2000
|
+
};
|
|
2001
|
+
//#endregion
|
|
2002
|
+
//#region src/plugins/deploy/providers/index.ts
|
|
2003
|
+
/**
|
|
2004
|
+
* Dispatch a resource descriptor to the matching provider's provisioning routine.
|
|
2005
|
+
*
|
|
2006
|
+
* @param resource - The resource descriptor to provision.
|
|
2007
|
+
* @param ci - Whether running non-interactively.
|
|
2008
|
+
* @returns The provisioning outcome — `{ id }` for kv/d1, `{}` for r2/queue/do.
|
|
2009
|
+
* @example
|
|
2010
|
+
* ```ts
|
|
2011
|
+
* const { id } = await provisionResource({ kind: "kv", binding: "CACHE" }, false);
|
|
2012
|
+
* await provisionResource({ kind: "r2", bucket: "ASSETS" }, false); // {}
|
|
2013
|
+
* ```
|
|
2014
|
+
*/
|
|
2015
|
+
const provisionResource = async (resource, ci) => {
|
|
2016
|
+
switch (resource.kind) {
|
|
2017
|
+
case "kv": return provisionKv(resource, ci);
|
|
2018
|
+
case "d1": return provisionD1(resource, ci);
|
|
2019
|
+
case "r2":
|
|
2020
|
+
await provisionR2(resource, ci);
|
|
2021
|
+
return {};
|
|
2022
|
+
case "queue":
|
|
2023
|
+
await provisionQueue(resource, ci);
|
|
2024
|
+
return {};
|
|
2025
|
+
case "do":
|
|
2026
|
+
await provisionDurableObject(resource, ci);
|
|
2027
|
+
return {};
|
|
2028
|
+
}
|
|
2029
|
+
};
|
|
2030
|
+
//#endregion
|
|
2031
|
+
//#region src/plugins/deploy/tty.ts
|
|
2032
|
+
/**
|
|
2033
|
+
* @file deploy plugin — TTY detection (isolated so the guided flow is testable).
|
|
2034
|
+
*
|
|
2035
|
+
* The guided deploy only prompts on an interactive terminal; in a pipe or CI it must never block
|
|
2036
|
+
* on stdin. Kept in its own module so tests can mock it without stubbing `process.stdout`.
|
|
2037
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
2038
|
+
*/
|
|
2039
|
+
/**
|
|
2040
|
+
* Whether stdout is an interactive TTY (so prompts are safe to show).
|
|
2041
|
+
*
|
|
2042
|
+
* @returns True when stdout is a terminal.
|
|
2043
|
+
* @example
|
|
2044
|
+
* ```ts
|
|
2045
|
+
* if (stdoutIsTty()) await prompts.confirm("Deploy?");
|
|
2046
|
+
* ```
|
|
2047
|
+
*/
|
|
2048
|
+
const stdoutIsTty = () => process.stdout.isTTY === true;
|
|
2049
|
+
//#endregion
|
|
2050
|
+
//#region src/plugins/deploy/wrangler-config.ts
|
|
2051
|
+
/**
|
|
2052
|
+
* @file deploy plugin — wrangler config generation + scaffold.
|
|
2053
|
+
*
|
|
2054
|
+
* Provides two exports:
|
|
2055
|
+
* - `writeWranglerConfig`: generates/updates a wrangler.jsonc file from an ExternalManifest.
|
|
2056
|
+
* Non-destructive: preserves existing top-level keys not managed by deploy.
|
|
2057
|
+
* - `scaffoldWranglerAndCi`: creates a minimal starter wrangler config when the file does not
|
|
2058
|
+
* exist yet; idempotent (leaves existing files untouched).
|
|
2059
|
+
*
|
|
2060
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
2061
|
+
*/
|
|
2062
|
+
/**
|
|
2063
|
+
* Strip JSONC line- and block-comments, then JSON.parse the result.
|
|
2064
|
+
*
|
|
2065
|
+
* @param source - Raw JSONC file contents.
|
|
2066
|
+
* @returns The parsed object.
|
|
2067
|
+
* @example
|
|
2068
|
+
* ```ts
|
|
2069
|
+
* const cfg = parseJsonc('{ "name": "w" } // trailing comment');
|
|
2070
|
+
* ```
|
|
2071
|
+
*/
|
|
2072
|
+
const parseJsonc = (source) => {
|
|
2073
|
+
const stripped = source.replaceAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/gu, "");
|
|
2074
|
+
return JSON.parse(stripped);
|
|
2075
|
+
};
|
|
2076
|
+
/**
|
|
2077
|
+
* Build the wrangler `kv_namespaces` array from the manifest's kv resources.
|
|
2078
|
+
*
|
|
2079
|
+
* @param resources - All resource descriptors from the manifest.
|
|
2080
|
+
* @param ids - Captured Cloudflare ids keyed by binding; the entry's `id` is filled from here.
|
|
2081
|
+
* @returns One wrangler KV namespace entry per kv resource (real `id` when known, else "").
|
|
2082
|
+
* @example
|
|
2083
|
+
* ```ts
|
|
2084
|
+
* const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }], { CACHE: "ns123" });
|
|
2085
|
+
* ```
|
|
2086
|
+
*/
|
|
2087
|
+
const buildKvNamespaces = (resources, ids) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
|
|
2088
|
+
binding: resource.binding,
|
|
2089
|
+
id: ids[resource.binding] ?? ""
|
|
2090
|
+
}));
|
|
2091
|
+
/**
|
|
2092
|
+
* Build the wrangler `r2_buckets` array from the manifest's r2 resources.
|
|
2093
|
+
*
|
|
2094
|
+
* @param resources - All resource descriptors from the manifest.
|
|
2095
|
+
* @returns One wrangler R2 bucket entry per r2 resource.
|
|
2096
|
+
* @example
|
|
2097
|
+
* ```ts
|
|
2098
|
+
* const r2 = buildR2Buckets([{ kind: "r2", bucket: "ASSETS" }]);
|
|
2099
|
+
* ```
|
|
2100
|
+
*/
|
|
2101
|
+
const buildR2Buckets = (resources) => resources.filter((resource) => resource.kind === "r2").map((resource) => ({
|
|
2102
|
+
binding: resource.bucket,
|
|
2103
|
+
bucket_name: resource.bucket.toLowerCase()
|
|
2104
|
+
}));
|
|
2105
|
+
/**
|
|
2106
|
+
* Build the wrangler `d1_databases` array from the manifest's d1 resources.
|
|
2107
|
+
*
|
|
2108
|
+
* @param resources - All resource descriptors from the manifest.
|
|
2109
|
+
* @param ids - Captured Cloudflare ids keyed by binding; the entry's `database_id` is filled from here.
|
|
2110
|
+
* @returns One wrangler D1 database entry per d1 resource (migrations_dir set when present).
|
|
2111
|
+
* @example
|
|
2112
|
+
* ```ts
|
|
2113
|
+
* const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }], { DB: "uuid-1234" });
|
|
2114
|
+
* ```
|
|
2115
|
+
*/
|
|
2116
|
+
const buildD1Databases = (resources, ids) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
|
|
2117
|
+
const entry = {
|
|
2118
|
+
binding: resource.binding,
|
|
2119
|
+
database_name: resource.binding.toLowerCase(),
|
|
2120
|
+
database_id: ids[resource.binding] ?? ""
|
|
2121
|
+
};
|
|
2122
|
+
if (resource.migrations) entry.migrations_dir = resource.migrations;
|
|
2123
|
+
return entry;
|
|
2124
|
+
});
|
|
2125
|
+
/**
|
|
2126
|
+
* Build the wrangler `queues` producers section from the manifest's queue resources.
|
|
2127
|
+
*
|
|
2128
|
+
* @param resources - All resource descriptors from the manifest.
|
|
2129
|
+
* @returns The queues section, or undefined when there are no queue resources.
|
|
2130
|
+
* @example
|
|
2131
|
+
* ```ts
|
|
2132
|
+
* const q = buildQueues([{ kind: "queue", producers: ["jobs"] }]);
|
|
2133
|
+
* ```
|
|
2134
|
+
*/
|
|
2135
|
+
const buildQueues = (resources) => {
|
|
2136
|
+
const queueResources = resources.filter((resource) => resource.kind === "queue");
|
|
2137
|
+
if (queueResources.length === 0) return void 0;
|
|
2138
|
+
return { producers: queueResources.flatMap((resource) => resource.producers.map((producer) => ({
|
|
2139
|
+
queue: producer,
|
|
2140
|
+
binding: producer.toUpperCase()
|
|
2141
|
+
}))) };
|
|
2142
|
+
};
|
|
2143
|
+
/**
|
|
2144
|
+
* Build the wrangler `durable_objects` bindings section from the manifest's do resources.
|
|
2145
|
+
*
|
|
2146
|
+
* @param resources - All resource descriptors from the manifest.
|
|
2147
|
+
* @returns The durable_objects section, or undefined when there are no do resources.
|
|
2148
|
+
* @example
|
|
2149
|
+
* ```ts
|
|
2150
|
+
* const dobj = buildDurableObjects([{ kind: "do", bindings: { Counter: "COUNTER" } }]);
|
|
2151
|
+
* ```
|
|
2152
|
+
*/
|
|
2153
|
+
const buildDurableObjects = (resources) => {
|
|
2154
|
+
const doResources = resources.filter((resource) => resource.kind === "do");
|
|
2155
|
+
if (doResources.length === 0) return void 0;
|
|
2156
|
+
return { bindings: doResources.flatMap((resource) => Object.entries(resource.bindings).map(([className, bindingName]) => ({
|
|
2157
|
+
name: bindingName,
|
|
2158
|
+
class_name: className
|
|
2159
|
+
}))) };
|
|
2160
|
+
};
|
|
2161
|
+
/**
|
|
2162
|
+
* Generate/update the wrangler config file from a manifest (non-destructive merge).
|
|
2163
|
+
* If the file exists, its top-level keys are preserved and only deploy-managed keys
|
|
2164
|
+
* (name, compatibility_date, kv_namespaces, r2_buckets, d1_databases, queues,
|
|
2165
|
+
* durable_objects) are updated.
|
|
2166
|
+
*
|
|
2167
|
+
* @param configFile - Path to the wrangler config file.
|
|
2168
|
+
* @param manifest - The assembled deploy manifest.
|
|
2169
|
+
* @param ids - Captured Cloudflare ids keyed by binding (kv namespace id, d1 database id). Defaults
|
|
2170
|
+
* to an empty map, in which case `id`/`database_id` are written as "" (e.g. the universal path).
|
|
2171
|
+
* @returns Resolves once the file is written.
|
|
2172
|
+
* @example
|
|
2173
|
+
* ```ts
|
|
2174
|
+
* await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123", DB: "uuid-1234" });
|
|
2175
|
+
* ```
|
|
2176
|
+
*/
|
|
2177
|
+
const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
|
|
2178
|
+
let existing = {};
|
|
2179
|
+
if (existsSync(configFile)) try {
|
|
2180
|
+
existing = parseJsonc(readFileSync(configFile, "utf8"));
|
|
2181
|
+
} catch {
|
|
2182
|
+
existing = {};
|
|
2183
|
+
}
|
|
2184
|
+
const kvNamespaces = buildKvNamespaces(manifest.resources, ids);
|
|
2185
|
+
const r2Buckets = buildR2Buckets(manifest.resources);
|
|
2186
|
+
const d1Databases = buildD1Databases(manifest.resources, ids);
|
|
2187
|
+
const queues = buildQueues(manifest.resources);
|
|
2188
|
+
const durableObjects = buildDurableObjects(manifest.resources);
|
|
2189
|
+
const updated = {
|
|
2190
|
+
...existing,
|
|
2191
|
+
name: manifest.name,
|
|
2192
|
+
compatibility_date: manifest.compatibilityDate
|
|
2193
|
+
};
|
|
2194
|
+
if (kvNamespaces.length > 0) updated.kv_namespaces = kvNamespaces;
|
|
2195
|
+
if (r2Buckets.length > 0) updated.r2_buckets = r2Buckets;
|
|
2196
|
+
if (d1Databases.length > 0) updated.d1_databases = d1Databases;
|
|
2197
|
+
if (queues !== void 0) updated.queues = queues;
|
|
2198
|
+
if (durableObjects !== void 0) updated.durable_objects = durableObjects;
|
|
2199
|
+
await writeFile(configFile, JSON.stringify(updated, void 0, 2));
|
|
2200
|
+
};
|
|
2201
|
+
/**
|
|
2202
|
+
* Scaffold a starting wrangler config and, when ci is set, CI workflow files.
|
|
2203
|
+
* Idempotent: an existing config file is left completely untouched.
|
|
2204
|
+
*
|
|
2205
|
+
* @param configFile - Path to the wrangler config file.
|
|
2206
|
+
* @param _ci - Whether to also scaffold CI workflow files.
|
|
2207
|
+
* @returns Resolves once scaffolding is written.
|
|
2208
|
+
* @example
|
|
2209
|
+
* ```ts
|
|
2210
|
+
* await scaffoldWranglerAndCi("wrangler.jsonc", true);
|
|
2211
|
+
* ```
|
|
2212
|
+
*/
|
|
2213
|
+
const scaffoldWranglerAndCi = async (configFile, _ci) => {
|
|
2214
|
+
if (existsSync(configFile)) return;
|
|
2215
|
+
const starter = {
|
|
2216
|
+
name: "my-worker",
|
|
2217
|
+
main: "src/worker.ts",
|
|
2218
|
+
compatibility_date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
2219
|
+
};
|
|
2220
|
+
await writeFile(configFile, JSON.stringify(starter, void 0, 2));
|
|
2221
|
+
};
|
|
2222
|
+
//#endregion
|
|
2223
|
+
//#region src/plugins/deploy/api.ts
|
|
2224
|
+
/**
|
|
2225
|
+
* @file deploy plugin — API factory (run, dev, init, checkInfra, provisionInfra).
|
|
2226
|
+
*
|
|
2227
|
+
* Pure ctx-taking factory. Assembles the deploy manifest from each resource plugin's own
|
|
2228
|
+
* deployManifest() api (never sibling pluginConfigs — design F6), runs an infra preflight
|
|
2229
|
+
* (check-before-create + capture real ids), generates/updates the wrangler config, uploads the
|
|
2230
|
+
* R2 upload dir, and runs wrangler deploy. Emits only global events: deploy:phase,
|
|
2231
|
+
* deploy:complete, provision:resource, provision:plan, provision:skip.
|
|
2232
|
+
*
|
|
2233
|
+
* Node-only: uses node:child_process (via runner.ts), node:fs (via wrangler-config.ts), and the
|
|
2234
|
+
* Cloudflare REST API (via infra/). Never called in the deployed Worker runtime.
|
|
2235
|
+
*/
|
|
2236
|
+
/**
|
|
2237
|
+
* Derive a human-readable name string from a resource descriptor (used in provision events).
|
|
2238
|
+
*
|
|
2239
|
+
* @param resource - The resource descriptor.
|
|
2240
|
+
* @returns A name suitable for the provision:resource / provision:skip event payload.
|
|
2241
|
+
* @example
|
|
2242
|
+
* ```ts
|
|
2243
|
+
* resourceName({ kind: "kv", binding: "CACHE" }); // "CACHE"
|
|
2244
|
+
* ```
|
|
2245
|
+
*/
|
|
2246
|
+
const resourceName = (resource) => {
|
|
2247
|
+
switch (resource.kind) {
|
|
2248
|
+
case "r2": return resource.bucket;
|
|
2249
|
+
case "do": return Object.values(resource.bindings).join(",");
|
|
2250
|
+
case "queue": return resource.producers.join(",");
|
|
2251
|
+
default: return resource.binding;
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
/**
|
|
2255
|
+
* Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api,
|
|
2256
|
+
* gated by ctx.has(name) so absent plugins are skipped — never sibling pluginConfigs (F6).
|
|
2257
|
+
*
|
|
2258
|
+
* @param ctx - The deploy plugin context.
|
|
2259
|
+
* @returns The assembled manifest (name, compatibilityDate, resources).
|
|
2260
|
+
* @example
|
|
2261
|
+
* ```ts
|
|
2262
|
+
* const manifest = assembleManifest(ctx);
|
|
2263
|
+
* ```
|
|
2264
|
+
*/
|
|
2265
|
+
const assembleManifest = (ctx) => ({
|
|
2266
|
+
name: ctx.global.name,
|
|
2267
|
+
compatibilityDate: ctx.global.compatibilityDate,
|
|
2268
|
+
resources: [
|
|
2269
|
+
ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : void 0,
|
|
2270
|
+
ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : void 0,
|
|
2271
|
+
ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : void 0,
|
|
2272
|
+
ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : void 0,
|
|
2273
|
+
ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : void 0
|
|
2274
|
+
].filter((resource) => resource !== void 0)
|
|
2275
|
+
});
|
|
2276
|
+
/**
|
|
2277
|
+
* Act on an infra plan: skip the resources that already exist (reusing their ids), create only
|
|
2278
|
+
* the missing ones (capturing each new id), and announce each via provision:skip / :resource.
|
|
2279
|
+
*
|
|
2280
|
+
* @param ctx - The deploy plugin context.
|
|
2281
|
+
* @param plan - The infra plan from planInfra (existing vs missing).
|
|
2282
|
+
* @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
|
|
2283
|
+
* @returns The provisioning result: created, skipped, and the merged binding → id map.
|
|
2284
|
+
* @example
|
|
2285
|
+
* ```ts
|
|
2286
|
+
* const { ids } = await applyPlan(ctx, plan, false);
|
|
2287
|
+
* ```
|
|
2288
|
+
*/
|
|
2289
|
+
const applyPlan = async (ctx, plan, ci) => {
|
|
2290
|
+
const ids = {};
|
|
2291
|
+
for (const ref of plan.exists) {
|
|
2292
|
+
if (ref.id !== void 0 && (ref.resource.kind === "kv" || ref.resource.kind === "d1")) ids[ref.resource.binding] = ref.id;
|
|
2293
|
+
ctx.emit("provision:skip", {
|
|
2294
|
+
kind: ref.resource.kind,
|
|
2295
|
+
name: resourceName(ref.resource)
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
const created = [];
|
|
2299
|
+
for (const resource of plan.missing) {
|
|
2300
|
+
const { id } = await provisionResource(resource, ci);
|
|
2301
|
+
if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
|
|
2302
|
+
created.push(id === void 0 ? { resource } : {
|
|
2303
|
+
resource,
|
|
2304
|
+
id
|
|
2305
|
+
});
|
|
2306
|
+
ctx.emit("provision:resource", {
|
|
2307
|
+
kind: resource.kind,
|
|
2308
|
+
name: resourceName(resource)
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
return {
|
|
2312
|
+
created,
|
|
2313
|
+
skipped: plan.exists,
|
|
2314
|
+
ids
|
|
2315
|
+
};
|
|
2316
|
+
};
|
|
2317
|
+
/**
|
|
2318
|
+
* Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
|
|
2319
|
+
* runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
|
|
2320
|
+
* `wrangler deploy`, emitting global deploy events along the way.
|
|
2321
|
+
*
|
|
2322
|
+
* @param ctx - Plugin context (own config + require + has + emit + global + env).
|
|
2323
|
+
* @returns The app.deploy api: run / dev / init / checkInfra / provisionInfra.
|
|
2324
|
+
* @example
|
|
2325
|
+
* ```ts
|
|
2326
|
+
* const api = createDeployApi(ctx);
|
|
2327
|
+
* await api.run();
|
|
2328
|
+
* ```
|
|
2329
|
+
*/
|
|
2330
|
+
const createDeployApi = (ctx) => ({
|
|
2331
|
+
/**
|
|
2332
|
+
* Run the full deploy pipeline: detect → preflight (check-before-create) → provision (only the
|
|
2333
|
+
* missing) → wrangler-config (with real ids) → upload → deploy. When opts.manifest is supplied
|
|
2334
|
+
* it is used verbatim (universal path).
|
|
2335
|
+
*
|
|
2336
|
+
* @param opts - Optional run options.
|
|
2337
|
+
* @param opts.ci - CI/automated mode: never prompts, auto-confirms every gate. When false (the
|
|
2338
|
+
* default) and stdout is a TTY, the deploy is guided — each gate is confirmed interactively.
|
|
2339
|
+
* Falls back to ctx.config.ci when omitted.
|
|
2340
|
+
* @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
|
|
2341
|
+
* @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
|
|
2342
|
+
* @returns Resolves once the deploy completes.
|
|
2343
|
+
* @example
|
|
2344
|
+
* ```ts
|
|
2345
|
+
* await api.run({ webBuild: () => web.cli.build() }); // guided on a TTY
|
|
2346
|
+
* await api.run({ ci: true, manifest: { name: "w", compatibilityDate: "2026-06-17", resources: [] } });
|
|
2347
|
+
* ```
|
|
2348
|
+
*/
|
|
2349
|
+
async run(opts) {
|
|
2350
|
+
const ci = opts?.ci ?? ctx.config.ci;
|
|
2351
|
+
const confirm = !ci && stdoutIsTty() ? createBrandPrompts().confirm : async (_question) => true;
|
|
2352
|
+
ctx.emit("deploy:phase", { phase: "auth" });
|
|
2353
|
+
await verifyAuth(ctx);
|
|
2354
|
+
const webBuild = opts?.webBuild ?? ctx.config.webBuild;
|
|
2355
|
+
if (webBuild !== void 0) {
|
|
2356
|
+
ctx.emit("deploy:phase", {
|
|
2357
|
+
phase: "build",
|
|
2358
|
+
detail: "web"
|
|
2359
|
+
});
|
|
2360
|
+
await webBuild();
|
|
2361
|
+
}
|
|
2362
|
+
ctx.emit("deploy:phase", { phase: "detect" });
|
|
2363
|
+
const manifest = opts?.manifest ?? assembleManifest(ctx);
|
|
2364
|
+
ctx.emit("deploy:phase", { phase: "provision" });
|
|
2365
|
+
const plan = await planInfra(ctx, manifest);
|
|
2366
|
+
if (plan.missing.length > 0 && !await confirm(`Create ${plan.missing.length} missing resource(s) in "${plan.account}"?`)) {
|
|
2367
|
+
ctx.emit("deploy:phase", { phase: "aborted" });
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
const { ids } = await applyPlan(ctx, plan, ci);
|
|
2371
|
+
ctx.emit("deploy:phase", { phase: "wrangler-config" });
|
|
2372
|
+
await writeWranglerConfig(ctx.config.configFile, manifest, ids);
|
|
2373
|
+
const r2Resource = manifest.resources.find((resource) => resource.kind === "r2");
|
|
2374
|
+
if (r2Resource?.upload) {
|
|
2375
|
+
const count = await uploadDirToR2(r2Resource.bucket, r2Resource.upload);
|
|
2376
|
+
ctx.emit("deploy:phase", {
|
|
2377
|
+
phase: "upload",
|
|
2378
|
+
detail: `${String(count)} files`
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
if (!await confirm(`Deploy "${manifest.name}" to ${ctx.global.stage}?`)) {
|
|
2382
|
+
ctx.emit("deploy:phase", { phase: "aborted" });
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
ctx.emit("deploy:phase", { phase: "deploy" });
|
|
2386
|
+
const url = await runWrangler([
|
|
2387
|
+
"deploy",
|
|
2388
|
+
"--config",
|
|
2389
|
+
ctx.config.configFile
|
|
2390
|
+
]);
|
|
2391
|
+
ctx.emit("deploy:complete", { url });
|
|
2392
|
+
},
|
|
2393
|
+
/**
|
|
2394
|
+
* Start a long-lived local dev session: cold-build the Moku site, spawn `wrangler dev
|
|
2395
|
+
* --live-reload`, and watch the site sources — rebuilding on change (wrangler live-reloads the
|
|
2396
|
+
* browser). Resolves on SIGINT.
|
|
2397
|
+
*
|
|
2398
|
+
* @param opts - Optional options.
|
|
2399
|
+
* @param opts.port - Local dev port (default 8787).
|
|
2400
|
+
* @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
|
|
2401
|
+
* @returns Resolves when the dev session ends.
|
|
2402
|
+
* @example
|
|
2403
|
+
* ```ts
|
|
2404
|
+
* await api.dev({ port: 8787, webBuild: () => web.cli.build() });
|
|
2405
|
+
* ```
|
|
2406
|
+
*/
|
|
2407
|
+
dev: (opts) => runDev(ctx, opts, realDevDeps()),
|
|
2408
|
+
/**
|
|
2409
|
+
* Scaffold a starting wrangler config (and CI files when ci is set).
|
|
2410
|
+
* Idempotent: an existing config file is left untouched.
|
|
2411
|
+
*
|
|
2412
|
+
* @param opts - Optional options.
|
|
2413
|
+
* @param opts.ci - Also scaffold CI workflow files.
|
|
2414
|
+
* @returns Resolves once scaffolding is written.
|
|
2415
|
+
* @example
|
|
2416
|
+
* ```ts
|
|
2417
|
+
* await api.init({ ci: true });
|
|
2418
|
+
* ```
|
|
2419
|
+
*/
|
|
2420
|
+
init: async (opts) => {
|
|
2421
|
+
await scaffoldWranglerAndCi(ctx.config.configFile, opts?.ci ?? ctx.config.ci);
|
|
2422
|
+
},
|
|
2423
|
+
/**
|
|
2424
|
+
* Read-only infra preflight: assemble the manifest, resolve the account, list what exists in
|
|
2425
|
+
* Cloudflare, diff, emit provision:plan, and return the plan. Writes nothing.
|
|
2426
|
+
*
|
|
2427
|
+
* @returns The infra plan (existing vs missing resources, with captured ids).
|
|
2428
|
+
* @example
|
|
2429
|
+
* ```ts
|
|
2430
|
+
* const plan = await api.checkInfra();
|
|
2431
|
+
* ```
|
|
2432
|
+
*/
|
|
2433
|
+
checkInfra: () => planInfra(ctx, assembleManifest(ctx)),
|
|
2434
|
+
/**
|
|
2435
|
+
* Create only the resources missing from the plan (skipping existing), capturing each id.
|
|
2436
|
+
*
|
|
2437
|
+
* @param plan - A plan produced by checkInfra().
|
|
2438
|
+
* @returns The provisioning result: created, skipped, and the merged id map.
|
|
2439
|
+
* @example
|
|
2440
|
+
* ```ts
|
|
2441
|
+
* const { created } = await api.provisionInfra(await api.checkInfra());
|
|
2442
|
+
* ```
|
|
2443
|
+
*/
|
|
2444
|
+
provisionInfra: (plan) => applyPlan(ctx, plan, ctx.config.ci),
|
|
2445
|
+
/**
|
|
2446
|
+
* Verify the `.env` Cloudflare API token (must be active) and resolve its account; emits
|
|
2447
|
+
* auth:verified. Throws a branded error pointing at `auth setup` when absent/invalid/inactive.
|
|
2448
|
+
*
|
|
2449
|
+
* @returns The verified auth status (account + id).
|
|
2450
|
+
* @example
|
|
2451
|
+
* ```ts
|
|
2452
|
+
* const { account } = await api.verifyAuth();
|
|
2453
|
+
* ```
|
|
2454
|
+
*/
|
|
2455
|
+
verifyAuth: () => verifyAuth(ctx),
|
|
2456
|
+
/**
|
|
2457
|
+
* Derive the minimum Cloudflare API token this app needs from its manifest (pure, no network).
|
|
2458
|
+
*
|
|
2459
|
+
* @returns The token requirement (full set + groups to add to the stock template).
|
|
2460
|
+
* @example
|
|
2461
|
+
* ```ts
|
|
2462
|
+
* const { toAdd } = api.requiredToken();
|
|
2463
|
+
* ```
|
|
2464
|
+
*/
|
|
2465
|
+
requiredToken: () => requiredToken(assembleManifest(ctx)),
|
|
2466
|
+
/**
|
|
2467
|
+
* Render the `auth setup` guidance from the derived token requirement (pure, no network).
|
|
2468
|
+
*
|
|
2469
|
+
* @returns The rendered instruction text.
|
|
2470
|
+
* @example
|
|
2471
|
+
* ```ts
|
|
2472
|
+
* const text = api.tokenInstructions();
|
|
2473
|
+
* ```
|
|
2474
|
+
*/
|
|
2475
|
+
tokenInstructions: () => tokenInstructions(assembleManifest(ctx)),
|
|
2476
|
+
/**
|
|
2477
|
+
* Run an arbitrary wrangler command, streaming its output (the branded CLI escape hatch).
|
|
2478
|
+
*
|
|
2479
|
+
* @param args - The wrangler arguments.
|
|
2480
|
+
* @returns Resolves once wrangler exits.
|
|
2481
|
+
* @example
|
|
2482
|
+
* ```ts
|
|
2483
|
+
* await api.wrangler(["kv", "namespace", "list"]);
|
|
2484
|
+
* ```
|
|
2485
|
+
*/
|
|
2486
|
+
wrangler: (args) => runWranglerInherit(args)
|
|
2487
|
+
});
|
|
2488
|
+
/**
|
|
2489
|
+
* Complex tier (node-only) — build-time deploy orchestrator over the five resource plugins.
|
|
2490
|
+
*
|
|
2491
|
+
* Assembles each resource plugin's deployManifest() via ctx.require, provisions resources,
|
|
2492
|
+
* generates/updates wrangler config, uploads the R2 upload dir, and runs wrangler deploy.
|
|
2493
|
+
* Also supports a universal path: run({ manifest }) uses a caller-supplied manifest verbatim.
|
|
2494
|
+
*
|
|
2495
|
+
* Emits only the global events `deploy:phase`, `deploy:complete`, and `provision:resource`
|
|
2496
|
+
* (declared in WorkerEvents — no per-plugin events block).
|
|
2497
|
+
*
|
|
2498
|
+
* @see README.md
|
|
2499
|
+
*/
|
|
2500
|
+
const deployPlugin = createPlugin("deploy", {
|
|
2501
|
+
config: {
|
|
2502
|
+
configFile: "wrangler.jsonc",
|
|
2503
|
+
ci: false,
|
|
2504
|
+
watch: ["src/**/*.{ts,tsx,css}", "public/**/*"],
|
|
2505
|
+
buildCommand: "",
|
|
2506
|
+
migrateLocal: true,
|
|
2507
|
+
debounceMs: 120
|
|
2508
|
+
},
|
|
2509
|
+
depends: [
|
|
2510
|
+
storagePlugin,
|
|
2511
|
+
kvPlugin,
|
|
2512
|
+
d1Plugin,
|
|
2513
|
+
queuesPlugin,
|
|
2514
|
+
durableObjectsPlugin
|
|
2515
|
+
],
|
|
2516
|
+
api: (ctx) => createDeployApi(ctx)
|
|
2517
|
+
});
|
|
2518
|
+
//#endregion
|
|
2519
|
+
//#region src/plugins/cli/args.ts
|
|
2520
|
+
/**
|
|
2521
|
+
* @file cli plugin — argv parsing helpers (isolated so they unit-test without a real process).
|
|
2522
|
+
*
|
|
2523
|
+
* `dev` resolves its port from the command line (`bun scripts/dev.ts --port 3000`) so a consumer
|
|
2524
|
+
* never hardcodes it in the app. Pure: takes an argv array, reads no globals. Node-only tooling.
|
|
2525
|
+
*/
|
|
2526
|
+
/** The valid TCP port range a `--port` value must fall within to be accepted. */
|
|
2527
|
+
const MAX_PORT = 65535;
|
|
2528
|
+
/**
|
|
2529
|
+
* Extract a `--port`/`-p` value from a single token (and the token after it, for the spaced form).
|
|
2530
|
+
*
|
|
2531
|
+
* @param token - The current argv token.
|
|
2532
|
+
* @param next - The following argv token (the value, for the `--port 3000` spaced form).
|
|
2533
|
+
* @returns The raw string value when this token is a port flag, else undefined.
|
|
2534
|
+
* @example
|
|
2535
|
+
* ```ts
|
|
2536
|
+
* portValueFrom("--port=3000", undefined); // "3000"
|
|
2537
|
+
* portValueFrom("--port", "3000"); // "3000"
|
|
2538
|
+
* portValueFrom("--config", "x"); // undefined
|
|
2539
|
+
* ```
|
|
2540
|
+
*/
|
|
2541
|
+
const portValueFrom = (token, next) => {
|
|
2542
|
+
const inline = /^(?:--port|-p)=(.+)$/u.exec(token);
|
|
2543
|
+
if (inline) return inline[1];
|
|
2544
|
+
if (token === "--port" || token === "-p") return next;
|
|
2545
|
+
};
|
|
2546
|
+
/**
|
|
2547
|
+
* Parse a `--port <n>` / `--port=<n>` / `-p <n>` flag out of an argv array.
|
|
2548
|
+
*
|
|
2549
|
+
* Returns the first valid port (a positive integer ≤ 65535) found, or undefined when the flag is
|
|
2550
|
+
* absent or its value is not a usable port — letting the caller fall back to a default.
|
|
2551
|
+
*
|
|
2552
|
+
* @param argv - The argv array to scan (the caller passes the process argv).
|
|
2553
|
+
* @returns The parsed port number, or undefined when no valid `--port`/`-p` flag is present.
|
|
2554
|
+
* @example
|
|
2555
|
+
* ```ts
|
|
2556
|
+
* parsePortArg(["bun", "scripts/dev.ts", "--port", "3000"]); // 3000
|
|
2557
|
+
* parsePortArg(["bun", "scripts/dev.ts", "--port=3000"]); // 3000
|
|
2558
|
+
* parsePortArg(["bun", "scripts/dev.ts"]); // undefined
|
|
2559
|
+
* ```
|
|
2560
|
+
*/
|
|
2561
|
+
const parsePortArg = (argv) => {
|
|
2562
|
+
for (let index = 0; index < argv.length; index++) {
|
|
2563
|
+
const token = argv[index];
|
|
2564
|
+
if (token === void 0) continue;
|
|
2565
|
+
const raw = portValueFrom(token, argv[index + 1]);
|
|
2566
|
+
if (raw === void 0) continue;
|
|
2567
|
+
const port = Number(raw);
|
|
2568
|
+
if (Number.isInteger(port) && port > 0 && port <= MAX_PORT) return port;
|
|
2569
|
+
}
|
|
2570
|
+
};
|
|
2571
|
+
//#endregion
|
|
2572
|
+
//#region src/plugins/cli/api.ts
|
|
2573
|
+
/**
|
|
2574
|
+
* @file cli plugin — API factory (dev, deploy, auth, doctor).
|
|
2575
|
+
*/
|
|
2576
|
+
/**
|
|
2577
|
+
* Builds app.cli.* over the deploy plugin (via ctx.require(deployPlugin)). `dev`/`deploy` resolve
|
|
2578
|
+
* their args (port from `--port`; guided unless `ci`) then delegate, catching any failure into a
|
|
2579
|
+
* branded `✗` line + non-zero exit; the read-only verbs (auth/doctor/whoami) render in Moku style.
|
|
2580
|
+
*
|
|
2581
|
+
* @param ctx - CLI plugin context (own config + typed require to deployPlugin).
|
|
2582
|
+
* @returns The cli API object (dev, deploy, auth, doctor, whoami, wrangler).
|
|
2583
|
+
* @example
|
|
2584
|
+
* ```ts
|
|
2585
|
+
* const api = createCliApi(ctx);
|
|
2586
|
+
* await api.dev({ webBuild: () => web.cli.build() }); // → deploy.dev({ port })
|
|
2587
|
+
* await api.deploy({ ci: true }); // → deploy.run({ ci: true })
|
|
2588
|
+
* ```
|
|
2589
|
+
*/
|
|
2590
|
+
const createCliApi = (ctx) => ({
|
|
2591
|
+
/**
|
|
2592
|
+
* Run the Worker locally. Resolves the port from `opts.port`, else a `--port <n>` CLI flag, else
|
|
2593
|
+
* `ctx.config.port` (8787). Prints a branded dev-session banner, then delegates to deploy.dev; a
|
|
2594
|
+
* `webBuild` hook (e.g. `() => webApp.cli.build()`) wires the web build into the dev loop so the
|
|
2595
|
+
* site recompiles on change. A failure renders a branded `✗` line + non-zero exit, not a stack.
|
|
2596
|
+
*
|
|
2597
|
+
* @param opts - Optional local dev options.
|
|
2598
|
+
* @param opts.port - Local dev port to bind. Overrides the `--port` flag and the default.
|
|
2599
|
+
* @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
|
|
2600
|
+
* @returns Resolves when the dev session ends.
|
|
2601
|
+
* @example
|
|
2602
|
+
* ```ts
|
|
2603
|
+
* await api.dev({ webBuild: () => web.cli.build() }); // port from --port or 8787
|
|
2604
|
+
* ```
|
|
2605
|
+
*/
|
|
2606
|
+
async dev(opts) {
|
|
2607
|
+
const ui = createBrandConsole();
|
|
2608
|
+
ui.lockup({
|
|
2609
|
+
wordmark: "moku worker",
|
|
2610
|
+
label: "dev session"
|
|
2611
|
+
});
|
|
2612
|
+
const port = opts?.port ?? parsePortArg(process.argv) ?? ctx.config.port;
|
|
2613
|
+
try {
|
|
2614
|
+
await ctx.require(deployPlugin).dev(opts?.webBuild ? {
|
|
2615
|
+
port,
|
|
2616
|
+
webBuild: opts.webBuild
|
|
2617
|
+
} : { port });
|
|
2618
|
+
ui.check(true, "dev session stopped cleanly");
|
|
2619
|
+
} catch (error) {
|
|
2620
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
2621
|
+
process.exitCode = 1;
|
|
2622
|
+
}
|
|
2623
|
+
},
|
|
2624
|
+
/**
|
|
2625
|
+
* One-command Cloudflare deploy; forwards opts verbatim to deploy.run. Guided/interactive by
|
|
2626
|
+
* default; `{ ci: true }` runs the automated path (CI). A `webBuild` hook builds the web site
|
|
2627
|
+
* first (before `wrangler deploy`). A failure renders a branded `✗` line + non-zero exit code
|
|
2628
|
+
* (matching cli.auth/doctor), never a raw stack trace.
|
|
2629
|
+
*
|
|
2630
|
+
* @param opts - Optional deploy options.
|
|
2631
|
+
* @param opts.ci - Automated mode: never prompts, auto-confirms. Omit/false → guided on a TTY.
|
|
2632
|
+
* @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
|
|
2633
|
+
* @returns Resolves once the deploy completes (or after a failure is rendered).
|
|
2634
|
+
* @example
|
|
2635
|
+
* ```ts
|
|
2636
|
+
* await api.deploy({ webBuild: () => web.cli.build() }); // guided
|
|
2637
|
+
* await api.deploy({ ci: true, webBuild: () => web.cli.build() }); // CI
|
|
2638
|
+
* ```
|
|
2639
|
+
*/
|
|
2640
|
+
async deploy(opts) {
|
|
2641
|
+
try {
|
|
2642
|
+
await ctx.require(deployPlugin).run(opts);
|
|
2643
|
+
} catch (error) {
|
|
2644
|
+
createBrandConsole().error(error instanceof Error ? error.message : String(error));
|
|
2645
|
+
process.exitCode = 1;
|
|
2646
|
+
}
|
|
2647
|
+
},
|
|
2648
|
+
/**
|
|
2649
|
+
* Verify the `.env` token (no sub) or print the config-derived token guidance (`"setup"`),
|
|
2650
|
+
* rendered in Moku style. `setup` works without a token; verify reports the resolved account.
|
|
2651
|
+
*
|
|
2652
|
+
* @param sub - Pass "setup" to print guidance; omit to verify the current token.
|
|
2653
|
+
* @returns Resolves once the check or guidance render completes.
|
|
2654
|
+
* @example
|
|
2655
|
+
* ```ts
|
|
2656
|
+
* await api.auth("setup"); // print what token to create
|
|
2657
|
+
* await api.auth(); // verify the current token
|
|
2658
|
+
* ```
|
|
2659
|
+
*/
|
|
2660
|
+
async auth(sub) {
|
|
2661
|
+
const deploy = ctx.require(deployPlugin);
|
|
2662
|
+
const ui = createBrandConsole();
|
|
2663
|
+
if (sub === "setup") {
|
|
2664
|
+
for (const line of deploy.tokenInstructions().split("\n")) ui.line(line);
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
try {
|
|
2668
|
+
const status = await deploy.verifyAuth();
|
|
2669
|
+
ui.check(true, "token valid", `account "${status.account}" (${status.accountId})`);
|
|
2670
|
+
} catch (error) {
|
|
2671
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
2672
|
+
}
|
|
2673
|
+
},
|
|
2674
|
+
/**
|
|
2675
|
+
* One-shot preflight report: token + account (verifyAuth) then infra drift (checkInfra),
|
|
2676
|
+
* each as a branded check line. Stops after the token check when auth fails.
|
|
2677
|
+
*
|
|
2678
|
+
* @returns Resolves once the report is printed.
|
|
2679
|
+
* @example
|
|
2680
|
+
* ```ts
|
|
2681
|
+
* await api.doctor();
|
|
2682
|
+
* ```
|
|
2683
|
+
*/
|
|
2684
|
+
async doctor() {
|
|
2685
|
+
const deploy = ctx.require(deployPlugin);
|
|
2686
|
+
const ui = createBrandConsole();
|
|
2687
|
+
ui.heading("doctor");
|
|
2688
|
+
let tokenOk = false;
|
|
2689
|
+
try {
|
|
2690
|
+
const status = await deploy.verifyAuth();
|
|
2691
|
+
tokenOk = true;
|
|
2692
|
+
ui.check(true, "token", `valid · account "${status.account}" (${status.accountId})`);
|
|
2693
|
+
} catch (error) {
|
|
2694
|
+
ui.check(false, "token", error instanceof Error ? error.message : String(error));
|
|
2695
|
+
}
|
|
2696
|
+
if (!tokenOk) {
|
|
2697
|
+
ui.line("Run `auth setup` for the exact token to create.");
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
try {
|
|
2701
|
+
const plan = await deploy.checkInfra();
|
|
2702
|
+
ui.check(true, "infra", `${plan.exists.length} exist, ${plan.missing.length} to create in "${plan.account}"`);
|
|
2703
|
+
} catch (error) {
|
|
2704
|
+
ui.check(false, "infra", error instanceof Error ? error.message : String(error));
|
|
2705
|
+
}
|
|
2706
|
+
},
|
|
2707
|
+
/**
|
|
2708
|
+
* Print the resolved Cloudflare account for the current `.env` token.
|
|
2709
|
+
*
|
|
2710
|
+
* @returns Resolves once the account summary is printed.
|
|
2711
|
+
* @example
|
|
2712
|
+
* ```ts
|
|
2713
|
+
* await api.whoami();
|
|
2714
|
+
* ```
|
|
2715
|
+
*/
|
|
2716
|
+
async whoami() {
|
|
2717
|
+
const ui = createBrandConsole();
|
|
2718
|
+
try {
|
|
2719
|
+
const status = await ctx.require(deployPlugin).verifyAuth();
|
|
2720
|
+
ui.check(true, "account", `${status.account} (${status.accountId})`);
|
|
2721
|
+
} catch (error) {
|
|
2722
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
2723
|
+
}
|
|
2724
|
+
},
|
|
2725
|
+
/**
|
|
2726
|
+
* Run an arbitrary wrangler command through the branded CLI (escape hatch). Streams its output.
|
|
2727
|
+
*
|
|
2728
|
+
* @param args - The wrangler arguments.
|
|
2729
|
+
* @returns Resolves once wrangler exits.
|
|
2730
|
+
* @example
|
|
2731
|
+
* ```ts
|
|
2732
|
+
* await api.wrangler(["kv", "namespace", "list"]);
|
|
2733
|
+
* ```
|
|
2734
|
+
*/
|
|
2735
|
+
async wrangler(args) {
|
|
2736
|
+
createBrandConsole().heading(`wrangler ${args.join(" ")}`);
|
|
2737
|
+
await ctx.require(deployPlugin).wrangler(args);
|
|
2738
|
+
}
|
|
2739
|
+
});
|
|
2740
|
+
//#endregion
|
|
2741
|
+
//#region src/plugins/cli/handlers.ts
|
|
2742
|
+
/** Divider drawn before the native `wrangler dev` TUI so the moku preamble reads as one section. */
|
|
2743
|
+
const WRANGLER_DIVIDER = ` ── wrangler ${"─".repeat(48)}`;
|
|
2744
|
+
/**
|
|
2745
|
+
* Builds the hook handlers that turn global deploy events into a live progress TUI.
|
|
2746
|
+
* Each logs a clean, prefix-free message via `ctx.log`; the branded log sink (installed
|
|
2747
|
+
* by the cli plugin's onInit from `@moku-labs/common/cli`) adds the `›` marker, brand
|
|
2748
|
+
* color, and stderr routing. Pure observers — print and return; never mutate state,
|
|
2749
|
+
* never block the deploy pipeline (fire-and-forget, spec/07 §3,§4).
|
|
2750
|
+
*
|
|
2751
|
+
* @param ctx - CLI plugin context with injected log core API.
|
|
2752
|
+
* @returns Hook map for the three global deploy events.
|
|
2753
|
+
* @example
|
|
2754
|
+
* ```ts
|
|
2755
|
+
* const hooks = createCliHooks(ctx);
|
|
2756
|
+
* hooks["deploy:phase"]({ phase: "detect" }); // logs "detect" → renders " › detect"
|
|
2757
|
+
* hooks["provision:resource"]({ kind: "kv", name: "KV" }); // logs "kv KV" → " › kv KV"
|
|
2758
|
+
* hooks["deploy:complete"]({ url: "https://x.workers.dev" }); // "deployed → https://x.workers.dev"
|
|
2759
|
+
* ```
|
|
2760
|
+
*/
|
|
2761
|
+
const createCliHooks = (ctx) => {
|
|
2762
|
+
const ui = createBrandConsole();
|
|
2763
|
+
return {
|
|
2764
|
+
/**
|
|
2765
|
+
* Log one clean line per pipeline phase: "phase" or "phase · detail".
|
|
2766
|
+
*
|
|
2767
|
+
* @param p - The deploy:phase event payload.
|
|
2768
|
+
* @example
|
|
2769
|
+
* ```ts
|
|
2770
|
+
* handler({ phase: "detect" }); // "detect"
|
|
2771
|
+
* handler({ phase: "upload", detail: "3 files" }); // "upload · 3 files"
|
|
2772
|
+
* ```
|
|
2773
|
+
*/
|
|
2774
|
+
"deploy:phase"(p) {
|
|
2775
|
+
ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
|
|
2776
|
+
},
|
|
2777
|
+
/**
|
|
2778
|
+
* Log the infra preflight summary: "infra · N exist, M to create · account".
|
|
2779
|
+
*
|
|
2780
|
+
* @param p - The provision:plan event payload.
|
|
2781
|
+
* @example
|
|
2782
|
+
* ```ts
|
|
2783
|
+
* handler({ exists: 2, missing: 1, account: "Play Co" }); // "infra · 2 exist, 1 to create · Play Co"
|
|
2784
|
+
* ```
|
|
2785
|
+
*/
|
|
2786
|
+
"provision:plan"(p) {
|
|
2787
|
+
ctx.log.info(`infra · ${p.exists} exist, ${p.missing} to create · ${p.account}`);
|
|
2788
|
+
},
|
|
2789
|
+
/**
|
|
2790
|
+
* Log one clean line per provisioned resource: "kind name".
|
|
2791
|
+
*
|
|
2792
|
+
* @param p - The provision:resource event payload.
|
|
2793
|
+
* @example
|
|
2794
|
+
* ```ts
|
|
2795
|
+
* handler({ kind: "kv", name: "KV" }); // "kv KV"
|
|
2796
|
+
* ```
|
|
2797
|
+
*/
|
|
2798
|
+
"provision:resource"(p) {
|
|
2799
|
+
ctx.log.info(`${p.kind} ${p.name}`);
|
|
2800
|
+
},
|
|
2801
|
+
/**
|
|
2802
|
+
* Log one clean line per already-existing resource (skipped): "kind name (exists)".
|
|
2803
|
+
*
|
|
2804
|
+
* @param p - The provision:skip event payload.
|
|
2805
|
+
* @example
|
|
2806
|
+
* ```ts
|
|
2807
|
+
* handler({ kind: "kv", name: "KV" }); // "kv KV (exists)"
|
|
2808
|
+
* ```
|
|
2809
|
+
*/
|
|
2810
|
+
"provision:skip"(p) {
|
|
2811
|
+
ctx.log.info(`${p.kind} ${p.name} (exists)`);
|
|
2812
|
+
},
|
|
2813
|
+
/**
|
|
2814
|
+
* Log one dev-session phase: "phase" or "phase · detail".
|
|
2815
|
+
*
|
|
2816
|
+
* @param p - The dev:phase event payload.
|
|
2817
|
+
* @example
|
|
2818
|
+
* ```ts
|
|
2819
|
+
* handler({ phase: "serve", detail: "http://localhost:8787" }); // "serve · http://localhost:8787"
|
|
2820
|
+
* ```
|
|
2821
|
+
*/
|
|
2822
|
+
"dev:phase"(p) {
|
|
2823
|
+
ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
|
|
2824
|
+
if (p.phase === "serve") ui.line(WRANGLER_DIVIDER);
|
|
2825
|
+
},
|
|
2826
|
+
/**
|
|
2827
|
+
* Log the site rebuild result: "site <n> files · <ms>ms" (omits the count when unknown).
|
|
2828
|
+
*
|
|
2829
|
+
* @param p - The dev:rebuilt event payload.
|
|
2830
|
+
* @example
|
|
2831
|
+
* ```ts
|
|
2832
|
+
* handler({ files: 12, ms: 240 }); // "site 12 files · 240ms"
|
|
2833
|
+
* handler({ files: 0, ms: 240 }); // "site · 240ms"
|
|
2834
|
+
* ```
|
|
2835
|
+
*/
|
|
2836
|
+
"dev:rebuilt"(p) {
|
|
2837
|
+
ctx.log.info(p.files > 0 ? `site ${String(p.files)} files · ${String(p.ms)}ms` : `site · ${String(p.ms)}ms`);
|
|
2838
|
+
},
|
|
2839
|
+
/**
|
|
2840
|
+
* Log a non-fatal dev build failure via warn (the session keeps serving the last good build).
|
|
2841
|
+
*
|
|
2842
|
+
* @param p - The dev:error event payload.
|
|
2843
|
+
* @example
|
|
2844
|
+
* ```ts
|
|
2845
|
+
* handler({ message: "build failed" }); // warn "build failed"
|
|
2846
|
+
* ```
|
|
2847
|
+
*/
|
|
2848
|
+
"dev:error"(p) {
|
|
2849
|
+
ctx.log.warn(p.message);
|
|
2850
|
+
},
|
|
2851
|
+
/**
|
|
2852
|
+
* Log the terminal success line with the deployed URL.
|
|
2853
|
+
*
|
|
2854
|
+
* @param p - The deploy:complete event payload.
|
|
2855
|
+
* @example
|
|
2856
|
+
* ```ts
|
|
2857
|
+
* handler({ url: "https://my-worker.workers.dev" }); // "deployed → https://my-worker.workers.dev"
|
|
2858
|
+
* ```
|
|
2859
|
+
*/
|
|
2860
|
+
"deploy:complete"(p) {
|
|
2861
|
+
ctx.log.info(`deployed → ${p.url}`);
|
|
2862
|
+
}
|
|
2863
|
+
};
|
|
2864
|
+
};
|
|
2865
|
+
/**
|
|
2866
|
+
* Standard tier (node-only) — developer-facing CLI surface.
|
|
2867
|
+
*
|
|
2868
|
+
* Mounts `app.cli.dev()` and `app.cli.deploy()` as thin passthroughs to deployPlugin.
|
|
2869
|
+
* Hooks subscribe to the global deploy:phase / provision:resource / deploy:complete events
|
|
2870
|
+
* and print a live progress TUI via the injected ctx.log core API.
|
|
2871
|
+
*
|
|
2872
|
+
* Inline lambdas on `api`/`hooks` preserve event-name inference so the hook map keys
|
|
2873
|
+
* are constrained to `WorkerEvents` keys (spec/15 §5).
|
|
2874
|
+
*
|
|
2875
|
+
* @see README.md
|
|
2876
|
+
*/
|
|
2877
|
+
const cliPlugin = createPlugin("cli", {
|
|
2878
|
+
depends: [deployPlugin],
|
|
2879
|
+
config: { port: 8787 },
|
|
2880
|
+
onInit: (ctx) => {
|
|
2881
|
+
ctx.log.clearSinks();
|
|
2882
|
+
ctx.log.addSink(brandedSink("info"));
|
|
2883
|
+
},
|
|
2884
|
+
api: (ctx) => createCliApi(ctx),
|
|
2885
|
+
hooks: (ctx) => createCliHooks(ctx)
|
|
2886
|
+
});
|
|
2887
|
+
//#endregion
|
|
2888
|
+
export { kvPlugin as a, d1Plugin as c, createCore as d, createPlugin as f, queuesPlugin as i, bindingsPlugin as l, deployPlugin as n, durableObjectsPlugin as o, stagePlugin as p, storagePlugin as r, defineDurableObject as s, cliPlugin as t, coreConfig as u };
|