diff --git a/.changeset/wrangler-build-write-workerd-config.md b/.changeset/wrangler-build-write-workerd-config.md new file mode 100644 index 000000000000..c5a1c92a41af --- /dev/null +++ b/.changeset/wrangler-build-write-workerd-config.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +wrangler build: add a new flag --write-workerd-config [path] to generate a workerd capnp configuration file for the built Worker, suitable for running with the workerd binary. The path may be a file or a directory (defaults to workerd.capnp). diff --git a/packages/wrangler/src/__tests__/build-write-workerd-config.test.ts b/packages/wrangler/src/__tests__/build-write-workerd-config.test.ts new file mode 100644 index 000000000000..80299c385d31 --- /dev/null +++ b/packages/wrangler/src/__tests__/build-write-workerd-config.test.ts @@ -0,0 +1,38 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { execa } from "execa"; +import { runInTempDir } from "./helpers/run-in-tmp"; + +describe("wrangler build --write-workerd-config", () => { + it("writes a workerd capnp config to the specified path", async () => { + runInTempDir(); + writeFileSync( + join(process.cwd(), "wrangler.toml"), + `name = "test-worker"\nmain = "index.js"\ncompatibility_date = "2024-01-01"\n` + ); + writeFileSync( + join(process.cwd(), "index.js"), + `export default { fetch() { return new Response("ok"); } }` + ); + + await execa( + "node", + [ + join(__dirname, "..", "..", "bin", "wrangler.js"), + "build", + "--write-workerd-config", + "out.capnp", + ], + { + cwd: process.cwd(), + timeout: 120_000, + stdio: "pipe", + } + ); + + const outPath = join(process.cwd(), "out.capnp"); + expect(existsSync(outPath)).toBe(true); + const buf = readFileSync(outPath); + expect(buf.byteLength).toBeGreaterThan(0); + }, 120_000); +}); diff --git a/packages/wrangler/src/build/index.ts b/packages/wrangler/src/build/index.ts index c94c2aeb9ae5..72e4ea6073e0 100644 --- a/packages/wrangler/src/build/index.ts +++ b/packages/wrangler/src/build/index.ts @@ -12,14 +12,26 @@ export const buildCommand = createCommand({ printBanner: false, provideConfig: false, }, + args: { + "write-workerd-config": { + type: "string", + describe: + "Path to write a workerd capnp config for running the built worker in workerd", + requiresArg: true, + }, + }, async handler(buildArgs) { - const { wrangler } = createCLIParser([ + const argv = [ "deploy", "--dry-run", "--outdir=dist", ...(buildArgs.env ? ["--env", buildArgs.env] : []), ...(buildArgs.config ? ["--config", buildArgs.config] : []), - ]); + ...(buildArgs.writeWorkerdConfig + ? ["--write-workerd-config", buildArgs.writeWorkerdConfig as string] + : []), + ]; + const { wrangler } = createCLIParser(argv); await wrangler.parse(); }, }); diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index 3048a55d5ec3..5a51ae6a8329 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -358,6 +358,7 @@ export default async function deploy(props: Props): Promise<{ versionId: string | null; workerTag: string | null; targets?: string[]; + emittedEntryPath: string; }> { const deployConfirm = getDeployConfirmFunction(props.strict); @@ -422,7 +423,7 @@ export default async function deploy(props: Props): Promise<{ "Deploying the Worker will override the remote configuration with your local one." ); if (!(await deployConfirm("Would you like to continue?"))) { - return { versionId, workerTag }; + return { versionId, workerTag, emittedEntryPath: "" }; } } } else { @@ -430,7 +431,7 @@ export default async function deploy(props: Props): Promise<{ `You are about to publish a Workers Service that was last published via the Cloudflare Dashboard.\nEdits that have been made via the dashboard will be overridden by your local code and config.` ); if (!(await deployConfirm("Would you like to continue?"))) { - return { versionId, workerTag }; + return { versionId, workerTag, emittedEntryPath: "" }; } } } else if (script.last_deployed_from === "api") { @@ -438,7 +439,7 @@ export default async function deploy(props: Props): Promise<{ `You are about to publish a Workers Service that was last updated via the script API.\nEdits that have been made via the script API will be overridden by your local code and config.` ); if (!(await deployConfirm("Would you like to continue?"))) { - return { versionId, workerTag }; + return { versionId, workerTag, emittedEntryPath: "" }; } } } catch (e) { @@ -537,7 +538,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m ); if (!yes) { cancel("Aborting deploy..."); - return { versionId, workerTag }; + return { versionId, workerTag, emittedEntryPath: "" }; } } @@ -574,6 +575,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } let sourceMapSize; + let emittedEntryPath = ""; const normalisedContainerConfig = await getNormalizedContainerOptions( config, props @@ -671,6 +673,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m : module.content.byteLength; dependencies[modulePath] = { bytesInOutput }; } + emittedEntryPath = resolvedEntryPointPath; const content = readFileSync(resolvedEntryPointPath, { encoding: "utf-8", @@ -1109,7 +1112,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m if (props.dryRun) { logger.log(`--dry-run: exiting now.`); - return { versionId, workerTag }; + return { versionId, workerTag, emittedEntryPath }; } const uploadMs = Date.now() - start; @@ -1119,7 +1122,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m // Early exit for WfP since it doesn't need the below code if (props.dispatchNamespace !== undefined) { deployWfpUserWorker(props.dispatchNamespace, versionId); - return { versionId, workerTag }; + return { versionId, workerTag, emittedEntryPath }; } if (normalisedContainerConfig.length) { @@ -1145,6 +1148,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m sourceMapSize, versionId, workerTag, + emittedEntryPath, targets: targets ?? [], }; } diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index ef6b0a6fcf66..9f09ae8acc82 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -22,6 +22,7 @@ import { getRules } from "../utils/getRules"; import { getScriptName } from "../utils/getScriptName"; import { isLegacyEnv } from "../utils/isLegacyEnv"; import deploy from "./deploy"; +import type { Config } from "miniflare"; export const deployCommand = createCommand({ metadata: { @@ -180,6 +181,12 @@ export const deployCommand = createCommand({ describe: "Don't actually deploy", type: "boolean", }, + "write-workerd-config": { + describe: + "Path to write a workerd capnp config for running the built worker in workerd (only with --dry-run)", + type: "string", + requiresArg: true, + }, metafile: { describe: "Path to output build metadata from esbuild. If flag is used without a path, defaults to 'bundle-meta.json' inside the directory specified by --outdir.", @@ -350,46 +357,47 @@ export const deployCommand = createCommand({ config.configPath ); } - const { sourceMapSize, versionId, workerTag, targets } = await deploy({ - config, - accountId, - name, - rules: getRules(config), - entry, - env: args.env, - compatibilityDate: args.latest - ? formatCompatibilityDate(new Date()) - : args.compatibilityDate, - compatibilityFlags: args.compatibilityFlags, - vars: cliVars, - defines: cliDefines, - alias: cliAlias, - triggers: args.triggers, - jsxFactory: args.jsxFactory, - jsxFragment: args.jsxFragment, - tsconfig: args.tsconfig, - routes: args.routes, - domains: args.domains, - assetsOptions, - legacyAssetPaths: siteAssetPaths, - legacyEnv: isLegacyEnv(config), - minify: args.minify, - isWorkersSite: Boolean(args.site || config.site), - outDir: args.outdir, - outFile: args.outfile, - dryRun: args.dryRun, - metafile: args.metafile, - noBundle: !(args.bundle ?? !config.no_bundle), - keepVars: args.keepVars, - logpush: args.logpush, - uploadSourceMaps: args.uploadSourceMaps, - oldAssetTtl: args.oldAssetTtl, - projectRoot, - dispatchNamespace: args.dispatchNamespace, - experimentalAutoCreate: args.experimentalAutoCreate, - containersRollout: args.containersRollout, - strict: args.strict, - }); + const { sourceMapSize, versionId, workerTag, targets, emittedEntryPath } = + await deploy({ + config, + accountId, + name, + rules: getRules(config), + entry, + env: args.env, + compatibilityDate: args.latest + ? formatCompatibilityDate(new Date()) + : args.compatibilityDate, + compatibilityFlags: args.compatibilityFlags, + vars: cliVars, + defines: cliDefines, + alias: cliAlias, + triggers: args.triggers, + jsxFactory: args.jsxFactory, + jsxFragment: args.jsxFragment, + tsconfig: args.tsconfig, + routes: args.routes, + domains: args.domains, + assetsOptions, + legacyAssetPaths: siteAssetPaths, + legacyEnv: isLegacyEnv(config), + minify: args.minify, + isWorkersSite: Boolean(args.site || config.site), + outDir: args.outdir, + outFile: args.outfile, + dryRun: args.dryRun, + metafile: args.metafile, + noBundle: !(args.bundle ?? !config.no_bundle), + keepVars: args.keepVars, + logpush: args.logpush, + uploadSourceMaps: args.uploadSourceMaps, + oldAssetTtl: args.oldAssetTtl, + projectRoot, + dispatchNamespace: args.dispatchNamespace, + experimentalAutoCreate: args.experimentalAutoCreate, + containersRollout: args.containersRollout, + strict: args.strict, + }); writeOutput({ type: "deploy", @@ -413,6 +421,56 @@ export const deployCommand = createCommand({ sendMetrics: config.send_metrics, } ); + + if (args.dryRun && args.writeWorkerdConfig) { + const outPathArg = args.writeWorkerdConfig as string; + const fs = await import("node:fs/promises"); + const { serializeConfig } = await import("miniflare"); + + const serviceName = name ?? "worker"; + + const chosenEntry = emittedEntryPath; + if (!chosenEntry) { + throw new UserError( + "Failed to determine emitted bundle entry to generate workerd config. Please ensure bundling succeeded." + ); + } + + const workerdConfig: Config = { + services: [ + { + name: serviceName, + worker: { + modules: [ + { + name: path.basename(chosenEntry), + esModule: chosenEntry, + }, + ], + compatibilityDate: args.latest + ? formatCompatibilityDate(new Date()) + : args.compatibilityDate ?? config.compatibility_date, + compatibilityFlags: + args.compatibilityFlags ?? config.compatibility_flags, + }, + }, + ], + }; + + let outputPath = outPathArg; + try { + const stat = await fs.stat(outPathArg); + if (stat.isDirectory()) { + outputPath = path.join(outPathArg, "workerd.capnp"); + } + } catch { + await fs.mkdir(path.dirname(outPathArg), { recursive: true }); + } + + const buf = serializeConfig(workerdConfig); + await fs.writeFile(outputPath, buf); + logger.log(`Wrote workerd config to ${path.resolve(outputPath)}`); + } }, });