@moku-labs/worker 0.6.0 → 0.7.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/dist/{cli-DgZv5A0G.mjs → cli-Bb37rYq_.mjs} +1253 -501
- package/dist/{cli-Dc0q0hIy.cjs → cli-C8DdTtzn.cjs} +1254 -502
- package/dist/cli.cjs +1 -1
- package/dist/cli.d.cts +1 -1
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +1 -1
- package/dist/{index-VZ99IAMv.d.cts → index-BKOUpKtC.d.cts} +60 -9
- package/dist/{index-VZ99IAMv.d.mts → index-BKOUpKtC.d.mts} +60 -9
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +331 -163
- package/dist/index.d.mts +331 -163
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { envPlugin, logPlugin } from "@moku-labs/common";
|
|
2
2
|
import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
|
|
3
3
|
import { brandedSink, createBrandConsole, createBrandPrompts } from "@moku-labs/common/cli";
|
|
4
|
+
import { access, readdir, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
4
6
|
import { spawn } from "node:child_process";
|
|
5
7
|
import { existsSync, readFileSync, watch } from "node:fs";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { readdir, stat, writeFile } from "node:fs/promises";
|
|
8
8
|
/**
|
|
9
9
|
* stage core plugin — deployment-stage / dev-mode detection, flat-injected on
|
|
10
10
|
* every regular plugin's context as `ctx.stage` (spec/02 §6). No state, no
|
|
@@ -158,115 +158,173 @@ const bindingsPlugin = createPlugin("bindings", {
|
|
|
158
158
|
api: createBindingsApi
|
|
159
159
|
});
|
|
160
160
|
//#endregion
|
|
161
|
+
//#region src/instances.ts
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the default instance key from a keyed-map config: the sole entry, or the one flagged
|
|
164
|
+
* `default: true`. Throws a branded error when there are no instances, or several without (or with
|
|
165
|
+
* more than one) `default: true`.
|
|
166
|
+
*
|
|
167
|
+
* @param instances - The keyed-map config (`Record<key, instance>`).
|
|
168
|
+
* @param kind - The resource kind, for the error message (e.g. "kv", "d1").
|
|
169
|
+
* @returns The default instance's key.
|
|
170
|
+
* @throws {Error} With a `[moku-worker]` prefix when no single default can be resolved.
|
|
171
|
+
* @example
|
|
172
|
+
* ```ts
|
|
173
|
+
* defaultInstanceKey({ main: { name: "db", binding: "DB" } }, "d1"); // "main"
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
const defaultInstanceKey = (instances, kind) => {
|
|
177
|
+
const keys = Object.keys(instances);
|
|
178
|
+
if (keys.length === 0) throw new Error(`[moku-worker] No ${kind} instance is configured.`);
|
|
179
|
+
if (keys.length === 1) return keys[0];
|
|
180
|
+
const flagged = keys.filter((key) => instances[key]?.default === true);
|
|
181
|
+
if (flagged.length === 1) return flagged[0];
|
|
182
|
+
throw new Error(`[moku-worker] ${kind} has ${String(keys.length)} instances — mark exactly one with \`default: true\`.`);
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* Look up a resource instance by key, with a branded error listing the configured keys when absent.
|
|
186
|
+
*
|
|
187
|
+
* @param instances - The keyed-map config (`Record<key, instance>`).
|
|
188
|
+
* @param key - The instance key to resolve (the `use(key)` selector).
|
|
189
|
+
* @param kind - The resource kind, for the error message.
|
|
190
|
+
* @returns The instance at `key`.
|
|
191
|
+
* @throws {Error} With a `[moku-worker]` prefix when `key` is not configured.
|
|
192
|
+
* @example
|
|
193
|
+
* ```ts
|
|
194
|
+
* pickInstance(cfg, "analytics", "d1");
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
const pickInstance = (instances, key, kind) => {
|
|
198
|
+
const instance = instances[key];
|
|
199
|
+
if (instance === void 0) {
|
|
200
|
+
const configured = Object.keys(instances).join(", ") || "(none)";
|
|
201
|
+
throw new Error(`[moku-worker] No ${kind} instance "${key}". Configured: ${configured}.`);
|
|
202
|
+
}
|
|
203
|
+
return instance;
|
|
204
|
+
};
|
|
205
|
+
//#endregion
|
|
161
206
|
//#region src/plugins/d1/api.ts
|
|
162
207
|
/**
|
|
163
|
-
* Create the d1 api
|
|
164
|
-
* `
|
|
165
|
-
*
|
|
208
|
+
* Create the d1 api over a keyed map of database instances. The default-database methods and
|
|
209
|
+
* `use(key)` both resolve the `D1Database` off the REQUEST-SUPPLIED env on every call — env is
|
|
210
|
+
* threaded, never stored (SB4), so concurrent requests stay isolated — and the instance key is
|
|
211
|
+
* resolved lazily by binding-getter so an unconfigured-but-present plugin only errors when actually
|
|
212
|
+
* called.
|
|
166
213
|
*
|
|
167
214
|
* The return is intentionally NOT annotated `: Api`. Annotating it would
|
|
168
215
|
* collapse the per-method call-site generic `<T>` on `query`/`first` to
|
|
169
216
|
* `unknown`; instead the implementation forwards `<T>` to `all<T>()` /
|
|
170
217
|
* `first<T>()` and `types.ts#Api` remains the public-surface source of truth.
|
|
171
218
|
*
|
|
172
|
-
* @param {D1Ctx} ctx - Plugin context (
|
|
173
|
-
* @returns {object} The d1 public api (query, first, run, batch, prepare, deployManifest).
|
|
219
|
+
* @param {D1Ctx} ctx - Plugin context (keyed-map config + require).
|
|
220
|
+
* @returns {object} The d1 public api (query, first, run, batch, prepare, use, deployManifest).
|
|
174
221
|
* @example
|
|
175
222
|
* ```typescript
|
|
176
223
|
* const api = createD1Api(ctx);
|
|
177
224
|
* const { results } = await api.query<Product>(env, "SELECT * FROM products");
|
|
225
|
+
* await api.use("analytics").run(env, "INSERT INTO events (name) VALUES (?)", "click");
|
|
178
226
|
* ```
|
|
179
227
|
*/
|
|
180
228
|
const createD1Api = (ctx) => {
|
|
181
|
-
const
|
|
229
|
+
const bindings = ctx.require(bindingsPlugin);
|
|
230
|
+
const surface = (binding) => {
|
|
231
|
+
const db = (env) => bindings.require(env, binding());
|
|
232
|
+
return {
|
|
233
|
+
/**
|
|
234
|
+
* Run a statement against this database and return all rows.
|
|
235
|
+
*
|
|
236
|
+
* @param env - The per-request Cloudflare env.
|
|
237
|
+
* @param sql - SQL with `?` placeholders.
|
|
238
|
+
* @param params - Bind parameters for the placeholders.
|
|
239
|
+
* @returns All rows in a D1 result.
|
|
240
|
+
* @example
|
|
241
|
+
* ```typescript
|
|
242
|
+
* const { results } = await api.query<Product>(env, "SELECT * FROM products");
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
query: (env, sql, ...params) => db(env).prepare(sql).bind(...params).all(),
|
|
246
|
+
/**
|
|
247
|
+
* Run a statement against this database and return the first row, or null when none.
|
|
248
|
+
*
|
|
249
|
+
* @param env - The per-request Cloudflare env.
|
|
250
|
+
* @param sql - SQL with `?` placeholders.
|
|
251
|
+
* @param params - Bind parameters for the placeholders.
|
|
252
|
+
* @returns The first row, or null if none.
|
|
253
|
+
* @example
|
|
254
|
+
* ```typescript
|
|
255
|
+
* const product = await api.first<Product>(env, "SELECT * FROM products WHERE id = ?", 1);
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
first: (env, sql, ...params) => db(env).prepare(sql).bind(...params).first(),
|
|
259
|
+
/**
|
|
260
|
+
* Run a write/DDL statement against this database and return its result meta.
|
|
261
|
+
*
|
|
262
|
+
* @param env - The per-request Cloudflare env.
|
|
263
|
+
* @param sql - SQL with `?` placeholders.
|
|
264
|
+
* @param params - Bind parameters for the placeholders.
|
|
265
|
+
* @returns Result carrying `.meta`.
|
|
266
|
+
* @example
|
|
267
|
+
* ```typescript
|
|
268
|
+
* await api.run(env, "INSERT INTO events (name) VALUES (?)", "click");
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
run: (env, sql, ...params) => db(env).prepare(sql).bind(...params).run(),
|
|
272
|
+
/**
|
|
273
|
+
* Execute caller-built prepared statements atomically in one round-trip.
|
|
274
|
+
*
|
|
275
|
+
* @param env - The per-request Cloudflare env.
|
|
276
|
+
* @param stmts - Caller-built prepared statements.
|
|
277
|
+
* @returns One result per statement, order preserved.
|
|
278
|
+
* @example
|
|
279
|
+
* ```typescript
|
|
280
|
+
* await api.batch(env, [api.prepare(env).prepare("INSERT INTO t (id) VALUES (1)")]);
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
283
|
+
batch: (env, stmts) => db(env).batch(stmts),
|
|
284
|
+
/**
|
|
285
|
+
* Resolve the request `D1Database` so callers can build statements for `batch()`.
|
|
286
|
+
*
|
|
287
|
+
* @param env - The per-request Cloudflare env.
|
|
288
|
+
* @returns The request-resolved database handle.
|
|
289
|
+
* @example
|
|
290
|
+
* ```typescript
|
|
291
|
+
* const stmt = api.prepare(env).prepare("SELECT * FROM products");
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
prepare: (env) => db(env)
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "d1"), "d1").binding;
|
|
182
298
|
return {
|
|
299
|
+
...surface(defaultBinding),
|
|
183
300
|
/**
|
|
184
|
-
*
|
|
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.
|
|
301
|
+
* Select a specific D1 database instance by its config key.
|
|
229
302
|
*
|
|
230
|
-
* @param
|
|
231
|
-
* @
|
|
232
|
-
* @returns {Promise<D1Result[]>} One result per statement, order preserved.
|
|
303
|
+
* @param key - The instance key (as configured under `pluginConfigs.d1`).
|
|
304
|
+
* @returns The SQL surface bound to that database.
|
|
233
305
|
* @example
|
|
234
306
|
* ```typescript
|
|
235
|
-
*
|
|
236
|
-
* await api.batch(env, [handle.prepare("INSERT INTO a VALUES (1)").bind()]);
|
|
307
|
+
* await api.use("analytics").run(env, "INSERT INTO events (name) VALUES (?)", "click");
|
|
237
308
|
* ```
|
|
238
309
|
*/
|
|
239
|
-
|
|
310
|
+
use: (key) => surface(() => pickInstance(ctx.config, key, "d1").binding),
|
|
240
311
|
/**
|
|
241
|
-
*
|
|
242
|
-
* statements for batch(). Issues no query itself.
|
|
312
|
+
* Return this plugin's deploy metadata — one descriptor per configured database.
|
|
243
313
|
*
|
|
244
|
-
* @
|
|
245
|
-
* @returns {D1Database} The request-resolved database handle.
|
|
314
|
+
* @returns One d1 deploy descriptor per instance.
|
|
246
315
|
* @example
|
|
247
316
|
* ```typescript
|
|
248
|
-
* const
|
|
249
|
-
* const stmt = handle.prepare("SELECT * FROM t").bind();
|
|
317
|
+
* const manifest = api.deployManifest(); // [{ kind: "d1", name: "tracker-db", binding: "DB" }]
|
|
250
318
|
* ```
|
|
251
319
|
*/
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
320
|
+
deployManifest: () => Object.values(ctx.config).map((instance) => {
|
|
321
|
+
const entry = {
|
|
322
|
+
kind: "d1",
|
|
323
|
+
name: instance.name,
|
|
324
|
+
binding: instance.binding
|
|
325
|
+
};
|
|
326
|
+
if (instance.migrations !== void 0) entry.migrations = instance.migrations;
|
|
327
|
+
return entry;
|
|
270
328
|
})
|
|
271
329
|
};
|
|
272
330
|
};
|
|
@@ -281,10 +339,7 @@ const createD1Api = (ctx) => {
|
|
|
281
339
|
*/
|
|
282
340
|
const d1Plugin = createPlugin("d1", {
|
|
283
341
|
depends: [bindingsPlugin],
|
|
284
|
-
config: {
|
|
285
|
-
binding: "DB",
|
|
286
|
-
migrations: ""
|
|
287
|
-
},
|
|
342
|
+
config: {},
|
|
288
343
|
api: (ctx) => createD1Api(ctx)
|
|
289
344
|
});
|
|
290
345
|
//#endregion
|
|
@@ -293,59 +348,62 @@ const d1Plugin = createPlugin("d1", {
|
|
|
293
348
|
* Builds the `app.durableObjects` API surface — `get` and `deployManifest`.
|
|
294
349
|
*
|
|
295
350
|
* All namespace resolution uses the per-call `env` argument: `env` is threaded, never
|
|
296
|
-
* stored (SB4 / design §1a). The config
|
|
351
|
+
* stored (SB4 / design §1a). The keyed-map config is frozen and read-only. No state
|
|
297
352
|
* is held on the plugin between calls (stateless — `Record<string, never>`).
|
|
298
353
|
*
|
|
299
|
-
* @param ctx - Plugin context with `config
|
|
354
|
+
* @param ctx - Plugin context with the keyed-map `config`, `require(bindingsPlugin)`, and core APIs.
|
|
300
355
|
* @returns The durableObjects API: `{ get, deployManifest }`.
|
|
301
356
|
* @example
|
|
302
357
|
* ```typescript
|
|
303
358
|
* const api = createDoApi(ctx);
|
|
304
|
-
* const stub = api.get(env, "
|
|
305
|
-
* const manifest = api.deployManifest(); // { kind: "do",
|
|
359
|
+
* const stub = api.get(env, "board", "room-42");
|
|
360
|
+
* const manifest = api.deployManifest(); // [{ kind: "do", binding: "BOARD", className: "BoardChannel" }]
|
|
306
361
|
* ```
|
|
307
362
|
*/
|
|
308
363
|
const createDoApi = (ctx) => ({
|
|
309
364
|
/**
|
|
310
365
|
* Resolves a `DurableObjectStub` off the per-request env.
|
|
311
366
|
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
367
|
+
* Selects the configured instance by `logicalName` (the config key) via `pickInstance`, resolves
|
|
368
|
+
* its `binding` off `env`, derives a deterministic id via `namespace.idFromName(idName)`, and
|
|
369
|
+
* returns the addressed stub. Synchronous — returns a stub, not a Promise. Throws (branded) when
|
|
370
|
+
* `logicalName` is not configured, or (via the bindings resolver) when the binding is not present
|
|
371
|
+
* on `env`.
|
|
316
372
|
*
|
|
317
373
|
* @param env - Per-request Cloudflare bindings object (Worker fetch/queue/scheduled env).
|
|
318
|
-
* @param logicalName - Logical DO
|
|
374
|
+
* @param logicalName - Logical DO key (selects the configured instance, e.g. `"board"`).
|
|
319
375
|
* @param idName - Stable id name passed to `idFromName` (e.g. `"room-42"`).
|
|
320
376
|
* @returns The addressed `DurableObjectStub`.
|
|
321
|
-
* @throws {Error} With `[moku-worker]` prefix when
|
|
377
|
+
* @throws {Error} With `[moku-worker]` prefix when `logicalName` is not configured, or when the
|
|
378
|
+
* binding is not bound on `env`.
|
|
322
379
|
* @example
|
|
323
380
|
* ```typescript
|
|
324
|
-
* const stub = app.durableObjects.get(env, "
|
|
381
|
+
* const stub = app.durableObjects.get(env, "board", "room-42");
|
|
325
382
|
* const res = await stub.fetch("https://do/increment");
|
|
326
383
|
* ```
|
|
327
384
|
*/
|
|
328
385
|
get: (env, logicalName, idName) => {
|
|
329
|
-
const binding = ctx.config
|
|
386
|
+
const binding = pickInstance(ctx.config, logicalName, "durableObjects").binding;
|
|
330
387
|
const ns = ctx.require(bindingsPlugin).require(env, binding);
|
|
331
388
|
return ns.get(ns.idFromName(idName));
|
|
332
389
|
},
|
|
333
390
|
/**
|
|
334
|
-
* Returns this plugin's deploy metadata — read by the `deploy`
|
|
335
|
-
* `ctx.require(durableObjectsPlugin)`. Never reads sibling `pluginConfigs` (F6;
|
|
336
|
-
* spec/08 §5, §7). Pure synchronous read of `ctx.config
|
|
391
|
+
* Returns this plugin's deploy metadata — one entry per configured instance, read by the `deploy`
|
|
392
|
+
* plugin via `ctx.require(durableObjectsPlugin)`. Never reads sibling `pluginConfigs` (F6;
|
|
393
|
+
* spec/08 §5, §7). Pure synchronous read of `ctx.config`.
|
|
337
394
|
*
|
|
338
|
-
* @returns `{ kind: "do",
|
|
395
|
+
* @returns One `{ kind: "do", binding, className }` per configured instance.
|
|
339
396
|
* @example
|
|
340
397
|
* ```typescript
|
|
341
398
|
* const manifest = app.durableObjects.deployManifest();
|
|
342
|
-
* // → { kind: "do",
|
|
399
|
+
* // → [{ kind: "do", binding: "BOARD", className: "BoardChannel" }]
|
|
343
400
|
* ```
|
|
344
401
|
*/
|
|
345
|
-
deployManifest: () => ({
|
|
402
|
+
deployManifest: () => Object.values(ctx.config).map((instance) => ({
|
|
346
403
|
kind: "do",
|
|
347
|
-
|
|
348
|
-
|
|
404
|
+
binding: instance.binding,
|
|
405
|
+
className: instance.className
|
|
406
|
+
}))
|
|
349
407
|
});
|
|
350
408
|
//#endregion
|
|
351
409
|
//#region src/plugins/durable-objects/helpers.ts
|
|
@@ -418,17 +476,17 @@ const defineDurableObject = (name) => {
|
|
|
418
476
|
* Cloudflare Durable Objects plugin — Standard tier.
|
|
419
477
|
*
|
|
420
478
|
* Exposes `get(env, logicalName, idName)` (synchronous stub accessor, threaded env) and
|
|
421
|
-
* `deployManifest()` (build-time metadata). Depends on
|
|
422
|
-
* resolution. The `defineDurableObject` helper is mounted under
|
|
423
|
-
* at the top level for consumer use.
|
|
479
|
+
* `deployManifest()` (build-time metadata, one entry per configured instance). Depends on
|
|
480
|
+
* `bindingsPlugin` for namespace resolution. The `defineDurableObject` helper is mounted under
|
|
481
|
+
* `helpers` and re-exported at the top level for consumer use.
|
|
424
482
|
*
|
|
425
483
|
* @example
|
|
426
484
|
* ```typescript
|
|
427
485
|
* // Consumer endpoint handler:
|
|
428
|
-
* const stub = app.durableObjects.get(env, "
|
|
486
|
+
* const stub = app.durableObjects.get(env, "board", params.room!);
|
|
429
487
|
* const res = await stub.fetch("https://do/increment");
|
|
430
|
-
* // Consumer DO class:
|
|
431
|
-
* export class
|
|
488
|
+
* // Consumer DO class (the EXPORTED className referenced by the "board" instance):
|
|
489
|
+
* export class BoardChannel extends defineDurableObject("BoardChannel") {
|
|
432
490
|
* async fetch(): Promise<Response> { return new Response("ok"); }
|
|
433
491
|
* }
|
|
434
492
|
* ```
|
|
@@ -436,91 +494,112 @@ const defineDurableObject = (name) => {
|
|
|
436
494
|
*/
|
|
437
495
|
const durableObjectsPlugin = createPlugin("durableObjects", {
|
|
438
496
|
depends: [bindingsPlugin],
|
|
439
|
-
config: {
|
|
497
|
+
config: {},
|
|
440
498
|
api: createDoApi,
|
|
441
499
|
helpers: { defineDurableObject }
|
|
442
500
|
});
|
|
443
501
|
//#endregion
|
|
444
502
|
//#region src/plugins/kv/api.ts
|
|
445
503
|
/**
|
|
446
|
-
* Builds the app.kv.* api
|
|
447
|
-
* on every call — env is threaded,
|
|
504
|
+
* Builds the app.kv.* api over a keyed map of namespace instances. The default-namespace methods and
|
|
505
|
+
* `use(key)` both resolve the namespace off the REQUEST-SUPPLIED env on every call — env is threaded,
|
|
506
|
+
* never stored (design §1a / SB4) — and the instance key is resolved lazily so an unconfigured-but-
|
|
507
|
+
* present plugin only errors when actually called.
|
|
448
508
|
*
|
|
449
|
-
* @param ctx - The kv plugin context (
|
|
450
|
-
* @returns The app.kv api: get / put / delete / list / deployManifest.
|
|
509
|
+
* @param ctx - The kv plugin context (keyed-map config + merged events).
|
|
510
|
+
* @returns The app.kv api: get / put / delete / list / use / deployManifest.
|
|
451
511
|
* @example
|
|
452
512
|
* ```typescript
|
|
453
513
|
* const api = createKvApi(ctx);
|
|
454
514
|
* const value = await api.get(env, "key");
|
|
515
|
+
* await api.use("sessions").put(env, "s:1", "data");
|
|
455
516
|
* ```
|
|
456
517
|
*/
|
|
457
518
|
const createKvApi = (ctx) => {
|
|
458
|
-
const
|
|
519
|
+
const bindings = ctx.require(bindingsPlugin);
|
|
520
|
+
const surface = (binding) => {
|
|
521
|
+
const ns = (env) => bindings.require(env, binding());
|
|
522
|
+
return {
|
|
523
|
+
/**
|
|
524
|
+
* Read a value by key from this namespace. Returns null when absent.
|
|
525
|
+
*
|
|
526
|
+
* @param env - The per-request Cloudflare env.
|
|
527
|
+
* @param key - The key to read.
|
|
528
|
+
* @returns The stored value, or null when absent.
|
|
529
|
+
* @example
|
|
530
|
+
* ```typescript
|
|
531
|
+
* const value = await api.get(env, "feature-flags");
|
|
532
|
+
* ```
|
|
533
|
+
*/
|
|
534
|
+
get: async (env, key) => ns(env).get(key),
|
|
535
|
+
/**
|
|
536
|
+
* Write a string value under a key, optionally with KV put options.
|
|
537
|
+
*
|
|
538
|
+
* @param env - The per-request Cloudflare env.
|
|
539
|
+
* @param key - The key to write.
|
|
540
|
+
* @param value - The string value to store.
|
|
541
|
+
* @param opts - Optional expiration / metadata.
|
|
542
|
+
* @returns Resolves once the write is acknowledged.
|
|
543
|
+
* @example
|
|
544
|
+
* ```typescript
|
|
545
|
+
* await api.put(env, "session:1", "data", { expirationTtl: 3600 });
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
put: async (env, key, value, opts) => ns(env).put(key, value, opts),
|
|
549
|
+
/**
|
|
550
|
+
* Remove a key from this namespace (no-op if absent).
|
|
551
|
+
*
|
|
552
|
+
* @param env - The per-request Cloudflare env.
|
|
553
|
+
* @param key - The key to delete.
|
|
554
|
+
* @returns Resolves once the delete is acknowledged.
|
|
555
|
+
* @example
|
|
556
|
+
* ```typescript
|
|
557
|
+
* await api.delete(env, "session:expired");
|
|
558
|
+
* ```
|
|
559
|
+
*/
|
|
560
|
+
delete: async (env, key) => ns(env).delete(key),
|
|
561
|
+
/**
|
|
562
|
+
* List keys in this namespace, optionally filtered/paginated.
|
|
563
|
+
*
|
|
564
|
+
* @param env - The per-request Cloudflare env.
|
|
565
|
+
* @param opts - Optional prefix / cursor / limit.
|
|
566
|
+
* @returns The list result.
|
|
567
|
+
* @example
|
|
568
|
+
* ```typescript
|
|
569
|
+
* const { keys } = await api.list(env, { prefix: "session:" });
|
|
570
|
+
* ```
|
|
571
|
+
*/
|
|
572
|
+
list: async (env, opts) => ns(env).list(opts)
|
|
573
|
+
};
|
|
574
|
+
};
|
|
575
|
+
const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "kv"), "kv").binding;
|
|
459
576
|
return {
|
|
577
|
+
...surface(defaultBinding),
|
|
460
578
|
/**
|
|
461
|
-
*
|
|
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.
|
|
579
|
+
* Select a specific KV namespace instance by its config key.
|
|
474
580
|
*
|
|
475
|
-
* @param
|
|
476
|
-
* @
|
|
477
|
-
* @param value - The string value to store.
|
|
478
|
-
* @param opts - Optional expiration / metadata.
|
|
479
|
-
* @returns Resolves once the write is acknowledged.
|
|
581
|
+
* @param key - The instance key (as configured under `pluginConfigs.kv`).
|
|
582
|
+
* @returns The key/value surface bound to that namespace.
|
|
480
583
|
* @example
|
|
481
584
|
* ```typescript
|
|
482
|
-
* await api.
|
|
585
|
+
* await api.use("sessions").get(env, "s:1");
|
|
483
586
|
* ```
|
|
484
587
|
*/
|
|
485
|
-
|
|
588
|
+
use: (key) => surface(() => pickInstance(ctx.config, key, "kv").binding),
|
|
486
589
|
/**
|
|
487
|
-
*
|
|
590
|
+
* Return this plugin's deploy metadata — one descriptor per configured namespace.
|
|
488
591
|
*
|
|
489
|
-
* @
|
|
490
|
-
* @param key - The key to delete.
|
|
491
|
-
* @returns Resolves once the delete is acknowledged.
|
|
592
|
+
* @returns One kv deploy descriptor per instance.
|
|
492
593
|
* @example
|
|
493
594
|
* ```typescript
|
|
494
|
-
*
|
|
595
|
+
* const manifest = api.deployManifest(); // [{ kind: "kv", name: "tracker-cache", binding: "CACHE" }]
|
|
495
596
|
* ```
|
|
496
597
|
*/
|
|
497
|
-
|
|
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: () => ({
|
|
598
|
+
deployManifest: () => Object.values(ctx.config).map((instance) => ({
|
|
521
599
|
kind: "kv",
|
|
522
|
-
|
|
523
|
-
|
|
600
|
+
name: instance.name,
|
|
601
|
+
binding: instance.binding
|
|
602
|
+
}))
|
|
524
603
|
};
|
|
525
604
|
};
|
|
526
605
|
/**
|
|
@@ -534,109 +613,113 @@ const createKvApi = (ctx) => {
|
|
|
534
613
|
*/
|
|
535
614
|
const kvPlugin = createPlugin("kv", {
|
|
536
615
|
depends: [bindingsPlugin],
|
|
537
|
-
config: {
|
|
616
|
+
config: {},
|
|
538
617
|
api: createKvApi
|
|
539
618
|
});
|
|
540
619
|
//#endregion
|
|
541
620
|
//#region src/plugins/queues/api.ts
|
|
542
621
|
/**
|
|
543
|
-
*
|
|
622
|
+
* Resolve the instance a consumed batch belongs to. With a single instance, that instance always
|
|
623
|
+
* matches. With several, match the instance whose `name` equals `batch.queue` OR whose stage-suffixed
|
|
624
|
+
* form (`${name}-`) prefixes it (tolerant of the deploy stage suffix, e.g. `tracker-activity-dev`);
|
|
625
|
+
* fall back to the default instance when nothing matches.
|
|
544
626
|
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
* The `
|
|
627
|
+
* @param config - The keyed-map queues config.
|
|
628
|
+
* @param queueName - The CF queue name from `batch.queue`.
|
|
629
|
+
* @returns The matched `QueueInstance` (its `onMessage` is what `consume` awaits).
|
|
630
|
+
* @example
|
|
631
|
+
* ```ts
|
|
632
|
+
* routeInstance(cfg, "tracker-activity-dev"); // → the `activity` instance
|
|
633
|
+
* ```
|
|
548
634
|
*/
|
|
635
|
+
const routeInstance = (config, queueName) => {
|
|
636
|
+
const keys = Object.keys(config);
|
|
637
|
+
if (keys.length === 1) return pickInstance(config, keys[0], "queues");
|
|
638
|
+
return Object.values(config).find((instance) => instance.name === queueName || queueName.startsWith(`${instance.name}-`)) ?? pickInstance(config, defaultInstanceKey(config, "queues"), "queues");
|
|
639
|
+
};
|
|
549
640
|
/**
|
|
550
|
-
* Builds app.queues.* — read by worker.ts queue() delegation
|
|
641
|
+
* Builds app.queues.* over a keyed map of Queue instances — read by worker.ts queue() delegation
|
|
642
|
+
* (design §1d; spec/02 §7). The default-instance producer methods and `use(key)` both resolve the
|
|
643
|
+
* Queue off the REQUEST-SUPPLIED env on every call (env is threaded, never stored — SB4); the
|
|
644
|
+
* instance key is resolved lazily. Emits `queue:message` for observability after each consumed
|
|
645
|
+
* message (F8).
|
|
551
646
|
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
* @param ctx - Plugin context (own config + require + emit).
|
|
556
|
-
* @returns The queues API surface: send, sendBatch, consume, deployManifest.
|
|
647
|
+
* @param ctx - Plugin context (keyed-map config + require + emit).
|
|
648
|
+
* @returns The queues API surface: send, sendBatch, use, consume, deployManifest.
|
|
557
649
|
* @example
|
|
558
650
|
* ```ts
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
651
|
+
* const api = createQueuesApi(ctx);
|
|
652
|
+
* await api.send(env, { orderId: "1" }); // default instance
|
|
653
|
+
* await api.use("activity").send(env, { id: 2 }); // a named instance
|
|
654
|
+
* // Worker entry (design §1d): queue: (b, e, c) => app.queues.consume(b, e, c)
|
|
563
655
|
* ```
|
|
564
656
|
*/
|
|
565
657
|
const createQueuesApi = (ctx) => {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
658
|
+
const bindings = ctx.require(bindingsPlugin);
|
|
659
|
+
const surface = (binding) => {
|
|
660
|
+
const queue = (env) => bindings.require(env, binding());
|
|
661
|
+
return {
|
|
662
|
+
/**
|
|
663
|
+
* Enqueue a single message onto this instance's queue.
|
|
664
|
+
*
|
|
665
|
+
* @param env - The per-request Cloudflare env.
|
|
666
|
+
* @param body - The message body to enqueue.
|
|
667
|
+
* @returns Resolves once the message is enqueued.
|
|
668
|
+
* @example
|
|
669
|
+
* ```typescript
|
|
670
|
+
* await api.send(env, { userId: "u1" });
|
|
671
|
+
* ```
|
|
672
|
+
*/
|
|
673
|
+
send: async (env, body) => {
|
|
674
|
+
await queue(env).send(body);
|
|
675
|
+
},
|
|
676
|
+
/**
|
|
677
|
+
* Enqueue many messages onto this instance's queue; each element becomes one message.
|
|
678
|
+
*
|
|
679
|
+
* @param env - The per-request Cloudflare env.
|
|
680
|
+
* @param bodies - Array of message bodies; each becomes one message.
|
|
681
|
+
* @returns Resolves once all messages are enqueued.
|
|
682
|
+
* @example
|
|
683
|
+
* ```typescript
|
|
684
|
+
* await api.sendBatch(env, [{ id: 1 }, { id: 2 }]);
|
|
685
|
+
* ```
|
|
686
|
+
*/
|
|
687
|
+
sendBatch: async (env, bodies) => {
|
|
688
|
+
await queue(env).sendBatch(bodies.map((body) => ({ body })));
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
};
|
|
692
|
+
const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "queues"), "queues").binding;
|
|
579
693
|
return {
|
|
694
|
+
...surface(defaultBinding),
|
|
580
695
|
/**
|
|
581
|
-
*
|
|
696
|
+
* Select a specific Queue instance by its config key.
|
|
582
697
|
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
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.
|
|
698
|
+
* @param key - The instance key (as configured under `pluginConfigs.queues`).
|
|
699
|
+
* @returns The producer surface bound to that instance.
|
|
591
700
|
* @example
|
|
592
|
-
* ```
|
|
593
|
-
* await
|
|
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);
|
|
701
|
+
* ```typescript
|
|
702
|
+
* await api.use("activity").send(env, { id: 2 });
|
|
612
703
|
* ```
|
|
613
704
|
*/
|
|
614
|
-
|
|
615
|
-
await queue(env, q).sendBatch(bodies.map((body) => ({ body })));
|
|
616
|
-
},
|
|
705
|
+
use: (key) => surface(() => pickInstance(ctx.config, key, "queues").binding),
|
|
617
706
|
/**
|
|
618
|
-
* Consumer dispatch — the Worker's `queue()` export delegates here.
|
|
707
|
+
* Consumer dispatch — the Worker's `queue()` export delegates here. Routes the batch to the
|
|
708
|
+
* matching instance's `onMessage` and emits `queue:message` per message.
|
|
619
709
|
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
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.
|
|
710
|
+
* @param batch - The incoming message batch.
|
|
711
|
+
* @param env - The per-request Cloudflare env.
|
|
712
|
+
* @param _ctx - The execution context (waitUntil / passThroughOnException); unused.
|
|
713
|
+
* @returns Resolves after all messages settle.
|
|
631
714
|
* @example
|
|
632
|
-
* ```
|
|
633
|
-
* // Worker entry
|
|
634
|
-
* queue: (b, e, c) => app.queues.consume(b, e, c),
|
|
715
|
+
* ```typescript
|
|
716
|
+
* // Worker entry (design §1d): queue: (b, e, c) => app.queues.consume(b, e, c)
|
|
635
717
|
* ```
|
|
636
718
|
*/
|
|
637
|
-
consume: async (batch, env,
|
|
719
|
+
consume: async (batch, env, _ctx) => {
|
|
720
|
+
const instance = routeInstance(ctx.config, batch.queue);
|
|
638
721
|
for (const m of batch.messages) {
|
|
639
|
-
await
|
|
722
|
+
if (instance.onMessage) await instance.onMessage(m, env);
|
|
640
723
|
ctx.emit("queue:message", {
|
|
641
724
|
queue: batch.queue,
|
|
642
725
|
messageId: m.id
|
|
@@ -644,24 +727,24 @@ const createQueuesApi = (ctx) => {
|
|
|
644
727
|
}
|
|
645
728
|
},
|
|
646
729
|
/**
|
|
647
|
-
*
|
|
648
|
-
* `ctx.require(queuesPlugin).deployManifest()` (F6 — never reads sibling config).
|
|
730
|
+
* Return this plugin's deploy metadata — one descriptor per configured instance.
|
|
649
731
|
*
|
|
650
|
-
* @returns
|
|
732
|
+
* @returns One queue deploy descriptor per instance.
|
|
651
733
|
* @example
|
|
652
|
-
* ```
|
|
653
|
-
* const manifest =
|
|
654
|
-
* // → { kind: "queue", producers: ["orders"] }
|
|
734
|
+
* ```typescript
|
|
735
|
+
* const manifest = api.deployManifest(); // [{ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }]
|
|
655
736
|
* ```
|
|
656
737
|
*/
|
|
657
|
-
deployManifest: () => ({
|
|
738
|
+
deployManifest: () => Object.values(ctx.config).map((instance) => ({
|
|
658
739
|
kind: "queue",
|
|
659
|
-
|
|
660
|
-
|
|
740
|
+
name: instance.name,
|
|
741
|
+
binding: instance.binding
|
|
742
|
+
}))
|
|
661
743
|
};
|
|
662
744
|
};
|
|
663
745
|
/**
|
|
664
|
-
* Standard tier — Cloudflare Queues producer + consumer dispatch
|
|
746
|
+
* Standard tier — Cloudflare Queues producer + per-instance consumer dispatch over a keyed map of
|
|
747
|
+
* instances.
|
|
665
748
|
*
|
|
666
749
|
* `events` is declared first and via `register.map<QueueEvents>` so the plugin's own events infer
|
|
667
750
|
* into the factory context; the api wiring is therefore arrow-wrapped (contextually typed).
|
|
@@ -673,10 +756,7 @@ const createQueuesApi = (ctx) => {
|
|
|
673
756
|
const queuesPlugin = createPlugin("queues", {
|
|
674
757
|
events: (register) => register.map({ "queue:message": "A queue message was processed" }),
|
|
675
758
|
depends: [bindingsPlugin],
|
|
676
|
-
config: {
|
|
677
|
-
producers: [],
|
|
678
|
-
onMessage: async () => {}
|
|
679
|
-
},
|
|
759
|
+
config: {},
|
|
680
760
|
api: (ctx) => createQueuesApi(ctx)
|
|
681
761
|
});
|
|
682
762
|
//#endregion
|
|
@@ -771,101 +851,108 @@ const resolveR2Provider = (bindings, env, bucket) => {
|
|
|
771
851
|
//#endregion
|
|
772
852
|
//#region src/plugins/storage/api.ts
|
|
773
853
|
/**
|
|
774
|
-
* Build the
|
|
775
|
-
*
|
|
776
|
-
*
|
|
854
|
+
* Build the app.storage.* api over a keyed map of R2 bucket instances. The default-bucket methods and
|
|
855
|
+
* `use(key)` both resolve the bucket off the REQUEST-SUPPLIED env on every call — env is threaded,
|
|
856
|
+
* never stored (worker-api-design SB4; spec/08 §6,§7) — and the instance key is resolved lazily so an
|
|
857
|
+
* unconfigured-but-present plugin only errors when actually called.
|
|
777
858
|
*
|
|
778
859
|
* The `deployManifest()` method is build-time only: it reads from `ctx.config`
|
|
779
860
|
* and never touches `env` or R2.
|
|
780
861
|
*
|
|
781
|
-
* @param ctx - Plugin context (config + require for bindings resolution).
|
|
782
|
-
* @returns {StorageApi} The
|
|
862
|
+
* @param ctx - Plugin context (keyed-map config + require for bindings resolution).
|
|
863
|
+
* @returns {StorageApi} The app.storage api: get / put / delete / list / use / deployManifest.
|
|
783
864
|
* @example
|
|
784
865
|
* ```typescript
|
|
785
866
|
* const api = createStorageApi(ctx);
|
|
786
867
|
* const body = await api.get(env, "my-object");
|
|
868
|
+
* await api.use("uploads").put(env, "avatar.png", buffer);
|
|
787
869
|
* ```
|
|
788
870
|
*/
|
|
789
871
|
const createStorageApi = (ctx) => {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
872
|
+
const bindings = ctx.require(bindingsPlugin);
|
|
873
|
+
const surface = (binding) => {
|
|
874
|
+
const provider = (env) => resolveR2Provider(bindings, env, binding());
|
|
875
|
+
return {
|
|
876
|
+
/**
|
|
877
|
+
* Read an object from this bucket; resolves null when the key is absent.
|
|
878
|
+
*
|
|
879
|
+
* @param env - The per-request Cloudflare env.
|
|
880
|
+
* @param key - The object key to read.
|
|
881
|
+
* @returns The object body, or null.
|
|
882
|
+
* @example
|
|
883
|
+
* ```typescript
|
|
884
|
+
* const body = await api.get(env, "assets/logo.png");
|
|
885
|
+
* ```
|
|
886
|
+
*/
|
|
887
|
+
get: (env, key) => provider(env).get(key),
|
|
888
|
+
/**
|
|
889
|
+
* Write an object to this bucket.
|
|
890
|
+
*
|
|
891
|
+
* @param env - The per-request Cloudflare env.
|
|
892
|
+
* @param key - The object key to write.
|
|
893
|
+
* @param value - The object contents.
|
|
894
|
+
* @returns The written object metadata.
|
|
895
|
+
* @example
|
|
896
|
+
* ```typescript
|
|
897
|
+
* await api.put(env, "avatar.png", buffer);
|
|
898
|
+
* ```
|
|
899
|
+
*/
|
|
900
|
+
put: (env, key, value) => provider(env).put(key, value),
|
|
901
|
+
/**
|
|
902
|
+
* Remove an object (or keys) from this bucket. No-op when absent.
|
|
903
|
+
*
|
|
904
|
+
* @param env - The per-request Cloudflare env.
|
|
905
|
+
* @param key - The object key or keys to delete.
|
|
906
|
+
* @returns Resolves once removed.
|
|
907
|
+
* @example
|
|
908
|
+
* ```typescript
|
|
909
|
+
* await api.delete(env, "assets/old-logo.png");
|
|
910
|
+
* ```
|
|
911
|
+
*/
|
|
912
|
+
delete: (env, key) => provider(env).delete(key),
|
|
913
|
+
/**
|
|
914
|
+
* List objects in this bucket, optionally filtered by R2ListOptions.
|
|
915
|
+
*
|
|
916
|
+
* @param env - The per-request Cloudflare env.
|
|
917
|
+
* @param opts - Optional prefix / limit / cursor / delimiter.
|
|
918
|
+
* @returns The list result.
|
|
919
|
+
* @example
|
|
920
|
+
* ```typescript
|
|
921
|
+
* const { objects } = await api.list(env, { prefix: "assets/" });
|
|
922
|
+
* ```
|
|
923
|
+
*/
|
|
924
|
+
list: (env, opts) => provider(env).list(opts)
|
|
925
|
+
};
|
|
926
|
+
};
|
|
927
|
+
const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "r2"), "r2").binding;
|
|
803
928
|
return {
|
|
929
|
+
...surface(defaultBinding),
|
|
804
930
|
/**
|
|
805
|
-
*
|
|
931
|
+
* Select a specific R2 bucket instance by its config key.
|
|
806
932
|
*
|
|
807
|
-
* @param
|
|
808
|
-
* @
|
|
809
|
-
* @returns {Promise<R2ObjectBody | null>} The R2ObjectBody, or null.
|
|
933
|
+
* @param key - The instance key (as configured under `pluginConfigs.storage`).
|
|
934
|
+
* @returns The object surface bound to that bucket.
|
|
810
935
|
* @example
|
|
811
936
|
* ```typescript
|
|
812
|
-
*
|
|
937
|
+
* await api.use("uploads").put(env, "avatar.png", buffer);
|
|
813
938
|
* ```
|
|
814
939
|
*/
|
|
815
|
-
|
|
940
|
+
use: (key) => surface(() => pickInstance(ctx.config, key, "r2").binding),
|
|
816
941
|
/**
|
|
817
|
-
*
|
|
942
|
+
* Return this plugin's deploy metadata — one descriptor per configured bucket.
|
|
818
943
|
*
|
|
819
|
-
* @
|
|
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.
|
|
944
|
+
* @returns One r2 deploy descriptor per instance.
|
|
823
945
|
* @example
|
|
824
946
|
* ```typescript
|
|
825
|
-
* const
|
|
947
|
+
* const manifest = api.deployManifest(); // [{ kind: "r2", name: "tracker-files", binding: "FILES" }]
|
|
826
948
|
* ```
|
|
827
949
|
*/
|
|
828
|
-
|
|
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: () => ({
|
|
950
|
+
deployManifest: () => Object.values(ctx.config).map((instance) => ({
|
|
865
951
|
kind: "r2",
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
952
|
+
name: instance.name,
|
|
953
|
+
binding: instance.binding,
|
|
954
|
+
...instance.upload === void 0 ? {} : { upload: instance.upload }
|
|
955
|
+
}))
|
|
869
956
|
};
|
|
870
957
|
};
|
|
871
958
|
/**
|
|
@@ -879,13 +966,47 @@ const createStorageApi = (ctx) => {
|
|
|
879
966
|
*/
|
|
880
967
|
const storagePlugin = createPlugin("storage", {
|
|
881
968
|
depends: [bindingsPlugin],
|
|
882
|
-
config: {
|
|
883
|
-
upload: "",
|
|
884
|
-
bucket: "ASSETS"
|
|
885
|
-
},
|
|
969
|
+
config: {},
|
|
886
970
|
api: createStorageApi
|
|
887
971
|
});
|
|
888
972
|
//#endregion
|
|
973
|
+
//#region src/plugins/deploy/auth/env-file.ts
|
|
974
|
+
/**
|
|
975
|
+
* @file deploy plugin — `.env.local` scaffolder (node:fs).
|
|
976
|
+
*
|
|
977
|
+
* Writes a ready-to-fill `.env.local` so the guided deploy can hand the user a real file to paste
|
|
978
|
+
* their Cloudflare token into — NEVER clobbering an existing one (it may already hold real secrets).
|
|
979
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
980
|
+
*/
|
|
981
|
+
/**
|
|
982
|
+
* Create `<dir>/.env.local` with the given contents, unless it already exists. Existing files are
|
|
983
|
+
* left untouched (they may hold real secrets) — the caller tells the user to fill that one in.
|
|
984
|
+
*
|
|
985
|
+
* @param dir - Directory to create the file in (usually `process.cwd()`).
|
|
986
|
+
* @param content - The file contents to write when absent (e.g. `envLocalScaffold(manifest)`).
|
|
987
|
+
* @returns Whether the file was created (false when it already existed) and its path.
|
|
988
|
+
* @example
|
|
989
|
+
* ```ts
|
|
990
|
+
* const { created, path } = await ensureEnvLocal(process.cwd(), envLocalScaffold(manifest));
|
|
991
|
+
* ```
|
|
992
|
+
*/
|
|
993
|
+
const ensureEnvLocal = async (dir, content) => {
|
|
994
|
+
const filePath = path.join(dir, ".env.local");
|
|
995
|
+
try {
|
|
996
|
+
await access(filePath);
|
|
997
|
+
return {
|
|
998
|
+
created: false,
|
|
999
|
+
path: filePath
|
|
1000
|
+
};
|
|
1001
|
+
} catch {
|
|
1002
|
+
await writeFile(filePath, content, "utf8");
|
|
1003
|
+
return {
|
|
1004
|
+
created: true,
|
|
1005
|
+
path: filePath
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
//#endregion
|
|
889
1010
|
//#region src/plugins/deploy/auth/permissions.ts
|
|
890
1011
|
/** Permission groups every deploy needs, regardless of resources. */
|
|
891
1012
|
const ALWAYS = [{
|
|
@@ -1024,6 +1145,104 @@ const ciToken = (manifest) => {
|
|
|
1024
1145
|
return groups;
|
|
1025
1146
|
};
|
|
1026
1147
|
//#endregion
|
|
1148
|
+
//#region src/plugins/deploy/auth/render.ts
|
|
1149
|
+
/** Cloudflare's dashboard path for creating API tokens. */
|
|
1150
|
+
const TOKENS_URL$1 = "https://dash.cloudflare.com/profile/api-tokens";
|
|
1151
|
+
/**
|
|
1152
|
+
* Render one permission as a framed row. With the template flag on (the LOCAL panel) a green `✓`
|
|
1153
|
+
* marks a permission the stock template already includes and a pink `+ ← add to template` marks one
|
|
1154
|
+
* the user must add; with it off (the CI panel) every row is a neutral bullet. Scope bold, reason dim.
|
|
1155
|
+
*
|
|
1156
|
+
* @param ui - The branded console (for its palette).
|
|
1157
|
+
* @param permission - The permission group to render.
|
|
1158
|
+
* @param showTemplateFlag - Whether to mark template-vs-add (LOCAL) or use a neutral bullet (CI).
|
|
1159
|
+
* @returns The rendered (colorized) row, ready to drop into a box.
|
|
1160
|
+
* @example
|
|
1161
|
+
* ```ts
|
|
1162
|
+
* permissionRow(ui, { group: "Account · D1", scope: "Edit", reason: "d1", inBaseTemplate: false }, true);
|
|
1163
|
+
* ```
|
|
1164
|
+
*/
|
|
1165
|
+
const permissionRow = (ui, permission, showTemplateFlag) => {
|
|
1166
|
+
const { palette } = ui;
|
|
1167
|
+
const templateMark = permission.inBaseTemplate ? palette.green("✓") : palette.pink("+");
|
|
1168
|
+
const mark = showTemplateFlag ? templateMark : palette.dim("•");
|
|
1169
|
+
const flag = showTemplateFlag && !permission.inBaseTemplate ? palette.pink(" ← add to template") : "";
|
|
1170
|
+
const reason = palette.dim(`(${permission.reason})`);
|
|
1171
|
+
return `${mark} ${permission.group} : ${palette.bold(permission.scope)} ${reason}${flag}`;
|
|
1172
|
+
};
|
|
1173
|
+
/**
|
|
1174
|
+
* Render the LOCAL (first deploy) token panel: the full permission set with template/add markers,
|
|
1175
|
+
* then the numbered create-token steps (URL cyan, template + `.env.local` bold).
|
|
1176
|
+
*
|
|
1177
|
+
* @param ui - The branded console to render through.
|
|
1178
|
+
* @param requirement - The LOCAL token requirement (from requiredToken()).
|
|
1179
|
+
* @example
|
|
1180
|
+
* ```ts
|
|
1181
|
+
* localPanel(ui, requiredToken(manifest));
|
|
1182
|
+
* ```
|
|
1183
|
+
*/
|
|
1184
|
+
const localPanel = (ui, requirement) => {
|
|
1185
|
+
const { palette } = ui;
|
|
1186
|
+
const adds = requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} → ${permission.scope}`).join(", ");
|
|
1187
|
+
const coversAll = palette.dim(`The "${requirement.base}" template covers everything.`);
|
|
1188
|
+
const addStep = requirement.toAdd.length > 0 ? ` 3. ADD ${palette.pink(adds)}` : ` 3. ${coversAll}`;
|
|
1189
|
+
const template = palette.bold(`"${requirement.base}"`);
|
|
1190
|
+
ui.box([
|
|
1191
|
+
palette.bold("LOCAL — first deploy (creates your infra)"),
|
|
1192
|
+
"",
|
|
1193
|
+
...requirement.required.map((permission) => permissionRow(ui, permission, true)),
|
|
1194
|
+
"",
|
|
1195
|
+
` 1. ${palette.cyan(TOKENS_URL$1)}`,
|
|
1196
|
+
` 2. Create Token → start from the ${template} template.`,
|
|
1197
|
+
addStep,
|
|
1198
|
+
" 4. Account Resources → Include → your account.",
|
|
1199
|
+
` 5. Create it, copy it, then paste into ${palette.bold(".env.local")} (below).`
|
|
1200
|
+
]);
|
|
1201
|
+
};
|
|
1202
|
+
/**
|
|
1203
|
+
* Render the compact CI (automation redeploy) token panel: the reduced, read-mostly permission set
|
|
1204
|
+
* for a later Custom Token. No template markers — CI builds a token from scratch, not the template.
|
|
1205
|
+
*
|
|
1206
|
+
* @param ui - The branded console to render through.
|
|
1207
|
+
* @param groups - The CI token permission groups (from ciToken()).
|
|
1208
|
+
* @example
|
|
1209
|
+
* ```ts
|
|
1210
|
+
* ciPanel(ui, ciToken(manifest));
|
|
1211
|
+
* ```
|
|
1212
|
+
*/
|
|
1213
|
+
const ciPanel = (ui, groups) => {
|
|
1214
|
+
const { palette } = ui;
|
|
1215
|
+
ui.box([
|
|
1216
|
+
palette.bold("CI — automation redeploy (optional, later)"),
|
|
1217
|
+
"",
|
|
1218
|
+
...groups.map((permission) => permissionRow(ui, permission, false)),
|
|
1219
|
+
"",
|
|
1220
|
+
palette.dim("Create a Custom Token with exactly these (Read, not Edit, on data)."),
|
|
1221
|
+
palette.dim("Store as the CLOUDFLARE_API_TOKEN secret; pin CLOUDFLARE_ACCOUNT_ID.")
|
|
1222
|
+
]);
|
|
1223
|
+
};
|
|
1224
|
+
/**
|
|
1225
|
+
* Render the full branded `auth setup` guidance: a heading, the LOCAL token panel (what to create
|
|
1226
|
+
* now), and — when `opts.ci` is supplied — the compact CI panel; otherwise a one-line pointer to
|
|
1227
|
+
* `auth setup` for the CI token (so the guided deploy stays focused on the immediate next step).
|
|
1228
|
+
*
|
|
1229
|
+
* @param ui - The branded console to render through.
|
|
1230
|
+
* @param requirement - The LOCAL token requirement (from requiredToken()).
|
|
1231
|
+
* @param opts - Optional rendering options.
|
|
1232
|
+
* @param opts.ci - The CI token permission groups (from ciToken()); omit to show a pointer instead.
|
|
1233
|
+
* @example
|
|
1234
|
+
* ```ts
|
|
1235
|
+
* renderAuthSetup(ui, requiredToken(manifest)); // guided deploy (LOCAL only)
|
|
1236
|
+
* renderAuthSetup(ui, requiredToken(manifest), { ci: ciToken(m) }); // `auth setup` (LOCAL + CI)
|
|
1237
|
+
* ```
|
|
1238
|
+
*/
|
|
1239
|
+
const renderAuthSetup = (ui, requirement, opts) => {
|
|
1240
|
+
ui.heading("Cloudflare API token");
|
|
1241
|
+
localPanel(ui, requirement);
|
|
1242
|
+
if (opts?.ci) ciPanel(ui, opts.ci);
|
|
1243
|
+
else ui.line(ui.palette.dim(" Need a CI token later? Run `auth setup` for the reduced set."));
|
|
1244
|
+
};
|
|
1245
|
+
//#endregion
|
|
1027
1246
|
//#region src/plugins/deploy/auth/setup.ts
|
|
1028
1247
|
/** Cloudflare's dashboard path for creating API tokens. */
|
|
1029
1248
|
const TOKENS_URL = "https://dash.cloudflare.com/profile/api-tokens";
|
|
@@ -1103,6 +1322,41 @@ const tokenInstructions = (manifest) => [
|
|
|
1103
1322
|
"",
|
|
1104
1323
|
...ciSection(ciToken(manifest))
|
|
1105
1324
|
].join("\n");
|
|
1325
|
+
/**
|
|
1326
|
+
* Render a ready-to-fill `.env.local` for the guided deploy: the two Cloudflare credential keys
|
|
1327
|
+
* (left blank to paste into) preceded by a comment block derived from the manifest — where to
|
|
1328
|
+
* create the token, which template to start from, exactly which permissions to add, and how to find
|
|
1329
|
+
* the account id. The same guidance {@link tokenInstructions} prints, but PERSISTED in the file the
|
|
1330
|
+
* user edits (so it survives the terminal scrolling away). Pure: no fs, no network.
|
|
1331
|
+
*
|
|
1332
|
+
* @param manifest - The assembled deploy manifest.
|
|
1333
|
+
* @returns The `.env.local` file contents (trailing newline included).
|
|
1334
|
+
* @example
|
|
1335
|
+
* ```ts
|
|
1336
|
+
* await writeFile(".env.local", envLocalScaffold(manifest));
|
|
1337
|
+
* ```
|
|
1338
|
+
*/
|
|
1339
|
+
const envLocalScaffold = (manifest) => {
|
|
1340
|
+
const requirement = requiredToken(manifest);
|
|
1341
|
+
const addStep = requirement.toAdd.length > 0 ? `# 3. Under Permissions, ADD: ${requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} -> ${permission.scope}`).join(", ")}` : `# 3. The "${requirement.base}" template covers everything — no changes needed.`;
|
|
1342
|
+
return `${[
|
|
1343
|
+
"# Cloudflare credentials for the moku deploy — fill in the two values below, then re-run deploy.",
|
|
1344
|
+
"# Local-only: keep this file out of git (.env.local is gitignored by convention).",
|
|
1345
|
+
"#",
|
|
1346
|
+
"# Create the API token:",
|
|
1347
|
+
`# 1. ${TOKENS_URL} -> Create Token`,
|
|
1348
|
+
`# 2. Start from the "${requirement.base}" template.`,
|
|
1349
|
+
addStep,
|
|
1350
|
+
"# 4. Account Resources -> Include -> your account.",
|
|
1351
|
+
"# 5. Create the token, copy it, and paste it after CLOUDFLARE_API_TOKEN= below.",
|
|
1352
|
+
"#",
|
|
1353
|
+
"# Account id: open https://dash.cloudflare.com — it is the id in the URL",
|
|
1354
|
+
"# (dash.cloudflare.com/<account-id>) or in the right sidebar of any domain's overview.",
|
|
1355
|
+
"",
|
|
1356
|
+
"CLOUDFLARE_API_TOKEN=",
|
|
1357
|
+
"CLOUDFLARE_ACCOUNT_ID="
|
|
1358
|
+
].join("\n")}\n`;
|
|
1359
|
+
};
|
|
1106
1360
|
//#endregion
|
|
1107
1361
|
//#region src/plugins/deploy/infra/cloudflare.ts
|
|
1108
1362
|
/**
|
|
@@ -1606,16 +1860,17 @@ const realDevDeps = () => ({
|
|
|
1606
1860
|
now: nowMs
|
|
1607
1861
|
});
|
|
1608
1862
|
/**
|
|
1609
|
-
* The d1
|
|
1863
|
+
* The d1 bindings to migrate locally — one per configured d1 instance that declares a migrations
|
|
1864
|
+
* directory (empty when no d1 plugin is present, or none declares migrations).
|
|
1610
1865
|
*
|
|
1611
1866
|
* @param ctx - The deploy plugin context.
|
|
1612
|
-
* @returns The d1 binding
|
|
1867
|
+
* @returns The d1 binding names with migrations (e.g. `["DB"]`).
|
|
1613
1868
|
* @example
|
|
1614
1869
|
* ```ts
|
|
1615
|
-
* const
|
|
1870
|
+
* const bindings = d1MigrationBindings(ctx); // ["DB"]
|
|
1616
1871
|
* ```
|
|
1617
1872
|
*/
|
|
1618
|
-
const
|
|
1873
|
+
const d1MigrationBindings = (ctx) => ctx.has("d1") ? ctx.require(d1Plugin).deployManifest().filter((manifest) => manifest.migrations !== void 0).map((manifest) => manifest.binding) : [];
|
|
1619
1874
|
/**
|
|
1620
1875
|
* Rebuild the site once and announce the result. A failed build keeps the session alive (it just
|
|
1621
1876
|
* emits dev:error and serves the last good build).
|
|
@@ -1669,13 +1924,13 @@ const runDev = async (ctx, opts, deps) => {
|
|
|
1669
1924
|
detail: "site"
|
|
1670
1925
|
});
|
|
1671
1926
|
await deps.build(ctx, webBuild);
|
|
1672
|
-
const
|
|
1673
|
-
if (
|
|
1927
|
+
const migrationBindings = ctx.config.migrateLocal ? d1MigrationBindings(ctx) : [];
|
|
1928
|
+
if (migrationBindings.length > 0) {
|
|
1674
1929
|
ctx.emit("dev:phase", {
|
|
1675
1930
|
phase: "migrate",
|
|
1676
1931
|
detail: "d1 (local)"
|
|
1677
1932
|
});
|
|
1678
|
-
await deps.runWrangler([
|
|
1933
|
+
for (const binding of migrationBindings) await deps.runWrangler([
|
|
1679
1934
|
"d1",
|
|
1680
1935
|
"migrations",
|
|
1681
1936
|
"apply",
|
|
@@ -1719,21 +1974,21 @@ const runDev = async (ctx, opts, deps) => {
|
|
|
1719
1974
|
const checkExisting = (resource, existing) => {
|
|
1720
1975
|
switch (resource.kind) {
|
|
1721
1976
|
case "kv": {
|
|
1722
|
-
const id = existing.kv.get(resource.
|
|
1977
|
+
const id = existing.kv.get(resource.name);
|
|
1723
1978
|
return id === void 0 ? { exists: false } : {
|
|
1724
1979
|
exists: true,
|
|
1725
1980
|
id
|
|
1726
1981
|
};
|
|
1727
1982
|
}
|
|
1728
1983
|
case "d1": {
|
|
1729
|
-
const id = existing.d1.get(resource.
|
|
1984
|
+
const id = existing.d1.get(resource.name);
|
|
1730
1985
|
return id === void 0 ? { exists: false } : {
|
|
1731
1986
|
exists: true,
|
|
1732
1987
|
id
|
|
1733
1988
|
};
|
|
1734
1989
|
}
|
|
1735
|
-
case "r2": return { exists: existing.r2.has(resource.
|
|
1736
|
-
case "queue": return { exists:
|
|
1990
|
+
case "r2": return { exists: existing.r2.has(resource.name) };
|
|
1991
|
+
case "queue": return { exists: existing.queue.has(resource.name) };
|
|
1737
1992
|
case "do": return { exists: false };
|
|
1738
1993
|
}
|
|
1739
1994
|
};
|
|
@@ -1783,6 +2038,177 @@ const planInfra = async (ctx, manifest) => {
|
|
|
1783
2038
|
};
|
|
1784
2039
|
};
|
|
1785
2040
|
//#endregion
|
|
2041
|
+
//#region src/plugins/deploy/infra/render.ts
|
|
2042
|
+
/**
|
|
2043
|
+
* Derive a human-readable name from a resource descriptor: the Cloudflare resource `name` for the
|
|
2044
|
+
* provisioned kinds (kv/r2/d1/queue), or the exported `className` for a Durable Object (which has no
|
|
2045
|
+
* provisioned name). Used in both the provision events and the branded panels so the two agree.
|
|
2046
|
+
*
|
|
2047
|
+
* @param resource - The resource descriptor.
|
|
2048
|
+
* @returns A short name identifying the resource.
|
|
2049
|
+
* @example
|
|
2050
|
+
* ```ts
|
|
2051
|
+
* resourceName({ kind: "kv", name: "tracker-cache", binding: "CACHE" }); // "tracker-cache"
|
|
2052
|
+
* ```
|
|
2053
|
+
*/
|
|
2054
|
+
const resourceName = (resource) => resource.kind === "do" ? resource.className : resource.name;
|
|
2055
|
+
/**
|
|
2056
|
+
* Format a `kind name` cell, padding the kind so the names line up in a column.
|
|
2057
|
+
*
|
|
2058
|
+
* @param kind - The resource kind (kv / r2 / d1 / queue / do).
|
|
2059
|
+
* @param name - The resource name.
|
|
2060
|
+
* @returns The aligned `kind name` cell.
|
|
2061
|
+
* @example
|
|
2062
|
+
* ```ts
|
|
2063
|
+
* cell("kv", "CACHE"); // "kv CACHE"
|
|
2064
|
+
* ```
|
|
2065
|
+
*/
|
|
2066
|
+
const cell = (kind, name) => `${kind.padEnd(6)}${name}`;
|
|
2067
|
+
/**
|
|
2068
|
+
* ANSI SGR matcher — built from `String.fromCharCode(27)` (the ESC byte) so no control character
|
|
2069
|
+
* appears in a regex literal (which both linters reject).
|
|
2070
|
+
*/
|
|
2071
|
+
const ANSI_SGR = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;]*m`, "gu");
|
|
2072
|
+
/**
|
|
2073
|
+
* Strip ANSI SGR escape sequences so a captured (colorized) error renders as plain, readable text.
|
|
2074
|
+
*
|
|
2075
|
+
* @param text - The (possibly colorized) text.
|
|
2076
|
+
* @returns The text with ANSI color codes removed.
|
|
2077
|
+
* @example
|
|
2078
|
+
* ```ts
|
|
2079
|
+
* stripAnsi(`${String.fromCharCode(27)}[31mX${String.fromCharCode(27)}[0m`); // "X"
|
|
2080
|
+
* ```
|
|
2081
|
+
*/
|
|
2082
|
+
const stripAnsi = (text) => text.replaceAll(ANSI_SGR, "");
|
|
2083
|
+
/**
|
|
2084
|
+
* Clean a captured (colorized, multi-line, wrapper-wrapped) provision error down to its meaningful
|
|
2085
|
+
* text: strip ANSI, drop the wrapper lines (the branded prefix, wrangler's log-file pointer), strip
|
|
2086
|
+
* each `✘ [ERROR]` marker, and join what's left. Returns the FULL message (the caller word-wraps it)
|
|
2087
|
+
* so the user reads the actual reason — never a truncated `…`.
|
|
2088
|
+
*
|
|
2089
|
+
* @param message - The captured error message.
|
|
2090
|
+
* @returns The full, plain failure reason.
|
|
2091
|
+
* @example
|
|
2092
|
+
* ```ts
|
|
2093
|
+
* cleanError("[moku-worker] wrangler exited…\n ✘ [ERROR] The bucket name is invalid.");
|
|
2094
|
+
* // "The bucket name is invalid."
|
|
2095
|
+
* ```
|
|
2096
|
+
*/
|
|
2097
|
+
const cleanError = (message) => {
|
|
2098
|
+
const cleaned = stripAnsi(message).split("\n").map((line) => line.trim()).filter((line) => line.length > 0).filter((line) => !/^\[moku-worker\]/u.test(line)).filter((line) => !/logs were written to/iu.test(line)).map((line) => line.replace(/^✘\s*/u, "").replace(/^\[error\]\s*/iu, "")).join(" ");
|
|
2099
|
+
return cleaned.length > 0 ? cleaned : stripAnsi(message).trim();
|
|
2100
|
+
};
|
|
2101
|
+
/**
|
|
2102
|
+
* Word-wrap text to `width` columns (never splitting inside a word), so a long failure reason reads
|
|
2103
|
+
* as a tidy indented block instead of forcing the box wide or scrolling off the edge.
|
|
2104
|
+
*
|
|
2105
|
+
* @param text - The text to wrap.
|
|
2106
|
+
* @param width - The maximum column width per line.
|
|
2107
|
+
* @returns The wrapped lines.
|
|
2108
|
+
* @example
|
|
2109
|
+
* ```ts
|
|
2110
|
+
* wrapText("a long sentence to wrap", 10); // ["a long", "sentence", "to wrap"]
|
|
2111
|
+
* ```
|
|
2112
|
+
*/
|
|
2113
|
+
const wrapText = (text, width) => {
|
|
2114
|
+
const lines = [];
|
|
2115
|
+
let line = "";
|
|
2116
|
+
for (const word of text.split(/\s+/u).filter(Boolean)) if (line.length === 0) line = word;
|
|
2117
|
+
else if (line.length + 1 + word.length <= width) line += ` ${word}`;
|
|
2118
|
+
else {
|
|
2119
|
+
lines.push(line);
|
|
2120
|
+
line = word;
|
|
2121
|
+
}
|
|
2122
|
+
if (line.length > 0) lines.push(line);
|
|
2123
|
+
return lines;
|
|
2124
|
+
};
|
|
2125
|
+
/**
|
|
2126
|
+
* Render the infra preflight plan as a branded panel: a dim summary line (counts + account) then one
|
|
2127
|
+
* row per declared resource — a pink `+` for those to create, a dim `~ (exists)` for those already
|
|
2128
|
+
* present. When nothing needs creating it still renders, so the user sees the full picture.
|
|
2129
|
+
*
|
|
2130
|
+
* @param ui - The branded console to render through.
|
|
2131
|
+
* @param plan - The infra plan (existing vs missing) from checkInfra()/planInfra().
|
|
2132
|
+
* @example
|
|
2133
|
+
* ```ts
|
|
2134
|
+
* renderPlan(ui, await planInfra(ctx, manifest));
|
|
2135
|
+
* ```
|
|
2136
|
+
*/
|
|
2137
|
+
const renderPlan = (ui, plan) => {
|
|
2138
|
+
const { palette } = ui;
|
|
2139
|
+
const summary = palette.dim(`${String(plan.missing.length)} to create · ${String(plan.exists.length)} exist · ${plan.account}`);
|
|
2140
|
+
const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
|
|
2141
|
+
const createRows = plan.missing.map((resource) => `${palette.pink("+")} ${cell(resource.kind, resourceName(resource))}`);
|
|
2142
|
+
ui.heading("Infra plan");
|
|
2143
|
+
ui.box([
|
|
2144
|
+
summary,
|
|
2145
|
+
"",
|
|
2146
|
+
...createRows,
|
|
2147
|
+
...existsRows
|
|
2148
|
+
]);
|
|
2149
|
+
};
|
|
2150
|
+
/**
|
|
2151
|
+
* Render the provision result as a branded panel — a green `✓` per created resource, a dim `~` per
|
|
2152
|
+
* skipped, a red `✗` per failure, then a summary line (failed count red when non-zero) — followed,
|
|
2153
|
+
* when anything failed, by a detail block printing each failure's FULL reason (ANSI-stripped and
|
|
2154
|
+
* word-wrapped) so it is actually readable instead of truncated inside the box.
|
|
2155
|
+
*
|
|
2156
|
+
* @param ui - The branded console to render through.
|
|
2157
|
+
* @param result - The provision result from provisionInfra()/the deploy pipeline.
|
|
2158
|
+
* @example
|
|
2159
|
+
* ```ts
|
|
2160
|
+
* renderProvisionResult(ui, await provisionInfra(plan));
|
|
2161
|
+
* ```
|
|
2162
|
+
*/
|
|
2163
|
+
const renderProvisionResult = (ui, result) => {
|
|
2164
|
+
const { palette } = ui;
|
|
2165
|
+
const createdRows = result.created.map((ref) => `${palette.green("✓")} ${cell(ref.resource.kind, resourceName(ref.resource))}`);
|
|
2166
|
+
const skippedRows = result.skipped.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
|
|
2167
|
+
const failedRows = result.failed.map((failure) => `${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
|
|
2168
|
+
const failedCount = result.failed.length > 0 ? palette.red(`${String(result.failed.length)} failed`) : "0 failed";
|
|
2169
|
+
const summary = `${String(result.created.length)} created · ${String(result.skipped.length)} exist · ${failedCount}`;
|
|
2170
|
+
ui.heading("Provisioned");
|
|
2171
|
+
ui.box([
|
|
2172
|
+
...createdRows,
|
|
2173
|
+
...skippedRows,
|
|
2174
|
+
...failedRows,
|
|
2175
|
+
"",
|
|
2176
|
+
summary
|
|
2177
|
+
]);
|
|
2178
|
+
if (result.failed.length > 0) {
|
|
2179
|
+
ui.line();
|
|
2180
|
+
for (const failure of result.failed) {
|
|
2181
|
+
ui.line(` ${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
|
|
2182
|
+
for (const wrapped of wrapText(cleanError(failure.error), ui.width - 4)) ui.line(palette.dim(` ${wrapped}`));
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
};
|
|
2186
|
+
//#endregion
|
|
2187
|
+
//#region src/plugins/deploy/naming.ts
|
|
2188
|
+
/**
|
|
2189
|
+
* @file deploy plugin — stage-aware resource naming.
|
|
2190
|
+
*
|
|
2191
|
+
* One source of truth for turning a base Cloudflare resource name into its stage variant, so the
|
|
2192
|
+
* worker name, the provisioners, the infra existence diff, and the generated wrangler config all
|
|
2193
|
+
* agree. Production keeps the base name; every other stage gets a `-${stage}` suffix. Node-only;
|
|
2194
|
+
* never imported by the runtime Worker bundle.
|
|
2195
|
+
*/
|
|
2196
|
+
/**
|
|
2197
|
+
* Apply the deploy stage to a base Cloudflare resource name: the base name in `production`, else
|
|
2198
|
+
* `${base}-${stage}` (e.g. dev → `tracker-db-dev`). Env bindings + DO class names never get the
|
|
2199
|
+
* suffix — only provisioned resource names (and the worker name) are stage-qualified.
|
|
2200
|
+
*
|
|
2201
|
+
* @param base - The base resource name (e.g. "tracker-db").
|
|
2202
|
+
* @param stage - The deploy stage (e.g. "production", "development", "dev").
|
|
2203
|
+
* @returns The stage-qualified name.
|
|
2204
|
+
* @example
|
|
2205
|
+
* ```ts
|
|
2206
|
+
* stageName("tracker-db", "production"); // "tracker-db"
|
|
2207
|
+
* stageName("tracker-db", "dev"); // "tracker-db-dev"
|
|
2208
|
+
* ```
|
|
2209
|
+
*/
|
|
2210
|
+
const stageName = (base, stage) => stage === "production" ? base : `${base}-${stage}`;
|
|
2211
|
+
//#endregion
|
|
1786
2212
|
//#region src/plugins/deploy/providers/d1.ts
|
|
1787
2213
|
/**
|
|
1788
2214
|
* @file deploy plugin — D1 provisioning adapter.
|
|
@@ -1822,13 +2248,13 @@ const provisionD1 = async (manifest, _ci) => {
|
|
|
1822
2248
|
const id = parseD1DatabaseId(await runWrangler([
|
|
1823
2249
|
"d1",
|
|
1824
2250
|
"create",
|
|
1825
|
-
manifest.
|
|
2251
|
+
manifest.name
|
|
1826
2252
|
]));
|
|
1827
2253
|
if (manifest.migrations) await runWrangler([
|
|
1828
2254
|
"d1",
|
|
1829
2255
|
"migrations",
|
|
1830
2256
|
"apply",
|
|
1831
|
-
manifest.
|
|
2257
|
+
manifest.name,
|
|
1832
2258
|
"--local"
|
|
1833
2259
|
]);
|
|
1834
2260
|
return id ? { id } : {};
|
|
@@ -1891,7 +2317,7 @@ const provisionKv = async (manifest, _ci) => {
|
|
|
1891
2317
|
"kv",
|
|
1892
2318
|
"namespace",
|
|
1893
2319
|
"create",
|
|
1894
|
-
manifest.
|
|
2320
|
+
manifest.name
|
|
1895
2321
|
]));
|
|
1896
2322
|
return id ? { id } : {};
|
|
1897
2323
|
};
|
|
@@ -1900,25 +2326,25 @@ const provisionKv = async (manifest, _ci) => {
|
|
|
1900
2326
|
/**
|
|
1901
2327
|
* @file deploy plugin — Queues provisioning adapter.
|
|
1902
2328
|
*
|
|
1903
|
-
* Creates Cloudflare
|
|
2329
|
+
* Creates one Cloudflare Queue via `wrangler queues create <name>` per queue instance.
|
|
1904
2330
|
* Node-only; never imported by the runtime Worker bundle.
|
|
1905
2331
|
*/
|
|
1906
2332
|
/**
|
|
1907
|
-
* Provision
|
|
2333
|
+
* Provision the queue via `wrangler queues create <name>`.
|
|
1908
2334
|
*
|
|
1909
2335
|
* @param manifest - The queue resource descriptor.
|
|
1910
2336
|
* @param _ci - Whether running non-interactively.
|
|
1911
|
-
* @returns Resolves once
|
|
2337
|
+
* @returns Resolves once the queue is created.
|
|
1912
2338
|
* @example
|
|
1913
2339
|
* ```ts
|
|
1914
|
-
* await provisionQueue({ kind: "queue",
|
|
2340
|
+
* await provisionQueue({ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }, false);
|
|
1915
2341
|
* ```
|
|
1916
2342
|
*/
|
|
1917
2343
|
const provisionQueue = async (manifest, _ci) => {
|
|
1918
|
-
|
|
2344
|
+
await runWrangler([
|
|
1919
2345
|
"queues",
|
|
1920
2346
|
"create",
|
|
1921
|
-
|
|
2347
|
+
manifest.name
|
|
1922
2348
|
]);
|
|
1923
2349
|
};
|
|
1924
2350
|
//#endregion
|
|
@@ -1941,7 +2367,7 @@ const provisionQueue = async (manifest, _ci) => {
|
|
|
1941
2367
|
* @returns Resolves once the bucket is created.
|
|
1942
2368
|
* @example
|
|
1943
2369
|
* ```ts
|
|
1944
|
-
* await provisionR2({ kind: "r2",
|
|
2370
|
+
* await provisionR2({ kind: "r2", name: "tracker-files", binding: "FILES" }, false);
|
|
1945
2371
|
* ```
|
|
1946
2372
|
*/
|
|
1947
2373
|
const provisionR2 = async (manifest, _ci) => {
|
|
@@ -1949,7 +2375,7 @@ const provisionR2 = async (manifest, _ci) => {
|
|
|
1949
2375
|
"r2",
|
|
1950
2376
|
"bucket",
|
|
1951
2377
|
"create",
|
|
1952
|
-
manifest.
|
|
2378
|
+
manifest.name
|
|
1953
2379
|
]);
|
|
1954
2380
|
};
|
|
1955
2381
|
/**
|
|
@@ -2095,12 +2521,12 @@ const buildKvNamespaces = (resources, ids) => resources.filter((resource) => res
|
|
|
2095
2521
|
* @returns One wrangler R2 bucket entry per r2 resource.
|
|
2096
2522
|
* @example
|
|
2097
2523
|
* ```ts
|
|
2098
|
-
* const r2 = buildR2Buckets([{ kind: "r2",
|
|
2524
|
+
* const r2 = buildR2Buckets([{ kind: "r2", name: "tracker-files", binding: "FILES" }]);
|
|
2099
2525
|
* ```
|
|
2100
2526
|
*/
|
|
2101
2527
|
const buildR2Buckets = (resources) => resources.filter((resource) => resource.kind === "r2").map((resource) => ({
|
|
2102
|
-
binding: resource.
|
|
2103
|
-
bucket_name: resource.
|
|
2528
|
+
binding: resource.binding,
|
|
2529
|
+
bucket_name: resource.name
|
|
2104
2530
|
}));
|
|
2105
2531
|
/**
|
|
2106
2532
|
* Build the wrangler `d1_databases` array from the manifest's d1 resources.
|
|
@@ -2110,13 +2536,13 @@ const buildR2Buckets = (resources) => resources.filter((resource) => resource.ki
|
|
|
2110
2536
|
* @returns One wrangler D1 database entry per d1 resource (migrations_dir set when present).
|
|
2111
2537
|
* @example
|
|
2112
2538
|
* ```ts
|
|
2113
|
-
* const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }], { DB: "uuid-1234" });
|
|
2539
|
+
* const d1 = buildD1Databases([{ kind: "d1", name: "tracker-db", binding: "DB" }], { DB: "uuid-1234" });
|
|
2114
2540
|
* ```
|
|
2115
2541
|
*/
|
|
2116
2542
|
const buildD1Databases = (resources, ids) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
|
|
2117
2543
|
const entry = {
|
|
2118
2544
|
binding: resource.binding,
|
|
2119
|
-
database_name: resource.
|
|
2545
|
+
database_name: resource.name,
|
|
2120
2546
|
database_id: ids[resource.binding] ?? ""
|
|
2121
2547
|
};
|
|
2122
2548
|
if (resource.migrations) entry.migrations_dir = resource.migrations;
|
|
@@ -2129,16 +2555,16 @@ const buildD1Databases = (resources, ids) => resources.filter((resource) => reso
|
|
|
2129
2555
|
* @returns The queues section, or undefined when there are no queue resources.
|
|
2130
2556
|
* @example
|
|
2131
2557
|
* ```ts
|
|
2132
|
-
* const q = buildQueues([{ kind: "queue",
|
|
2558
|
+
* const q = buildQueues([{ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }]);
|
|
2133
2559
|
* ```
|
|
2134
2560
|
*/
|
|
2135
2561
|
const buildQueues = (resources) => {
|
|
2136
2562
|
const queueResources = resources.filter((resource) => resource.kind === "queue");
|
|
2137
2563
|
if (queueResources.length === 0) return void 0;
|
|
2138
|
-
return { producers: queueResources.
|
|
2139
|
-
queue:
|
|
2140
|
-
binding:
|
|
2141
|
-
}))
|
|
2564
|
+
return { producers: queueResources.map((resource) => ({
|
|
2565
|
+
queue: resource.name,
|
|
2566
|
+
binding: resource.binding
|
|
2567
|
+
})) };
|
|
2142
2568
|
};
|
|
2143
2569
|
/**
|
|
2144
2570
|
* Build the wrangler `durable_objects` bindings section from the manifest's do resources.
|
|
@@ -2147,47 +2573,136 @@ const buildQueues = (resources) => {
|
|
|
2147
2573
|
* @returns The durable_objects section, or undefined when there are no do resources.
|
|
2148
2574
|
* @example
|
|
2149
2575
|
* ```ts
|
|
2150
|
-
* const dobj = buildDurableObjects([{ kind: "do",
|
|
2576
|
+
* const dobj = buildDurableObjects([{ kind: "do", binding: "COUNTER", className: "Counter" }]);
|
|
2151
2577
|
* ```
|
|
2152
2578
|
*/
|
|
2153
2579
|
const buildDurableObjects = (resources) => {
|
|
2154
2580
|
const doResources = resources.filter((resource) => resource.kind === "do");
|
|
2155
2581
|
if (doResources.length === 0) return void 0;
|
|
2156
|
-
return { bindings: doResources.
|
|
2157
|
-
name:
|
|
2158
|
-
class_name: className
|
|
2159
|
-
}))
|
|
2582
|
+
return { bindings: doResources.map((resource) => ({
|
|
2583
|
+
name: resource.binding,
|
|
2584
|
+
class_name: resource.className
|
|
2585
|
+
})) };
|
|
2586
|
+
};
|
|
2587
|
+
/**
|
|
2588
|
+
* Build the auto Durable Object `migrations` from the manifest's do classes. wrangler REQUIRES a
|
|
2589
|
+
* migration for every DO class, so this derives a single `v1` migration registering each class as
|
|
2590
|
+
* SQLite-backed (the modern default) — the exact section wrangler prompts for when it is missing.
|
|
2591
|
+
*
|
|
2592
|
+
* @param resources - All resource descriptors from the manifest.
|
|
2593
|
+
* @returns A single-entry migrations array, or undefined when there are no do resources.
|
|
2594
|
+
* @example
|
|
2595
|
+
* ```ts
|
|
2596
|
+
* buildMigrations([{ kind: "do", binding: "BOARD", className: "BoardChannel" }]);
|
|
2597
|
+
* // [{ tag: "v1", new_sqlite_classes: ["BoardChannel"] }]
|
|
2598
|
+
* ```
|
|
2599
|
+
*/
|
|
2600
|
+
const buildMigrations = (resources) => {
|
|
2601
|
+
const classes = resources.filter((resource) => resource.kind === "do").map((resource) => resource.className);
|
|
2602
|
+
return classes.length > 0 ? [{
|
|
2603
|
+
tag: "v1",
|
|
2604
|
+
new_sqlite_classes: classes
|
|
2605
|
+
}] : void 0;
|
|
2606
|
+
};
|
|
2607
|
+
/**
|
|
2608
|
+
* Extract the already-captured Cloudflare ids (kv namespace `id`, d1 `database_id`) from an existing
|
|
2609
|
+
* parsed wrangler config, keyed by binding — so a regeneration (e.g. on `dev`) can preserve ids it
|
|
2610
|
+
* isn't handed. Tolerant of a malformed/hand-edited file (skips non-object / non-string entries).
|
|
2611
|
+
*
|
|
2612
|
+
* @param existing - The parsed existing wrangler config (or `{}`).
|
|
2613
|
+
* @returns A binding → id map (empty when the file has none).
|
|
2614
|
+
* @example
|
|
2615
|
+
* ```ts
|
|
2616
|
+
* extractExistingIds({ kv_namespaces: [{ binding: "CACHE", id: "ns1" }] }); // { CACHE: "ns1" }
|
|
2617
|
+
* ```
|
|
2618
|
+
*/
|
|
2619
|
+
const extractExistingIds = (existing) => {
|
|
2620
|
+
const ids = {};
|
|
2621
|
+
const collect = (list, idKey) => {
|
|
2622
|
+
if (!Array.isArray(list)) return;
|
|
2623
|
+
for (const raw of list) {
|
|
2624
|
+
if (raw === null || typeof raw !== "object") continue;
|
|
2625
|
+
const entry = raw;
|
|
2626
|
+
const binding = entry.binding;
|
|
2627
|
+
const id = entry[idKey];
|
|
2628
|
+
if (typeof binding === "string" && typeof id === "string" && id.length > 0) ids[binding] = id;
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
collect(existing.kv_namespaces, "id");
|
|
2632
|
+
collect(existing.d1_databases, "database_id");
|
|
2633
|
+
return ids;
|
|
2634
|
+
};
|
|
2635
|
+
/**
|
|
2636
|
+
* Build the extra top-level wrangler keys from the typed deploy config: `entry` → `main`,
|
|
2637
|
+
* `nodeCompat` → `compatibility_flags: ["nodejs_compat"]`, `assets` → the wrangler `assets` block
|
|
2638
|
+
* (SPA fallback when `spa`), then the raw `wrangler` passthrough last (the escape hatch wins / adds
|
|
2639
|
+
* anything else). Pass the result as the `extra` argument to {@link writeWranglerConfig}.
|
|
2640
|
+
*
|
|
2641
|
+
* @param config - The deploy plugin config.
|
|
2642
|
+
* @returns The merged extra wrangler keys.
|
|
2643
|
+
* @example
|
|
2644
|
+
* ```ts
|
|
2645
|
+
* await writeWranglerConfig(file, manifest, ids, wranglerExtra(ctx.config));
|
|
2646
|
+
* ```
|
|
2647
|
+
*/
|
|
2648
|
+
const wranglerExtra = (config) => {
|
|
2649
|
+
const extra = {};
|
|
2650
|
+
if (config.entry !== void 0) extra.main = config.entry;
|
|
2651
|
+
if (config.nodeCompat === true) extra.compatibility_flags = ["nodejs_compat"];
|
|
2652
|
+
if (config.assets !== void 0) extra.assets = {
|
|
2653
|
+
directory: config.assets.directory,
|
|
2654
|
+
binding: config.assets.binding,
|
|
2655
|
+
...config.assets.spa === true ? { not_found_handling: "single-page-application" } : {}
|
|
2656
|
+
};
|
|
2657
|
+
return {
|
|
2658
|
+
...extra,
|
|
2659
|
+
...config.wrangler
|
|
2660
|
+
};
|
|
2160
2661
|
};
|
|
2161
2662
|
/**
|
|
2162
2663
|
* Generate/update the wrangler config file from a manifest (non-destructive merge).
|
|
2163
|
-
*
|
|
2164
|
-
* (
|
|
2165
|
-
*
|
|
2664
|
+
*
|
|
2665
|
+
* Layering (last wins): existing file keys → the `extra` passthrough (the app's `wrangler` config:
|
|
2666
|
+
* `main`, `compatibility_flags`, `assets`, `vars`, …) → the deploy-managed keys (name,
|
|
2667
|
+
* compatibility_date, kv_namespaces, r2_buckets, d1_databases, queues, durable_objects). So the
|
|
2668
|
+
* framework always owns the resource sections, the app supplies what the manifest can't derive, and
|
|
2669
|
+
* any other hand-written keys survive. Durable Object `migrations` are auto-derived for every DO
|
|
2670
|
+
* class (the section wrangler requires) UNLESS the file/passthrough already defines `migrations`.
|
|
2166
2671
|
*
|
|
2167
2672
|
* @param configFile - Path to the wrangler config file.
|
|
2168
2673
|
* @param manifest - The assembled deploy manifest.
|
|
2169
2674
|
* @param ids - Captured Cloudflare ids keyed by binding (kv namespace id, d1 database id). Defaults
|
|
2170
2675
|
* to an empty map, in which case `id`/`database_id` are written as "" (e.g. the universal path).
|
|
2676
|
+
* @param extra - Extra top-level wrangler keys to merge in (the app's `deploy.wrangler` config).
|
|
2171
2677
|
* @returns Resolves once the file is written.
|
|
2172
2678
|
* @example
|
|
2173
2679
|
* ```ts
|
|
2174
|
-
* await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123",
|
|
2680
|
+
* await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123" }, {
|
|
2681
|
+
* main: "src/cloudflare/worker.ts",
|
|
2682
|
+
* compatibility_flags: ["nodejs_compat"],
|
|
2683
|
+
* assets: { directory: "dist/client", binding: "ASSETS" }
|
|
2684
|
+
* });
|
|
2175
2685
|
* ```
|
|
2176
2686
|
*/
|
|
2177
|
-
const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
|
|
2687
|
+
const writeWranglerConfig = async (configFile, manifest, ids = {}, extra = {}) => {
|
|
2178
2688
|
let existing = {};
|
|
2179
2689
|
if (existsSync(configFile)) try {
|
|
2180
2690
|
existing = parseJsonc(readFileSync(configFile, "utf8"));
|
|
2181
2691
|
} catch {
|
|
2182
2692
|
existing = {};
|
|
2183
2693
|
}
|
|
2184
|
-
const
|
|
2694
|
+
const effectiveIds = {
|
|
2695
|
+
...extractExistingIds(existing),
|
|
2696
|
+
...ids
|
|
2697
|
+
};
|
|
2698
|
+
const kvNamespaces = buildKvNamespaces(manifest.resources, effectiveIds);
|
|
2185
2699
|
const r2Buckets = buildR2Buckets(manifest.resources);
|
|
2186
|
-
const d1Databases = buildD1Databases(manifest.resources,
|
|
2700
|
+
const d1Databases = buildD1Databases(manifest.resources, effectiveIds);
|
|
2187
2701
|
const queues = buildQueues(manifest.resources);
|
|
2188
2702
|
const durableObjects = buildDurableObjects(manifest.resources);
|
|
2189
2703
|
const updated = {
|
|
2190
2704
|
...existing,
|
|
2705
|
+
...extra,
|
|
2191
2706
|
name: manifest.name,
|
|
2192
2707
|
compatibility_date: manifest.compatibilityDate
|
|
2193
2708
|
};
|
|
@@ -2196,6 +2711,8 @@ const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
|
|
|
2196
2711
|
if (d1Databases.length > 0) updated.d1_databases = d1Databases;
|
|
2197
2712
|
if (queues !== void 0) updated.queues = queues;
|
|
2198
2713
|
if (durableObjects !== void 0) updated.durable_objects = durableObjects;
|
|
2714
|
+
const migrations = buildMigrations(manifest.resources);
|
|
2715
|
+
if (migrations !== void 0 && updated.migrations === void 0) updated.migrations = migrations;
|
|
2199
2716
|
await writeFile(configFile, JSON.stringify(updated, void 0, 2));
|
|
2200
2717
|
};
|
|
2201
2718
|
/**
|
|
@@ -2234,56 +2751,50 @@ const scaffoldWranglerAndCi = async (configFile, _ci) => {
|
|
|
2234
2751
|
* Cloudflare REST API (via infra/). Never called in the deployed Worker runtime.
|
|
2235
2752
|
*/
|
|
2236
2753
|
/**
|
|
2237
|
-
*
|
|
2238
|
-
*
|
|
2239
|
-
*
|
|
2240
|
-
*
|
|
2241
|
-
*
|
|
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).
|
|
2754
|
+
* Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api (each
|
|
2755
|
+
* returns one entry PER configured instance), gated by ctx.has(name) so absent plugins are skipped —
|
|
2756
|
+
* never sibling pluginConfigs (F6). The single place the deploy stage is baked into names: the worker
|
|
2757
|
+
* name and every provisioned resource `name` are run through {@link stageName} (bindings/DO class
|
|
2758
|
+
* names are never suffixed), so provisioning, the existence diff, and the generated config all agree.
|
|
2257
2759
|
*
|
|
2258
2760
|
* @param ctx - The deploy plugin context.
|
|
2259
|
-
* @
|
|
2761
|
+
* @param stage - The deploy stage (e.g. "production", "dev") applied to every resource name.
|
|
2762
|
+
* @returns The assembled manifest (stage-qualified name, compatibilityDate, per-instance resources).
|
|
2260
2763
|
* @example
|
|
2261
2764
|
* ```ts
|
|
2262
|
-
* const manifest = assembleManifest(ctx);
|
|
2765
|
+
* const manifest = assembleManifest(ctx, "production");
|
|
2263
2766
|
* ```
|
|
2264
2767
|
*/
|
|
2265
|
-
const assembleManifest = (ctx) =>
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
ctx.has("
|
|
2270
|
-
ctx.has("
|
|
2271
|
-
ctx.has("
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2768
|
+
const assembleManifest = (ctx, stage) => {
|
|
2769
|
+
const resources = [
|
|
2770
|
+
ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : [],
|
|
2771
|
+
ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : [],
|
|
2772
|
+
ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : [],
|
|
2773
|
+
ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : [],
|
|
2774
|
+
ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : []
|
|
2775
|
+
].flat();
|
|
2776
|
+
return {
|
|
2777
|
+
name: stageName(ctx.global.name, stage),
|
|
2778
|
+
compatibilityDate: ctx.global.compatibilityDate,
|
|
2779
|
+
resources: resources.map((resource) => "name" in resource ? {
|
|
2780
|
+
...resource,
|
|
2781
|
+
name: stageName(resource.name, stage)
|
|
2782
|
+
} : resource)
|
|
2783
|
+
};
|
|
2784
|
+
};
|
|
2276
2785
|
/**
|
|
2277
|
-
* Act on an infra plan: skip the resources that already exist (reusing their ids), create
|
|
2278
|
-
*
|
|
2786
|
+
* Act on an infra plan: skip the resources that already exist (reusing their ids), create the
|
|
2787
|
+
* missing ones (capturing each new id), and announce each via provision:skip / :resource. Resilient
|
|
2788
|
+
* — a single resource that fails to create is CAPTURED in `failed` (not thrown), so one bad resource
|
|
2789
|
+
* (e.g. an invalid bucket name) never aborts the whole run and the caller can report a clear result.
|
|
2279
2790
|
*
|
|
2280
2791
|
* @param ctx - The deploy plugin context.
|
|
2281
2792
|
* @param plan - The infra plan from planInfra (existing vs missing).
|
|
2282
2793
|
* @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
|
|
2283
|
-
* @returns The provisioning result: created, skipped, and the merged binding → id map.
|
|
2794
|
+
* @returns The provisioning result: created, skipped, failed, and the merged binding → id map.
|
|
2284
2795
|
* @example
|
|
2285
2796
|
* ```ts
|
|
2286
|
-
* const {
|
|
2797
|
+
* const { created, failed } = await applyPlan(ctx, plan, false);
|
|
2287
2798
|
* ```
|
|
2288
2799
|
*/
|
|
2289
2800
|
const applyPlan = async (ctx, plan, ci) => {
|
|
@@ -2296,7 +2807,8 @@ const applyPlan = async (ctx, plan, ci) => {
|
|
|
2296
2807
|
});
|
|
2297
2808
|
}
|
|
2298
2809
|
const created = [];
|
|
2299
|
-
|
|
2810
|
+
const failed = [];
|
|
2811
|
+
for (const resource of plan.missing) try {
|
|
2300
2812
|
const { id } = await provisionResource(resource, ci);
|
|
2301
2813
|
if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
|
|
2302
2814
|
created.push(id === void 0 ? { resource } : {
|
|
@@ -2307,14 +2819,234 @@ const applyPlan = async (ctx, plan, ci) => {
|
|
|
2307
2819
|
kind: resource.kind,
|
|
2308
2820
|
name: resourceName(resource)
|
|
2309
2821
|
});
|
|
2822
|
+
} catch (error) {
|
|
2823
|
+
failed.push({
|
|
2824
|
+
resource,
|
|
2825
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2826
|
+
});
|
|
2310
2827
|
}
|
|
2311
2828
|
return {
|
|
2312
2829
|
created,
|
|
2313
2830
|
skipped: plan.exists,
|
|
2831
|
+
failed,
|
|
2314
2832
|
ids
|
|
2315
2833
|
};
|
|
2316
2834
|
};
|
|
2317
2835
|
/**
|
|
2836
|
+
* Sentinel a guided helper resolves to when the user declined recovery — a clean abort the caller
|
|
2837
|
+
* turns into a `deploy:phase aborted` + early return, never a thrown (and re-rendered) error.
|
|
2838
|
+
*/
|
|
2839
|
+
const ABORTED = Symbol("deploy:aborted");
|
|
2840
|
+
/** Retry guidance shown beneath each step's failure, before the "Retry?" prompt. */
|
|
2841
|
+
const HINTS = {
|
|
2842
|
+
build: "Web build failed — fix the error above, then retry.",
|
|
2843
|
+
provision: "Verify your token's account scopes and Cloudflare's status, then retry.",
|
|
2844
|
+
upload: "R2 upload failed — check the bucket and your token's R2 scope, then retry.",
|
|
2845
|
+
deploy: "wrangler deploy failed — review the output above, then retry."
|
|
2846
|
+
};
|
|
2847
|
+
/**
|
|
2848
|
+
* Emit the terminal `aborted` phase — the single exit every guided gate/retry funnels through when
|
|
2849
|
+
* the user stops the deploy. Factored out so each abort path renders one consistent line.
|
|
2850
|
+
*
|
|
2851
|
+
* @param ctx - The deploy plugin context.
|
|
2852
|
+
* @returns Nothing.
|
|
2853
|
+
* @example
|
|
2854
|
+
* ```ts
|
|
2855
|
+
* if (declined) return emitAborted(ctx);
|
|
2856
|
+
* ```
|
|
2857
|
+
*/
|
|
2858
|
+
const emitAborted = (ctx) => ctx.emit("deploy:phase", { phase: "aborted" });
|
|
2859
|
+
/**
|
|
2860
|
+
* The full guided token setup shown after an auth failure on a TTY. Offers to walk the user through
|
|
2861
|
+
* it, and when accepted: prints WHERE to create the Cloudflare token (dashboard URL, which template,
|
|
2862
|
+
* the exact permissions to add) AND scaffolds a ready-to-fill `.env.local` — the same guidance baked
|
|
2863
|
+
* in as comments — for the user to paste the token + account id into (never clobbering an existing
|
|
2864
|
+
* file). Always ends pointing at the re-run.
|
|
2865
|
+
*
|
|
2866
|
+
* @param ctx - The deploy plugin context.
|
|
2867
|
+
* @param ui - The branded console to render the guidance through.
|
|
2868
|
+
* @param confirm - The yes/no prompt.
|
|
2869
|
+
* @returns Resolves once the guidance (and optional `.env.local` scaffold) has been rendered.
|
|
2870
|
+
* @example
|
|
2871
|
+
* ```ts
|
|
2872
|
+
* await guidedTokenSetup(ctx, createBrandConsole(), confirm);
|
|
2873
|
+
* ```
|
|
2874
|
+
*/
|
|
2875
|
+
const guidedTokenSetup = async (ctx, ui, confirm) => {
|
|
2876
|
+
if (!await confirm("Set up Cloudflare credentials now? (guided)")) {
|
|
2877
|
+
ui.info("Set CLOUDFLARE_API_TOKEN in .env.local, then run `deploy` again.");
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
const manifest = assembleManifest(ctx, ctx.global.stage);
|
|
2881
|
+
renderAuthSetup(ui, requiredToken(manifest));
|
|
2882
|
+
const { created, path } = await ensureEnvLocal(process.cwd(), envLocalScaffold(manifest));
|
|
2883
|
+
ui.info(created ? `Created ${path} — paste your token + account id there, then run \`deploy\` again.` : `${path} already exists — fill in CLOUDFLARE_API_TOKEN there, then run \`deploy\` again.`);
|
|
2884
|
+
};
|
|
2885
|
+
/**
|
|
2886
|
+
* Verify the `.env` token, turning a missing/invalid token into a guided recovery on a TTY: surface
|
|
2887
|
+
* WHY auth failed, then walk the user through {@link guidedTokenSetup} (where to create the token +
|
|
2888
|
+
* scaffold a `.env.local`). The env is snapshotted at app start, so a freshly-pasted token only
|
|
2889
|
+
* takes effect on a NEW run. In CI/pipes the branded error re-throws (fail-fast).
|
|
2890
|
+
*
|
|
2891
|
+
* @param ctx - The deploy plugin context.
|
|
2892
|
+
* @param deps - Interactivity + the confirm prompt.
|
|
2893
|
+
* @returns True when the token verified; false when the user must set it up and re-run.
|
|
2894
|
+
* @throws {Error} Re-throws the branded auth error in CI / non-interactive runs.
|
|
2895
|
+
* @example
|
|
2896
|
+
* ```ts
|
|
2897
|
+
* if (!(await guidedAuth(ctx, { interactive, confirm }))) return;
|
|
2898
|
+
* ```
|
|
2899
|
+
*/
|
|
2900
|
+
const guidedAuth = async (ctx, deps) => {
|
|
2901
|
+
try {
|
|
2902
|
+
await verifyAuth(ctx);
|
|
2903
|
+
return true;
|
|
2904
|
+
} catch (error) {
|
|
2905
|
+
if (!deps.interactive) throw error;
|
|
2906
|
+
const ui = createBrandConsole();
|
|
2907
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
2908
|
+
await guidedTokenSetup(ctx, ui, deps.confirm);
|
|
2909
|
+
return false;
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
/**
|
|
2913
|
+
* Run one external pipeline step with interactive recovery: on failure, render the branded error +
|
|
2914
|
+
* an actionable hint, then offer to retry — looping until the step succeeds or the user declines.
|
|
2915
|
+
* A decline resolves to {@link ABORTED} (a clean abort the caller surfaces), so the error is shown
|
|
2916
|
+
* once, not re-rendered downstream. In CI/pipes the first failure re-throws (fail-fast). The step
|
|
2917
|
+
* MUST be safe to re-run (idempotent).
|
|
2918
|
+
*
|
|
2919
|
+
* @param step - The async step to run (e.g. the web build, the R2 upload, `wrangler deploy`).
|
|
2920
|
+
* @param hint - One-line guidance shown beneath the error before the retry prompt.
|
|
2921
|
+
* @param deps - Interactivity + the confirm prompt.
|
|
2922
|
+
* @returns The step's resolved value once it succeeds, or {@link ABORTED} when a retry is declined.
|
|
2923
|
+
* @throws {Error} Re-throws the step's error in CI / non-interactive runs.
|
|
2924
|
+
* @example
|
|
2925
|
+
* ```ts
|
|
2926
|
+
* const url = await guidedStep(() => runWrangler(args), "wrangler deploy failed …", deps);
|
|
2927
|
+
* if (url === ABORTED) return;
|
|
2928
|
+
* ```
|
|
2929
|
+
*/
|
|
2930
|
+
const guidedStep = async (step, hint, deps) => {
|
|
2931
|
+
for (;;) try {
|
|
2932
|
+
return await step();
|
|
2933
|
+
} catch (error) {
|
|
2934
|
+
if (!deps.interactive) throw error;
|
|
2935
|
+
const ui = createBrandConsole();
|
|
2936
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
2937
|
+
ui.info(hint);
|
|
2938
|
+
if (!await deps.confirm("Retry?")) return ABORTED;
|
|
2939
|
+
}
|
|
2940
|
+
};
|
|
2941
|
+
/**
|
|
2942
|
+
* Run the read-only infra preflight with interactive recovery: a network/scope failure fails fast in
|
|
2943
|
+
* CI, or (on a TTY) renders the error + hint and offers a retry. Resolves the plan, or {@link ABORTED}
|
|
2944
|
+
* when the user declines the retry.
|
|
2945
|
+
*
|
|
2946
|
+
* @param ctx - The deploy plugin context.
|
|
2947
|
+
* @param manifest - The assembled (or caller-supplied) deploy manifest.
|
|
2948
|
+
* @param deps - Interactivity + the confirm prompt.
|
|
2949
|
+
* @returns The infra plan, or {@link ABORTED} when a preflight retry is declined.
|
|
2950
|
+
* @throws {Error} Re-throws the preflight error in CI / non-interactive runs.
|
|
2951
|
+
* @example
|
|
2952
|
+
* ```ts
|
|
2953
|
+
* const plan = await guidedPlan(ctx, manifest, deps);
|
|
2954
|
+
* if (plan === ABORTED) return;
|
|
2955
|
+
* ```
|
|
2956
|
+
*/
|
|
2957
|
+
const guidedPlan = async (ctx, manifest, deps) => {
|
|
2958
|
+
for (;;) try {
|
|
2959
|
+
return await planInfra(ctx, manifest);
|
|
2960
|
+
} catch (error) {
|
|
2961
|
+
if (!deps.interactive) throw error;
|
|
2962
|
+
const ui = createBrandConsole();
|
|
2963
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
2964
|
+
ui.info(HINTS.provision);
|
|
2965
|
+
if (!await deps.confirm("Retry?")) return ABORTED;
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
/**
|
|
2969
|
+
* Plan + provision the infra with branded panels and interactive recovery. Each attempt RE-PLANS
|
|
2970
|
+
* (a resource created by a prior attempt is seen as existing and skipped — retries stay idempotent),
|
|
2971
|
+
* renders the plan panel (what will be created vs already exists), confirms the create gate, creates
|
|
2972
|
+
* the resources, then renders the result panel (created / skipped / failed). When some resources
|
|
2973
|
+
* FAIL it offers to retry just those (interactive) or fails fast (CI). Resolves to {@link ABORTED}
|
|
2974
|
+
* when the user declines the gate or a retry.
|
|
2975
|
+
*
|
|
2976
|
+
* @param ctx - The deploy plugin context.
|
|
2977
|
+
* @param manifest - The assembled (or caller-supplied) deploy manifest.
|
|
2978
|
+
* @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
|
|
2979
|
+
* @param deps - Interactivity + the confirm prompt.
|
|
2980
|
+
* @returns The provisioning result (all created/skipped), or {@link ABORTED} when the user declined.
|
|
2981
|
+
* @throws {Error} Re-throws a plan error, or throws on a provision failure, in CI / non-interactive runs.
|
|
2982
|
+
* @example
|
|
2983
|
+
* ```ts
|
|
2984
|
+
* const provisioned = await guidedProvision(ctx, manifest, ci, deps);
|
|
2985
|
+
* if (provisioned === ABORTED) return;
|
|
2986
|
+
* ```
|
|
2987
|
+
*/
|
|
2988
|
+
const guidedProvision = async (ctx, manifest, ci, deps) => {
|
|
2989
|
+
for (;;) {
|
|
2990
|
+
const plan = await guidedPlan(ctx, manifest, deps);
|
|
2991
|
+
if (plan === ABORTED) return ABORTED;
|
|
2992
|
+
const ui = createBrandConsole();
|
|
2993
|
+
renderPlan(ui, plan);
|
|
2994
|
+
if (plan.missing.length > 0 && !await deps.confirm(`Create ${String(plan.missing.length)} missing resource(s) in "${plan.account}"?`)) return ABORTED;
|
|
2995
|
+
const result = await applyPlan(ctx, plan, ci);
|
|
2996
|
+
renderProvisionResult(ui, result);
|
|
2997
|
+
if (result.failed.length === 0) return result;
|
|
2998
|
+
if (!deps.interactive) throw new Error(`[moku-worker] ${String(result.failed.length)} resource(s) failed to provision.`);
|
|
2999
|
+
if (!await deps.confirm("Retry the failed resource(s)?")) return ABORTED;
|
|
3000
|
+
}
|
|
3001
|
+
};
|
|
3002
|
+
/**
|
|
3003
|
+
* Build the web site first (when a hook is wired in), so its assets exist before the R2 upload and
|
|
3004
|
+
* `wrangler deploy`. Emits the `build · web` phase, then runs the build with interactive retry.
|
|
3005
|
+
*
|
|
3006
|
+
* @param ctx - The deploy plugin context.
|
|
3007
|
+
* @param webBuild - The web build hook, or undefined when none is wired (then this is a no-op).
|
|
3008
|
+
* @param deps - Interactivity + the confirm prompt.
|
|
3009
|
+
* @returns True to continue the pipeline; false when the user declined a build retry (abort).
|
|
3010
|
+
* @example
|
|
3011
|
+
* ```ts
|
|
3012
|
+
* if (!(await guidedWebBuild(ctx, webBuild, deps))) return emitAborted(ctx);
|
|
3013
|
+
* ```
|
|
3014
|
+
*/
|
|
3015
|
+
const guidedWebBuild = async (ctx, webBuild, deps) => {
|
|
3016
|
+
if (webBuild === void 0) return true;
|
|
3017
|
+
ctx.emit("deploy:phase", {
|
|
3018
|
+
phase: "build",
|
|
3019
|
+
detail: "web"
|
|
3020
|
+
});
|
|
3021
|
+
return await guidedStep(() => webBuild(), HINTS.build, deps) !== ABORTED;
|
|
3022
|
+
};
|
|
3023
|
+
/**
|
|
3024
|
+
* Upload the R2 directory when a bucket declares an upload source, with interactive retry. Emits the
|
|
3025
|
+
* `upload · N files` phase on success; a no-op (and emits nothing) when no bucket declares an upload.
|
|
3026
|
+
*
|
|
3027
|
+
* @param ctx - The deploy plugin context.
|
|
3028
|
+
* @param manifest - The assembled (or caller-supplied) deploy manifest.
|
|
3029
|
+
* @param deps - Interactivity + the confirm prompt.
|
|
3030
|
+
* @returns True to continue the pipeline; false when the user declined an upload retry (abort).
|
|
3031
|
+
* @example
|
|
3032
|
+
* ```ts
|
|
3033
|
+
* if (!(await guidedUpload(ctx, manifest, deps))) return emitAborted(ctx);
|
|
3034
|
+
* ```
|
|
3035
|
+
*/
|
|
3036
|
+
const guidedUpload = async (ctx, manifest, deps) => {
|
|
3037
|
+
const r2 = manifest.resources.find((resource) => resource.kind === "r2");
|
|
3038
|
+
if (!r2?.upload) return true;
|
|
3039
|
+
const bucket = r2.name;
|
|
3040
|
+
const uploadDir = r2.upload;
|
|
3041
|
+
const count = await guidedStep(() => uploadDirToR2(bucket, uploadDir), HINTS.upload, deps);
|
|
3042
|
+
if (count === ABORTED) return false;
|
|
3043
|
+
ctx.emit("deploy:phase", {
|
|
3044
|
+
phase: "upload",
|
|
3045
|
+
detail: `${String(count)} files`
|
|
3046
|
+
});
|
|
3047
|
+
return true;
|
|
3048
|
+
};
|
|
3049
|
+
/**
|
|
2318
3050
|
* Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
|
|
2319
3051
|
* runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
|
|
2320
3052
|
* `wrangler deploy`, emitting global deploy events along the way.
|
|
@@ -2333,10 +3065,16 @@ const createDeployApi = (ctx) => ({
|
|
|
2333
3065
|
* missing) → wrangler-config (with real ids) → upload → deploy. When opts.manifest is supplied
|
|
2334
3066
|
* it is used verbatim (universal path).
|
|
2335
3067
|
*
|
|
3068
|
+
* On a TTY the run is GUIDED end to end: each gate is confirmed, and every failure is recovered
|
|
3069
|
+
* interactively rather than thrown — a missing/invalid token offers `auth setup`, and the build,
|
|
3070
|
+
* infra, upload, and `wrangler deploy` steps offer a retry. In CI/pipes it fails fast (no prompt,
|
|
3071
|
+
* the first error propagates to the branded CLI handler).
|
|
3072
|
+
*
|
|
2336
3073
|
* @param opts - Optional run options.
|
|
2337
|
-
* @param opts.ci - CI/automated mode: never prompts, auto-confirms every gate. When
|
|
2338
|
-
* default) and stdout is a TTY, the deploy is guided — each gate is confirmed
|
|
2339
|
-
* Falls back to ctx.config.ci when omitted.
|
|
3074
|
+
* @param opts.ci - CI/automated mode: never prompts, auto-confirms every gate, fails fast. When
|
|
3075
|
+
* false (the default) and stdout is a TTY, the deploy is guided — each gate is confirmed and
|
|
3076
|
+
* failures are recovered interactively. Falls back to ctx.config.ci when omitted.
|
|
3077
|
+
* @param opts.stage - Target stage; suffixes resource names (`production` = bare). Falls back to the app stage.
|
|
2340
3078
|
* @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
|
|
2341
3079
|
* @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
|
|
2342
3080
|
* @returns Resolves once the deploy completes.
|
|
@@ -2348,46 +3086,32 @@ const createDeployApi = (ctx) => ({
|
|
|
2348
3086
|
*/
|
|
2349
3087
|
async run(opts) {
|
|
2350
3088
|
const ci = opts?.ci ?? ctx.config.ci;
|
|
2351
|
-
const
|
|
3089
|
+
const stage = opts?.stage ?? ctx.global.stage;
|
|
3090
|
+
const interactive = !ci && stdoutIsTty();
|
|
3091
|
+
const confirm = interactive ? createBrandPrompts().confirm : async (_question) => true;
|
|
3092
|
+
const deps = {
|
|
3093
|
+
interactive,
|
|
3094
|
+
confirm
|
|
3095
|
+
};
|
|
2352
3096
|
ctx.emit("deploy:phase", { phase: "auth" });
|
|
2353
|
-
await
|
|
2354
|
-
|
|
2355
|
-
if (webBuild !== void 0) {
|
|
2356
|
-
ctx.emit("deploy:phase", {
|
|
2357
|
-
phase: "build",
|
|
2358
|
-
detail: "web"
|
|
2359
|
-
});
|
|
2360
|
-
await webBuild();
|
|
2361
|
-
}
|
|
3097
|
+
if (!await guidedAuth(ctx, deps)) return emitAborted(ctx);
|
|
3098
|
+
if (!await guidedWebBuild(ctx, opts?.webBuild ?? ctx.config.webBuild, deps)) return emitAborted(ctx);
|
|
2362
3099
|
ctx.emit("deploy:phase", { phase: "detect" });
|
|
2363
|
-
const manifest = opts?.manifest ?? assembleManifest(ctx);
|
|
3100
|
+
const manifest = opts?.manifest ?? assembleManifest(ctx, stage);
|
|
2364
3101
|
ctx.emit("deploy:phase", { phase: "provision" });
|
|
2365
|
-
const
|
|
2366
|
-
if (
|
|
2367
|
-
ctx.emit("deploy:phase", { phase: "aborted" });
|
|
2368
|
-
return;
|
|
2369
|
-
}
|
|
2370
|
-
const { ids } = await applyPlan(ctx, plan, ci);
|
|
3102
|
+
const provisioned = await guidedProvision(ctx, manifest, ci, deps);
|
|
3103
|
+
if (provisioned === ABORTED) return emitAborted(ctx);
|
|
2371
3104
|
ctx.emit("deploy:phase", { phase: "wrangler-config" });
|
|
2372
|
-
await writeWranglerConfig(ctx.config.configFile, manifest, ids);
|
|
2373
|
-
|
|
2374
|
-
if (
|
|
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
|
-
}
|
|
3105
|
+
await writeWranglerConfig(ctx.config.configFile, manifest, provisioned.ids, wranglerExtra(ctx.config));
|
|
3106
|
+
if (!await guidedUpload(ctx, manifest, deps)) return emitAborted(ctx);
|
|
3107
|
+
if (!await confirm(`Deploy "${manifest.name}" to ${stage}?`)) return emitAborted(ctx);
|
|
2385
3108
|
ctx.emit("deploy:phase", { phase: "deploy" });
|
|
2386
|
-
const url = await runWrangler([
|
|
3109
|
+
const url = await guidedStep(() => runWrangler([
|
|
2387
3110
|
"deploy",
|
|
2388
3111
|
"--config",
|
|
2389
3112
|
ctx.config.configFile
|
|
2390
|
-
]);
|
|
3113
|
+
]), HINTS.deploy, deps);
|
|
3114
|
+
if (url === ABORTED) return emitAborted(ctx);
|
|
2391
3115
|
ctx.emit("deploy:complete", { url });
|
|
2392
3116
|
},
|
|
2393
3117
|
/**
|
|
@@ -2397,6 +3121,7 @@ const createDeployApi = (ctx) => ({
|
|
|
2397
3121
|
*
|
|
2398
3122
|
* @param opts - Optional options.
|
|
2399
3123
|
* @param opts.port - Local dev port (default 8787).
|
|
3124
|
+
* @param opts.stage - Stage for the generated config's resource names (defaults to the app stage).
|
|
2400
3125
|
* @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
|
|
2401
3126
|
* @returns Resolves when the dev session ends.
|
|
2402
3127
|
* @example
|
|
@@ -2404,7 +3129,11 @@ const createDeployApi = (ctx) => ({
|
|
|
2404
3129
|
* await api.dev({ port: 8787, webBuild: () => web.cli.build() });
|
|
2405
3130
|
* ```
|
|
2406
3131
|
*/
|
|
2407
|
-
dev
|
|
3132
|
+
async dev(opts) {
|
|
3133
|
+
const manifest = assembleManifest(ctx, opts?.stage ?? ctx.global.stage);
|
|
3134
|
+
await writeWranglerConfig(ctx.config.configFile, manifest, {}, wranglerExtra(ctx.config));
|
|
3135
|
+
await runDev(ctx, opts, realDevDeps());
|
|
3136
|
+
},
|
|
2408
3137
|
/**
|
|
2409
3138
|
* Scaffold a starting wrangler config (and CI files when ci is set).
|
|
2410
3139
|
* Idempotent: an existing config file is left untouched.
|
|
@@ -2430,7 +3159,7 @@ const createDeployApi = (ctx) => ({
|
|
|
2430
3159
|
* const plan = await api.checkInfra();
|
|
2431
3160
|
* ```
|
|
2432
3161
|
*/
|
|
2433
|
-
checkInfra: () => planInfra(ctx, assembleManifest(ctx)),
|
|
3162
|
+
checkInfra: () => planInfra(ctx, assembleManifest(ctx, ctx.global.stage)),
|
|
2434
3163
|
/**
|
|
2435
3164
|
* Create only the resources missing from the plan (skipping existing), capturing each id.
|
|
2436
3165
|
*
|
|
@@ -2462,7 +3191,19 @@ const createDeployApi = (ctx) => ({
|
|
|
2462
3191
|
* const { toAdd } = api.requiredToken();
|
|
2463
3192
|
* ```
|
|
2464
3193
|
*/
|
|
2465
|
-
requiredToken: () => requiredToken(assembleManifest(ctx)),
|
|
3194
|
+
requiredToken: () => requiredToken(assembleManifest(ctx, ctx.global.stage)),
|
|
3195
|
+
/**
|
|
3196
|
+
* Derive the REDUCED CI/automation redeploy token permission groups from the manifest (pure, no
|
|
3197
|
+
* network). Used by the branded `auth setup` renderer to show the scoped CI token alongside the
|
|
3198
|
+
* full LOCAL one.
|
|
3199
|
+
*
|
|
3200
|
+
* @returns The CI token permission groups (read-mostly, manifest-scoped).
|
|
3201
|
+
* @example
|
|
3202
|
+
* ```ts
|
|
3203
|
+
* const groups = api.ciToken();
|
|
3204
|
+
* ```
|
|
3205
|
+
*/
|
|
3206
|
+
ciToken: () => ciToken(assembleManifest(ctx, ctx.global.stage)),
|
|
2466
3207
|
/**
|
|
2467
3208
|
* Render the `auth setup` guidance from the derived token requirement (pure, no network).
|
|
2468
3209
|
*
|
|
@@ -2472,7 +3213,7 @@ const createDeployApi = (ctx) => ({
|
|
|
2472
3213
|
* const text = api.tokenInstructions();
|
|
2473
3214
|
* ```
|
|
2474
3215
|
*/
|
|
2475
|
-
tokenInstructions: () => tokenInstructions(assembleManifest(ctx)),
|
|
3216
|
+
tokenInstructions: () => tokenInstructions(assembleManifest(ctx, ctx.global.stage)),
|
|
2476
3217
|
/**
|
|
2477
3218
|
* Run an arbitrary wrangler command, streaming its output (the branded CLI escape hatch).
|
|
2478
3219
|
*
|
|
@@ -2568,6 +3309,45 @@ const parsePortArg = (argv) => {
|
|
|
2568
3309
|
if (Number.isInteger(port) && port > 0 && port <= MAX_PORT) return port;
|
|
2569
3310
|
}
|
|
2570
3311
|
};
|
|
3312
|
+
/**
|
|
3313
|
+
* Extract a `--stage` value from a single token (and the token after it, for the spaced form).
|
|
3314
|
+
*
|
|
3315
|
+
* @param token - The current argv token.
|
|
3316
|
+
* @param next - The following argv token (the value, for the `--stage dev` spaced form).
|
|
3317
|
+
* @returns The raw string value when this token is a stage flag, else undefined.
|
|
3318
|
+
* @example
|
|
3319
|
+
* ```ts
|
|
3320
|
+
* stageValueFrom("--stage=dev", undefined); // "dev"
|
|
3321
|
+
* stageValueFrom("--stage", "dev"); // "dev"
|
|
3322
|
+
* stageValueFrom("--port", "3000"); // undefined
|
|
3323
|
+
* ```
|
|
3324
|
+
*/
|
|
3325
|
+
const stageValueFrom = (token, next) => {
|
|
3326
|
+
const inline = /^--stage=(.+)$/u.exec(token);
|
|
3327
|
+
if (inline) return inline[1];
|
|
3328
|
+
if (token === "--stage") return next;
|
|
3329
|
+
};
|
|
3330
|
+
/**
|
|
3331
|
+
* Parse a `--stage <name>` / `--stage=<name>` flag out of an argv array — the deploy/dev stage that
|
|
3332
|
+
* drives the resource-name suffix (e.g. `tracker-db-dev`). Returns the first non-empty value, or
|
|
3333
|
+
* undefined so the caller can fall back to the app's configured stage.
|
|
3334
|
+
*
|
|
3335
|
+
* @param argv - The argv array to scan (the caller passes the process argv).
|
|
3336
|
+
* @returns The parsed stage string, or undefined when no `--stage` flag is present.
|
|
3337
|
+
* @example
|
|
3338
|
+
* ```ts
|
|
3339
|
+
* parseStageArg(["bun", "scripts/deploy.ts", "--stage", "dev"]); // "dev"
|
|
3340
|
+
* parseStageArg(["bun", "scripts/deploy.ts"]); // undefined
|
|
3341
|
+
* ```
|
|
3342
|
+
*/
|
|
3343
|
+
const parseStageArg = (argv) => {
|
|
3344
|
+
for (let index = 0; index < argv.length; index++) {
|
|
3345
|
+
const token = argv[index];
|
|
3346
|
+
if (token === void 0) continue;
|
|
3347
|
+
const raw = stageValueFrom(token, argv[index + 1]);
|
|
3348
|
+
if (raw !== void 0 && raw.length > 0) return raw;
|
|
3349
|
+
}
|
|
3350
|
+
};
|
|
2571
3351
|
//#endregion
|
|
2572
3352
|
//#region src/plugins/cli/api.ts
|
|
2573
3353
|
/**
|
|
@@ -2596,6 +3376,7 @@ const createCliApi = (ctx) => ({
|
|
|
2596
3376
|
*
|
|
2597
3377
|
* @param opts - Optional local dev options.
|
|
2598
3378
|
* @param opts.port - Local dev port to bind. Overrides the `--port` flag and the default.
|
|
3379
|
+
* @param opts.stage - Stage for the generated wrangler config; falls back to `--stage` then the app stage.
|
|
2599
3380
|
* @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
|
|
2600
3381
|
* @returns Resolves when the dev session ends.
|
|
2601
3382
|
* @example
|
|
@@ -2610,11 +3391,13 @@ const createCliApi = (ctx) => ({
|
|
|
2610
3391
|
label: "dev session"
|
|
2611
3392
|
});
|
|
2612
3393
|
const port = opts?.port ?? parsePortArg(process.argv) ?? ctx.config.port;
|
|
3394
|
+
const stage = opts?.stage ?? parseStageArg(process.argv);
|
|
2613
3395
|
try {
|
|
2614
|
-
await ctx.require(deployPlugin).dev(
|
|
3396
|
+
await ctx.require(deployPlugin).dev({
|
|
2615
3397
|
port,
|
|
2616
|
-
|
|
2617
|
-
|
|
3398
|
+
...stage === void 0 ? {} : { stage },
|
|
3399
|
+
...opts?.webBuild ? { webBuild: opts.webBuild } : {}
|
|
3400
|
+
});
|
|
2618
3401
|
ui.check(true, "dev session stopped cleanly");
|
|
2619
3402
|
} catch (error) {
|
|
2620
3403
|
ui.error(error instanceof Error ? error.message : String(error));
|
|
@@ -2629,17 +3412,22 @@ const createCliApi = (ctx) => ({
|
|
|
2629
3412
|
*
|
|
2630
3413
|
* @param opts - Optional deploy options.
|
|
2631
3414
|
* @param opts.ci - Automated mode: never prompts, auto-confirms. Omit/false → guided on a TTY.
|
|
3415
|
+
* @param opts.stage - Target stage (resource-name suffix); falls back to `--stage` then the app stage.
|
|
2632
3416
|
* @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
|
|
2633
3417
|
* @returns Resolves once the deploy completes (or after a failure is rendered).
|
|
2634
3418
|
* @example
|
|
2635
3419
|
* ```ts
|
|
2636
|
-
* await api.deploy({ webBuild: () => web.cli.build() });
|
|
2637
|
-
* await api.deploy({ ci: true, webBuild: () => web.cli.build() });
|
|
3420
|
+
* await api.deploy({ webBuild: () => web.cli.build() }); // guided, app stage
|
|
3421
|
+
* await api.deploy({ ci: true, webBuild: () => web.cli.build() }); // CI; `--stage dev` honored
|
|
2638
3422
|
* ```
|
|
2639
3423
|
*/
|
|
2640
3424
|
async deploy(opts) {
|
|
3425
|
+
const stage = opts?.stage ?? parseStageArg(process.argv);
|
|
2641
3426
|
try {
|
|
2642
|
-
await ctx.require(deployPlugin).run(
|
|
3427
|
+
await ctx.require(deployPlugin).run({
|
|
3428
|
+
...opts,
|
|
3429
|
+
...stage === void 0 ? {} : { stage }
|
|
3430
|
+
});
|
|
2643
3431
|
} catch (error) {
|
|
2644
3432
|
createBrandConsole().error(error instanceof Error ? error.message : String(error));
|
|
2645
3433
|
process.exitCode = 1;
|
|
@@ -2661,7 +3449,7 @@ const createCliApi = (ctx) => ({
|
|
|
2661
3449
|
const deploy = ctx.require(deployPlugin);
|
|
2662
3450
|
const ui = createBrandConsole();
|
|
2663
3451
|
if (sub === "setup") {
|
|
2664
|
-
|
|
3452
|
+
renderAuthSetup(ui, deploy.requiredToken(), { ci: deploy.ciToken() });
|
|
2665
3453
|
return;
|
|
2666
3454
|
}
|
|
2667
3455
|
try {
|
|
@@ -2749,12 +3537,12 @@ const WRANGLER_DIVIDER = ` ── wrangler ${"─".repeat(48)}`;
|
|
|
2749
3537
|
* never block the deploy pipeline (fire-and-forget, spec/07 §3,§4).
|
|
2750
3538
|
*
|
|
2751
3539
|
* @param ctx - CLI plugin context with injected log core API.
|
|
2752
|
-
* @returns Hook map for the
|
|
3540
|
+
* @returns Hook map for the deploy/dev phase + completion events (provision detail is panel-rendered).
|
|
2753
3541
|
* @example
|
|
2754
3542
|
* ```ts
|
|
2755
3543
|
* const hooks = createCliHooks(ctx);
|
|
2756
3544
|
* hooks["deploy:phase"]({ phase: "detect" }); // logs "detect" → renders " › detect"
|
|
2757
|
-
* hooks["
|
|
3545
|
+
* hooks["dev:phase"]({ phase: "serve", detail: "http://localhost:8787" }); // "serve · …"
|
|
2758
3546
|
* hooks["deploy:complete"]({ url: "https://x.workers.dev" }); // "deployed → https://x.workers.dev"
|
|
2759
3547
|
* ```
|
|
2760
3548
|
*/
|
|
@@ -2775,42 +3563,6 @@ const createCliHooks = (ctx) => {
|
|
|
2775
3563
|
ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
|
|
2776
3564
|
},
|
|
2777
3565
|
/**
|
|
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
3566
|
* Log one dev-session phase: "phase" or "phase · detail".
|
|
2815
3567
|
*
|
|
2816
3568
|
* @param p - The dev:phase event payload.
|