/** * `wrangler.toml` generator. * * Emits a minimal but complete Cloudflare Workers config covering: * - ModuleWorker entry point (`main = "..."`) * - Node.js compatibility flag for `nodejs_compat` consumers * - Static assets binding (for `mandu build --target=workers` public/ output) * - Compatibility date * * Phase 15.2+ will extend this with KV/D1/R2 bindings, Cron triggers, and * Durable Objects sections when session/db/storage adapters ship. */ export interface WranglerConfigOptions { /** Worker name (appears in Cloudflare dashboard URL). Required. */ projectName: string; /** * Relative path from `wrangler.toml` to the built worker script. Defaults * to `.mandu/workers/worker.js`. */ main?: string; /** * Compatibility date. Should match Cloudflare's docs — defaults to a * known-good recent date so published projects don't drift silently. * @default "2025-09-23" */ compatibilityDate?: string; /** * Compatibility flags. `nodejs_compat` is enabled by default — it's * required for `AsyncLocalStorage` and a handful of Node-shimmed imports * we pull in transitively (e.g. `node:buffer` via React). * @default ["nodejs_compat"] */ compatibilityFlags?: string[]; /** * Directory of static assets (relative to project root). When set, emits * a `[assets]` block so Wrangler serves the files before routing to the * worker. Usually `"public"`. */ assetsDir?: string; /** * Binding name for the assets handler. Only used when `assetsDir` is set. * @default "ASSETS" */ assetsBinding?: string; /** * Environment variables exposed on `env.X`. Values are stored in plain * text — secrets should go through `wrangler secret put` instead. */ vars?: Record; /** * Custom domains / routes (e.g. `"example.com/*"`). When empty the worker * is only reachable via the `.workers.dev` subdomain. */ routes?: Array<{ pattern: string; zoneName?: string; custom_domain?: boolean }>; /** * Workers Cron Triggers. Phase 15.2+ will wire these into * `@mandujs/core/scheduler` automatically. */ crons?: string[]; /** * Opt-in observability logs. Cloudflare offers logpush/tail — this maps * to `[observability]` in wrangler.toml. */ observability?: boolean; } /** * Generate a `wrangler.toml` file contents as a string. The caller is * responsible for writing it to disk; this keeps the function trivially * testable. * * @example * ```ts * const toml = generateWranglerConfig({ * projectName: "my-mandu-app", * assetsDir: "public", * }); * await Bun.write("./wrangler.toml", toml); * ``` */ export function generateWranglerConfig(options: WranglerConfigOptions): string { if (!options.projectName || typeof options.projectName !== "string") { throw new Error("generateWranglerConfig: projectName is required"); } if (!/^[a-z0-9-]+$/.test(options.projectName)) { throw new Error( `generateWranglerConfig: projectName must match /^[a-z0-9-]+$/ ` + `(got: "${options.projectName}")` ); } const main = options.main ?? ".mandu/workers/worker.js"; const compatDate = options.compatibilityDate ?? "2025-09-23"; const compatFlags = options.compatibilityFlags ?? ["nodejs_compat"]; const lines: string[] = []; lines.push(`# Generated by mandu build --target=workers`); lines.push(`# See https://developers.cloudflare.com/workers/wrangler/configuration/`); lines.push(""); lines.push(`name = "${options.projectName}"`); lines.push(`main = "${main}"`); lines.push(`compatibility_date = "${compatDate}"`); if (compatFlags.length > 0) { lines.push(`compatibility_flags = ${JSON.stringify(compatFlags)}`); } if (options.observability) { lines.push(""); lines.push("[observability]"); lines.push("enabled = true"); } if (options.assetsDir) { const binding = options.assetsBinding ?? "ASSETS"; lines.push(""); lines.push("[assets]"); lines.push(`directory = "${options.assetsDir}"`); lines.push(`binding = "${binding}"`); } if (options.vars && Object.keys(options.vars).length > 0) { lines.push(""); lines.push("[vars]"); for (const [key, value] of Object.entries(options.vars)) { if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) { throw new Error( `generateWranglerConfig: var name "${key}" must be UPPER_SNAKE_CASE` ); } lines.push(`${key} = ${JSON.stringify(value)}`); } } if (options.routes && options.routes.length > 0) { for (const route of options.routes) { lines.push(""); lines.push("[[routes]]"); lines.push(`pattern = ${JSON.stringify(route.pattern)}`); if (route.zoneName) { lines.push(`zone_name = ${JSON.stringify(route.zoneName)}`); } if (route.custom_domain) { lines.push(`custom_domain = true`); } } } if (options.crons && options.crons.length > 0) { lines.push(""); lines.push("[triggers]"); lines.push(`crons = ${JSON.stringify(options.crons)}`); } // Trailing newline for POSIX file conventions. return lines.join("\n") + "\n"; }