diff --git a/.changeset/rich-pots-mate.md b/.changeset/rich-pots-mate.md new file mode 100644 index 000000000000..bd204271aed2 --- /dev/null +++ b/.changeset/rich-pots-mate.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Add `--outfile` to `wrangler deploy` for generating a worker bundle. + +This is an advanced feature that most users won't need to use. When set, Wrangler will output your built Worker bundle in a Cloudflare specific format that captures all information needed to deploy a Worker using the [Worker Upload API](https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/update/) diff --git a/.changeset/startup-profiling.md b/.changeset/startup-profiling.md new file mode 100644 index 000000000000..42b5a3707fa9 --- /dev/null +++ b/.changeset/startup-profiling.md @@ -0,0 +1,12 @@ +--- +"wrangler": minor +--- + +Add a `wrangler check startup` command to generate a CPU profile of your Worker's startup phase. + +This can be imported into Chrome DevTools or opened directly in VSCode to view a flamegraph of your Worker's startup phase. Additionally, when a Worker deployment fails with a startup time error Wrangler will automatically generate a CPU profile for easy investigation. + +Advanced usage: + +- `--deploy-args`: to customise the way `wrangler check startup` builds your Worker for analysis, provide the exact arguments you use when deploying your Worker with `wrangler deploy`. For instance, if you deploy your Worker with `wrangler deploy --no-bundle`, you should use `wrangler check startup --deploy-args="--no-bundle"` to profile the startup phase. +- `--worker-bundle`: if you don't use Wrangler to deploy your Worker, you can use this argument to provide a Worker bundle to analyse. This should be a file path to a serialised multipart upload, with the exact same format as the API expects: https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/update/ diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index 3f2cd6271507..df7037ebac99 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -65,6 +65,14 @@ import type { FormData } from "undici"; import type { Mock } from "vitest"; vi.mock("command-exists"); +vi.mock("../check/commands", async (importOriginal) => { + return { + ...(await importOriginal()), + analyseBundle() { + return `{}`; + }, + }; +}); describe("deploy", () => { mockAccountId(); @@ -10500,6 +10508,426 @@ export default{ }); }); + describe("--outfile", () => { + it("should generate worker bundle at --outfile if specified", async () => { + writeWranglerConfig(); + writeWorkerSource(); + mockSubDomainRequest(); + mockUploadWorkerRequest(); + await runWrangler("deploy index.js --outfile some-dir/worker.bundle"); + expect(fs.existsSync("some-dir/worker.bundle")).toBe(true); + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "info": "", + "out": "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + No bindings found. + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class", + "warn": "", + } + `); + }); + + it("should include any module imports related assets in the worker bundle", async () => { + writeWranglerConfig(); + fs.writeFileSync( + "./index.js", + ` +import txt from './textfile.txt'; +import hello from './hello.wasm'; +export default{ + async fetch(){ + const module = await WebAssembly.instantiate(hello); + return new Response(txt + module.exports.hello); + } +} +` + ); + fs.writeFileSync("./textfile.txt", "Hello, World!"); + fs.writeFileSync("./hello.wasm", "Hello wasm World!"); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedModules: { + "./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt": + "Hello, World!", + "./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm": + "Hello wasm World!", + }, + }); + await runWrangler("deploy index.js --outfile some-dir/worker.bundle"); + + expect(fs.existsSync("some-dir/worker.bundle")).toBe(true); + expect( + fs + .readFileSync("some-dir/worker.bundle", "utf8") + .replace( + /------formdata-undici-0.[0-9]*/g, + "------formdata-undici-0.test" + ) + .replace(/wrangler_(.+?)_default/g, "wrangler_default") + ).toMatchInlineSnapshot(` + "------formdata-undici-0.test + Content-Disposition: form-data; name=\\"metadata\\" + + {\\"main_module\\":\\"index.js\\",\\"bindings\\":[],\\"compatibility_date\\":\\"2022-01-12\\",\\"compatibility_flags\\":[]} + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"index.js\\"; filename=\\"index.js\\" + Content-Type: application/javascript+module + + // index.js + import txt from \\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\"; + import hello from \\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\"; + var wrangler_default = { + async fetch() { + const module = await WebAssembly.instantiate(hello); + return new Response(txt + module.exports.hello); + } + }; + export { + wrangler_default as default + }; + //# sourceMappingURL=index.js.map + + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\"; filename=\\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\" + Content-Type: text/plain + + Hello, World! + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\"; filename=\\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\" + Content-Type: application/wasm + + Hello wasm World! + ------formdata-undici-0.test--" + `); + + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "info": "", + "out": "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + No bindings found. + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class", + "warn": "", + } + `); + }); + + it("should include bindings in the worker bundle", async () => { + writeWranglerConfig({ + kv_namespaces: [{ binding: "KV", id: "kv-namespace-id" }], + }); + fs.writeFileSync( + "./index.js", + ` +import txt from './textfile.txt'; +import hello from './hello.wasm'; +export default{ + async fetch(){ + const module = await WebAssembly.instantiate(hello); + return new Response(txt + module.exports.hello); + } +} +` + ); + fs.writeFileSync("./textfile.txt", "Hello, World!"); + fs.writeFileSync("./hello.wasm", "Hello wasm World!"); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedModules: { + "./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt": + "Hello, World!", + "./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm": + "Hello wasm World!", + }, + }); + await runWrangler("deploy index.js --outfile some-dir/worker.bundle"); + + expect(fs.existsSync("some-dir/worker.bundle")).toBe(true); + expect( + fs + .readFileSync("some-dir/worker.bundle", "utf8") + .replace( + /------formdata-undici-0.[0-9]*/g, + "------formdata-undici-0.test" + ) + .replace(/wrangler_(.+?)_default/g, "wrangler_default") + ).toMatchInlineSnapshot(` + "------formdata-undici-0.test + Content-Disposition: form-data; name=\\"metadata\\" + + {\\"main_module\\":\\"index.js\\",\\"bindings\\":[{\\"name\\":\\"KV\\",\\"type\\":\\"kv_namespace\\",\\"namespace_id\\":\\"kv-namespace-id\\"}],\\"compatibility_date\\":\\"2022-01-12\\",\\"compatibility_flags\\":[]} + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"index.js\\"; filename=\\"index.js\\" + Content-Type: application/javascript+module + + // index.js + import txt from \\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\"; + import hello from \\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\"; + var wrangler_default = { + async fetch() { + const module = await WebAssembly.instantiate(hello); + return new Response(txt + module.exports.hello); + } + }; + export { + wrangler_default as default + }; + //# sourceMappingURL=index.js.map + + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\"; filename=\\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\" + Content-Type: text/plain + + Hello, World! + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\"; filename=\\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\" + Content-Type: application/wasm + + Hello wasm World! + ------formdata-undici-0.test--" + `); + + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "info": "", + "out": "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + Your worker has access to the following bindings: + - KV Namespaces: + - KV: kv-namespace-id + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class", + "warn": "", + } + `); + }); + }); + + describe("--outfile", () => { + it("should generate worker bundle at --outfile if specified", async () => { + writeWranglerConfig(); + writeWorkerSource(); + mockSubDomainRequest(); + mockUploadWorkerRequest(); + await runWrangler("deploy index.js --outfile some-dir/worker.bundle"); + expect(fs.existsSync("some-dir/worker.bundle")).toBe(true); + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "info": "", + "out": "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + No bindings found. + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class", + "warn": "", + } + `); + }); + + it("should include any module imports related assets in the worker bundle", async () => { + writeWranglerConfig(); + fs.writeFileSync( + "./index.js", + ` +import txt from './textfile.txt'; +import hello from './hello.wasm'; +export default{ + async fetch(){ + const module = await WebAssembly.instantiate(hello); + return new Response(txt + module.exports.hello); + } +} +` + ); + fs.writeFileSync("./textfile.txt", "Hello, World!"); + fs.writeFileSync("./hello.wasm", "Hello wasm World!"); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedModules: { + "./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt": + "Hello, World!", + "./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm": + "Hello wasm World!", + }, + }); + await runWrangler("deploy index.js --outfile some-dir/worker.bundle"); + + expect(fs.existsSync("some-dir/worker.bundle")).toBe(true); + expect( + fs + .readFileSync("some-dir/worker.bundle", "utf8") + .replace( + /------formdata-undici-0.[0-9]*/g, + "------formdata-undici-0.test" + ) + .replace(/wrangler_(.+?)_default/g, "wrangler_default") + ).toMatchInlineSnapshot(` + "------formdata-undici-0.test + Content-Disposition: form-data; name=\\"metadata\\" + + {\\"main_module\\":\\"index.js\\",\\"bindings\\":[],\\"compatibility_date\\":\\"2022-01-12\\",\\"compatibility_flags\\":[]} + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"index.js\\"; filename=\\"index.js\\" + Content-Type: application/javascript+module + + // index.js + import txt from \\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\"; + import hello from \\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\"; + var wrangler_default = { + async fetch() { + const module = await WebAssembly.instantiate(hello); + return new Response(txt + module.exports.hello); + } + }; + export { + wrangler_default as default + }; + //# sourceMappingURL=index.js.map + + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\"; filename=\\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\" + Content-Type: text/plain + + Hello, World! + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\"; filename=\\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\" + Content-Type: application/wasm + + Hello wasm World! + ------formdata-undici-0.test--" + `); + + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "info": "", + "out": "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + No bindings found. + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class", + "warn": "", + } + `); + }); + + it("should include bindings in the worker bundle", async () => { + writeWranglerConfig({ + kv_namespaces: [{ binding: "KV", id: "kv-namespace-id" }], + }); + fs.writeFileSync( + "./index.js", + ` +import txt from './textfile.txt'; +import hello from './hello.wasm'; +export default{ + async fetch(){ + const module = await WebAssembly.instantiate(hello); + return new Response(txt + module.exports.hello); + } +} +` + ); + fs.writeFileSync("./textfile.txt", "Hello, World!"); + fs.writeFileSync("./hello.wasm", "Hello wasm World!"); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedModules: { + "./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt": + "Hello, World!", + "./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm": + "Hello wasm World!", + }, + }); + await runWrangler("deploy index.js --outfile some-dir/worker.bundle"); + + expect(fs.existsSync("some-dir/worker.bundle")).toBe(true); + expect( + fs + .readFileSync("some-dir/worker.bundle", "utf8") + .replace( + /------formdata-undici-0.[0-9]*/g, + "------formdata-undici-0.test" + ) + .replace(/wrangler_(.+?)_default/g, "wrangler_default") + ).toMatchInlineSnapshot(` + "------formdata-undici-0.test + Content-Disposition: form-data; name=\\"metadata\\" + + {\\"main_module\\":\\"index.js\\",\\"bindings\\":[{\\"name\\":\\"KV\\",\\"type\\":\\"kv_namespace\\",\\"namespace_id\\":\\"kv-namespace-id\\"}],\\"compatibility_date\\":\\"2022-01-12\\",\\"compatibility_flags\\":[]} + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"index.js\\"; filename=\\"index.js\\" + Content-Type: application/javascript+module + + // index.js + import txt from \\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\"; + import hello from \\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\"; + var wrangler_default = { + async fetch() { + const module = await WebAssembly.instantiate(hello); + return new Response(txt + module.exports.hello); + } + }; + export { + wrangler_default as default + }; + //# sourceMappingURL=index.js.map + + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\"; filename=\\"./0a0a9f2a6772942557ab5355d76af442f8f65e01-textfile.txt\\" + Content-Type: text/plain + + Hello, World! + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\"; filename=\\"./d025a03cd31e98e96fb5bd5bce87f9bca4e8ce2c-hello.wasm\\" + Content-Type: application/wasm + + Hello wasm World! + ------formdata-undici-0.test--" + `); + + expect(std).toMatchInlineSnapshot(` + Object { + "debug": "", + "err": "", + "info": "", + "out": "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + Your worker has access to the following bindings: + - KV Namespaces: + - KV: kv-namespace-id + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class", + "warn": "", + } + `); + }); + }); + describe("--dry-run", () => { it("should not deploy the worker if --dry-run is specified", async () => { writeWranglerConfig({ @@ -11070,36 +11498,9 @@ export default{ main: "index.js", }); - await expect(runWrangler("deploy")).rejects.toMatchInlineSnapshot( - `[APIError: A request to the Cloudflare API (/accounts/some-account-id/workers/scripts/test-name/versions) failed.]` + await expect(runWrangler("deploy")).rejects.toThrowError( + `Your Worker failed validation because it exceeded startup limits.` ); - expect(std).toMatchInlineSnapshot(` - Object { - "debug": "", - "err": "", - "info": "", - "out": "Total Upload: xx KiB / gzip: xx KiB - No bindings found. - - X [ERROR] A request to the Cloudflare API (/accounts/some-account-id/workers/scripts/test-name/versions) failed. - - Error: Script startup exceeded CPU time limit. [code: 10021] - - If you think this is a bug, please open an issue at: - https://github.com/cloudflare/workers-sdk/issues/new/choose - - ", - "warn": "▲ [WARNING] Your Worker failed validation because it exceeded startup limits. - - To ensure fast responses, we place constraints on Worker startup -- like how much CPU it can use, - or how long it can take. - Your Worker failed validation, which means it hit one of these startup limits. - Try reducing the amount of work done during startup (outside the event handler), either by - removing code or relocating it inside the event handler. - - ", - } - `); }); describe("unit tests", () => { diff --git a/packages/wrangler/src/__tests__/startup-profiling.test.ts b/packages/wrangler/src/__tests__/startup-profiling.test.ts new file mode 100644 index 000000000000..630211ba7e89 --- /dev/null +++ b/packages/wrangler/src/__tests__/startup-profiling.test.ts @@ -0,0 +1,131 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { describe, expect, test } from "vitest"; +import { collectCLIOutput } from "./helpers/collect-cli-output"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { useMockIsTTY } from "./helpers/mock-istty"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { runWrangler } from "./helpers/run-wrangler"; +import { writeWorkerSource } from "./helpers/write-worker-source"; +import { writeWranglerConfig } from "./helpers/write-wrangler-config"; + +describe("wrangler check startup", () => { + mockConsoleMethods(); + const std = collectCLIOutput(); + runInTempDir(); + const { setIsTTY } = useMockIsTTY(); + setIsTTY(false); + + test("generates profile for basic worker", async () => { + writeWranglerConfig({ main: "index.js" }); + writeWorkerSource(); + + await runWrangler("check startup"); + + expect(std.out).toContain( + `CPU Profile written to worker-startup.cpuprofile` + ); + + await expect( + readFile("worker-startup.cpuprofile", "utf8") + ).resolves.toContain("callFrame"); + }); + test("--outfile works", async () => { + writeWranglerConfig({ main: "index.js" }); + writeWorkerSource(); + + await runWrangler("check startup --outfile worker.cpuprofile"); + + expect(std.out).toContain(`CPU Profile written to worker.cpuprofile`); + }); + test("--args passed through to deploy", async () => { + writeWranglerConfig({ main: "index.js" }); + writeWorkerSource(); + + await expect( + runWrangler("check startup --args 'abc'") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The entry-point file at "abc" was not found.]` + ); + }); + + test("--worker-bundle is used instead of building", async () => { + writeWranglerConfig({ main: "index.js" }); + writeWorkerSource(); + + await runWrangler("deploy --dry-run --outfile worker.bundle"); + + await expect(readFile("worker.bundle", "utf8")).resolves.toContain( + "main_module" + ); + await runWrangler("check startup --worker-bundle worker.bundle"); + expect(std.out).not.toContain(`Building your Worker`); + + await expect( + readFile("worker-startup.cpuprofile", "utf8") + ).resolves.toContain("callFrame"); + }); + + test("pages (config file)", async () => { + mkdirSync("public"); + writeFileSync("public/README.md", "This is a readme"); + + mkdirSync("functions"); + writeFileSync( + "functions/hello.js", + ` + const a = true; + a(); + + export async function onRequest() { + return new Response("Hello, world!"); + } + ` + ); + writeWranglerConfig({ pages_build_output_dir: "public" }); + + await runWrangler("check startup"); + + expect(std.out).toContain(`Pages project detected`); + + expect(std.out).toContain( + `CPU Profile written to worker-startup.cpuprofile` + ); + + await expect( + readFile("worker-startup.cpuprofile", "utf8") + ).resolves.toContain("callFrame"); + }); + + test("pages (args)", async () => { + mkdirSync("public"); + writeFileSync("public/README.md", "This is a readme"); + + mkdirSync("functions"); + writeFileSync( + "functions/hello.js", + ` + const a = true; + a(); + + export async function onRequest() { + return new Response("Hello, world!"); + } + ` + ); + + await runWrangler( + 'check startup --args="--build-output-directory=public" --pages' + ); + + expect(std.out).toContain(`Pages project detected`); + + expect(std.out).toContain( + `CPU Profile written to worker-startup.cpuprofile` + ); + + await expect( + readFile("worker-startup.cpuprofile", "utf8") + ).resolves.toContain("callFrame"); + }); +}); diff --git a/packages/wrangler/src/api/pages/deploy.ts b/packages/wrangler/src/api/pages/deploy.ts index ed9a88461bef..7e4fa13abaa9 100644 --- a/packages/wrangler/src/api/pages/deploy.ts +++ b/packages/wrangler/src/api/pages/deploy.ts @@ -447,7 +447,7 @@ export async function deploy({ body: formData, } ); - return deploymentResponse; + return { deploymentResponse, formData }; } catch (e) { lastErr = e; if ( diff --git a/packages/wrangler/src/check/commands.ts b/packages/wrangler/src/check/commands.ts new file mode 100644 index 000000000000..648849844e5e --- /dev/null +++ b/packages/wrangler/src/check/commands.ts @@ -0,0 +1,242 @@ +import { randomUUID } from "crypto"; +import { readFile } from "fs/promises"; +import events from "node:events"; +import { writeFile } from "node:fs/promises"; +import path from "path"; +import { log } from "@cloudflare/cli"; +import { spinnerWhile } from "@cloudflare/cli/interactive"; +import chalk from "chalk"; +import { Miniflare } from "miniflare"; +import { WebSocket } from "ws"; +import { createCLIParser } from ".."; +import { createCommand, createNamespace } from "../core/create-command"; +import { moduleTypeMimeType } from "../deployment-bundle/create-worker-upload-form"; +import { + flipObject, + ModuleTypeToRuleType, +} from "../deployment-bundle/module-collection"; +import { UserError } from "../errors"; +import { logger } from "../logger"; +import { getWranglerTmpDir } from "../paths"; +import type { Config } from "../config"; +import type { ModuleDefinition } from "miniflare"; +import type { FormData, FormDataEntryValue } from "undici"; + +const mimeTypeModuleType = flipObject(moduleTypeMimeType); + +export const checkNamespace = createNamespace({ + metadata: { + description: "☑︎ Run checks on your Worker", + owner: "Workers: Authoring and Testing", + status: "alpha", + hidden: true, + }, +}); + +async function checkStartupHandler( + { + outfile, + args, + workerBundle, + pages, + }: { outfile: string; args?: string; workerBundle?: string; pages?: boolean }, + { config }: { config: Config } +) { + if (workerBundle === undefined) { + const tmpDir = getWranglerTmpDir(undefined, "startup-profile"); + workerBundle = path.join(tmpDir.path, "worker.bundle"); + + if (config.pages_build_output_dir || pages) { + log("Pages project detected"); + log(""); + } + + if (logger.loggerLevel !== "debug") { + // Hide build logs + logger.loggerLevel = "error"; + } + + await spinnerWhile({ + promise: async () => + await createCLIParser( + config.pages_build_output_dir || pages + ? [ + "pages", + "functions", + "build", + ...(args?.split(" ") ?? []), + `--outfile=${workerBundle}`, + ] + : [ + "deploy", + ...(args?.split(" ") ?? []), + "--dry-run", + `--outfile=${workerBundle}`, + ] + ).parse(), + startMessage: "Building your Worker", + endMessage: chalk.green("Worker Built! 🎉"), + }); + logger.resetLoggerLevel(); + } + const cpuProfileResult = await spinnerWhile({ + promise: analyseBundle(workerBundle), + startMessage: "Analysing", + endMessage: chalk.green("Startup phase analysed"), + }); + + await writeFile(outfile, JSON.stringify(await cpuProfileResult)); + + log( + `CPU Profile written to ${outfile}. Load it into the Chrome DevTools profiler (or directly in VSCode) to view a flamegraph.` + ); +} + +export const checkStartupCommand = createCommand({ + args: { + outfile: { + describe: "Output file for startup phase cpuprofile", + type: "string", + default: "worker-startup.cpuprofile", + }, + workerBundle: { + alias: "worker", + describe: + "Path to a prebuilt worker bundle i.e the output of `wrangler deploy --outfile worker.bundle", + type: "string", + }, + pages: { + describe: "Force this project to be treated as a Pages project", + type: "boolean", + }, + args: { + describe: + "Additional arguments passed to `wrangler deploy` or `wrangler pages functions build` e.g. `--no-bundle`", + type: "string", + }, + }, + validateArgs({ args, workerBundle }) { + if (workerBundle && args) { + throw new UserError( + "`--args` and `--worker` are mutually exclusive—please only specify one" + ); + } + + if (args?.includes("outfile") || args?.includes("outdir")) { + throw new UserError( + "`--args` should not contain `--outfile` or `--outdir`" + ); + } + }, + metadata: { + description: "⌛ Profile your Worker's startup performance", + owner: "Workers: Authoring and Testing", + status: "alpha", + }, + handler: checkStartupHandler, +}); + +async function getEntryValue( + entry: FormDataEntryValue +): Promise | string> { + if (entry instanceof Blob) { + return new Uint8Array(await entry.arrayBuffer()); + } else { + return entry as string; + } +} + +function getModuleType(entry: FormDataEntryValue) { + if (entry instanceof Blob) { + return ModuleTypeToRuleType[mimeTypeModuleType[entry.type]]; + } else { + return "Text"; + } +} + +async function convertWorkerBundleToModules( + workerBundle: FormData +): Promise { + return await Promise.all( + [...workerBundle.entries()].map(async (m) => ({ + type: getModuleType(m[1]), + path: m[0], + contents: await getEntryValue(m[1]), + })) + ); +} + +async function parseFormDataFromFile(file: string): Promise { + const bundle = await readFile(file); + const firstLine = bundle.findIndex((v) => v === 10); + const boundary = Uint8Array.prototype.slice + .call(bundle, 2, firstLine) + .toString(); + return await new Response(bundle, { + headers: { + "Content-Type": "multipart/form-data; boundary=" + boundary, + }, + }).formData(); +} + +export async function analyseBundle( + workerBundle: string | FormData +): Promise> { + if (typeof workerBundle === "string") { + workerBundle = await parseFormDataFromFile(workerBundle); + } + + const metadata = JSON.parse(workerBundle.get("metadata") as string); + + if (!("main_module" in metadata)) { + throw new UserError( + "`wrangler check startup` does not support service-worker format Workers. Refer to https://developers.cloudflare.com/workers/reference/migrate-to-module-workers/ for migration guidance." + ); + } + const mf = new Miniflare({ + name: "profiler", + compatibilityDate: metadata.compatibility_date, + compatibilityFlags: metadata.compatibility_flags, + modulesRoot: "/", + modules: [ + { + type: "ESModule", + // Make sure the entrypoint path doesn't conflict with a user worker module + path: randomUUID(), + contents: /* javascript */ ` + async function startup() { + await import("${metadata.main_module}"); + } + export default { + async fetch() { + await startup() + return new Response("ok") + } + } + `, + }, + ...(await convertWorkerBundleToModules(workerBundle)), + ], + inspectorPort: 0, + }); + await mf.ready; + const inspectorUrl = await mf.getInspectorURL(); + const ws = new WebSocket(new URL("/core:user:profiler", inspectorUrl.href)); + await events.once(ws, "open"); + ws.send(JSON.stringify({ id: 1, method: "Profiler.enable", params: {} })); + ws.send(JSON.stringify({ id: 2, method: "Profiler.start", params: {} })); + + const cpuProfileResult = new Promise>((accept) => { + ws.addEventListener("message", (e) => { + const data = JSON.parse(e.data as string); + if (data.method === "Profiler.stop") { + void mf.dispose().then(() => accept(data.result.profile)); + } + }); + }); + + await (await mf.dispatchFetch("https://example.com")).text(); + ws.send(JSON.stringify({ id: 3, method: "Profiler.stop", params: {} })); + + return cpuProfileResult; +} diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index 754bfea9893f..dd05aa730a98 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -3,15 +3,13 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { URLSearchParams } from "node:url"; import { cancel } from "@cloudflare/cli"; +import { Response } from "undici"; import { syncAssets } from "../assets"; import { fetchListResult, fetchResult } from "../cfetch"; import { configFileName, formatConfigSnippet } from "../config"; import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; import { bundleWorker } from "../deployment-bundle/bundle"; -import { - printBundleSize, - printOffendingDependencies, -} from "../deployment-bundle/bundle-reporter"; +import { printBundleSize } from "../deployment-bundle/bundle-reporter"; import { getBundleType } from "../deployment-bundle/bundle-type"; import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; import { logBuildOutput } from "../deployment-bundle/esbuild-plugins/log-build-output"; @@ -48,6 +46,7 @@ import { maybeRetrieveFileSourceMap, } from "../sourcemap"; import triggersDeploy from "../triggers/deploy"; +import { helpIfErrorIsSizeOrScriptStartup } from "../utils/friendly-validator-errors"; import { printBindings } from "../utils/print-bindings"; import { retryOnAPIFailure } from "../utils/retry"; import { @@ -75,6 +74,7 @@ import type { PostQueueBody, PostTypedConsumerBody } from "../queues/client"; import type { LegacyAssetPaths } from "../sites"; import type { RetrieveSourceMapFunction } from "../sourcemap"; import type { ApiVersion, Percentage, VersionId } from "../versions/types"; +import type { FormData } from "undici"; type Props = { config: Config; @@ -100,6 +100,7 @@ type Props = { minify: boolean | undefined; nodeCompat: boolean | undefined; outDir: string | undefined; + outFile: string | undefined; dryRun: boolean | undefined; noBundle: boolean | undefined; keepVars: boolean | undefined; @@ -134,47 +135,6 @@ export type CustomDomainChangeset = { conflicting: ConflictingCustomDomain[]; }; -export function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -const scriptStartupErrorRegex = /startup/i; - -function errIsScriptSize(err: unknown): err is { code: 10027 } { - if (!err) { - return false; - } - - // 10027 = workers.api.error.script_too_large - if ((err as { code: number }).code === 10027) { - return true; - } - - return false; -} - -function errIsStartupErr(err: unknown): err is ParseError & { code: 10021 } { - if (!err) { - return false; - } - - // 10021 = validation error - // no explicit error code for more granular errors than "invalid script" - // but the error will contain a string error message directly from the - // validator. - // the error always SHOULD look like "Script startup exceeded CPU limit." - // (or the less likely "Script startup exceeded memory limits.") - if ( - (err as { code: number }).code === 10021 && - err instanceof ParseError && - scriptStartupErrorRegex.test(err.notes[0]?.text) - ) { - return true; - } - - return false; -} - export const validateRoutes = (routes: Route[], assets?: AssetsOptions) => { const invalidRoutes: Record = {}; const mountedAssetRoutes: string[] = []; @@ -795,7 +755,10 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m migrations === undefined && !config.first_party_worker; + let workerBundle: FormData; + if (props.dryRun) { + workerBundle = createWorkerUploadForm(worker); printBindings({ ...withoutStaticAssets, vars: maskedVars }); } else { assert(accountId, "Missing accountId"); @@ -809,6 +772,8 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m props.config ); } + workerBundle = createWorkerUploadForm(worker); + await ensureQueuesExistByConfig(config); let bindingsPrinted = false; @@ -831,7 +796,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m `/accounts/${accountId}/workers/scripts/${scriptName}/versions`, { method: "POST", - body: createWorkerUploadForm(worker), + body: workerBundle, headers: await getMetricsUsageHeaders(config.send_metrics), } ) @@ -873,7 +838,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m workerUrl, { method: "PUT", - body: createWorkerUploadForm(worker), + body: workerBundle, headers: await getMetricsUsageHeaders(config.send_metrics), }, new URLSearchParams({ @@ -918,7 +883,12 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m if (!bindingsPrinted) { printBindings({ ...withoutStaticAssets, vars: maskedVars }); } - helpIfErrorIsSizeOrScriptStartup(err, dependencies); + await helpIfErrorIsSizeOrScriptStartup( + err, + dependencies, + workerBundle, + props.projectRoot + ); // Apply source mapping to validation startup errors if possible if ( @@ -967,6 +937,15 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m throw err; } } + if (props.outFile) { + // we're using a custom output file, + // so let's first ensure it's parent directory exists + mkdirSync(path.dirname(props.outFile), { recursive: true }); + + const serializedFormData = await new Response(workerBundle).arrayBuffer(); + + writeFileSync(props.outFile, Buffer.from(serializedFormData)); + } } finally { if (typeof destination !== "string") { // this means we're using a temp dir, @@ -1012,27 +991,6 @@ function deployWfpUserWorker( logger.log("Current Version ID:", versionId); } -function helpIfErrorIsSizeOrScriptStartup( - err: unknown, - dependencies: { [path: string]: { bytesInOutput: number } } -) { - if (errIsScriptSize(err)) { - printOffendingDependencies(dependencies); - } else if (errIsStartupErr(err)) { - const youFailed = - "Your Worker failed validation because it exceeded startup limits."; - const heresWhy = - "To ensure fast responses, we place constraints on Worker startup -- like how much CPU it can use, or how long it can take."; - const heresTheProblem = - "Your Worker failed validation, which means it hit one of these startup limits."; - const heresTheSolution = - "Try reducing the amount of work done during startup (outside the event handler), either by removing code or relocating it inside the event handler."; - logger.warn( - [youFailed, heresWhy, heresTheProblem, heresTheSolution].join("\n") - ); - } -} - export function formatTime(duration: number) { return `(${(duration / 1000).toFixed(2)} sec)`; } diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index fda8144df6be..bd0604012462 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -64,6 +64,11 @@ export const deployCommand = createCommand({ type: "string", requiresArg: true, }, + outfile: { + describe: "Output file for the bundled worker", + type: "string", + requiresArg: true, + }, "compatibility-date": { describe: "Date to use for compatibility checks", type: "string", @@ -371,6 +376,7 @@ export const deployCommand = createCommand({ nodeCompat: args.nodeCompat, isWorkersSite: Boolean(args.site || config.site), outDir: args.outdir, + outFile: args.outfile, dryRun: args.dryRun, noBundle: !(args.bundle ?? !config.no_bundle), keepVars: args.keepVars, diff --git a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts index 2f732851f3e4..fb41aa4030c9 100644 --- a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts +++ b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts @@ -17,7 +17,9 @@ import type { import type { AssetConfig } from "@cloudflare/workers-shared"; import type { Json } from "miniflare"; -const moduleTypeMimeType: { [type in CfModuleType]: string | undefined } = { +export const moduleTypeMimeType: { + [type in CfModuleType]: string | undefined; +} = { esm: "application/javascript+module", commonjs: "application/javascript", "compiled-wasm": "application/wasm", diff --git a/packages/wrangler/src/deployment-bundle/module-collection.ts b/packages/wrangler/src/deployment-bundle/module-collection.ts index b35e9f576384..1b319db275eb 100644 --- a/packages/wrangler/src/deployment-bundle/module-collection.ts +++ b/packages/wrangler/src/deployment-bundle/module-collection.ts @@ -16,11 +16,15 @@ import type { Entry } from "./entry"; import type { CfModule, CfModuleType } from "./worker"; import type esbuild from "esbuild"; -function flipObject< +export function flipObject< K extends string | number | symbol, - V extends string | number | symbol, ->(obj: Record): Record { - return Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k])); + V extends string | number | symbol | undefined, +>(obj: Record): Record, K> { + return Object.fromEntries( + Object.entries(obj) + .filter(([_, v]) => !!v) + .map(([k, v]) => [v, k]) + ); } export const RuleTypeToModuleType: Record = diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 8b5b92748e95..b16e6ba24b94 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -13,6 +13,7 @@ import { certUploadMtlsCommand, certUploadNamespace, } from "./cert/cert"; +import { checkNamespace, checkStartupCommand } from "./check/commands"; import { cloudchamber } from "./cloudchamber"; import { experimental_readRawConfig, loadDotEnv } from "./config"; import { demandSingleValue } from "./core"; @@ -893,6 +894,18 @@ export function createCLIParser(argv: string[]) { ]); registry.registerNamespace("telemetry"); + registry.define([ + { + command: "wrangler check", + definition: checkNamespace, + }, + { + command: "wrangler check startup", + definition: checkStartupCommand, + }, + ]); + registry.registerNamespace("check"); + /******************************************************/ /* DEPRECATED COMMANDS */ /******************************************************/ diff --git a/packages/wrangler/src/pages/deploy.ts b/packages/wrangler/src/pages/deploy.ts index 985ef008d0b2..df99300ca3c6 100644 --- a/packages/wrangler/src/pages/deploy.ts +++ b/packages/wrangler/src/pages/deploy.ts @@ -1,4 +1,6 @@ import { execSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; import { deploy } from "../api/pages/deploy"; import { fetchResult } from "../cfetch"; import { configFileName, readPagesConfig } from "../config"; @@ -10,6 +12,7 @@ import { logger } from "../logger"; import * as metrics from "../metrics"; import { writeOutput } from "../output"; import { requireAuth } from "../user"; +import { handleStartupError } from "../utils/friendly-validator-errors"; import { MAX_DEPLOYMENT_STATUS_ATTEMPTS, PAGES_CONFIG_CACHE_FILENAME, @@ -17,6 +20,7 @@ import { import { EXIT_CODE_INVALID_PAGES_CONFIG } from "./errors"; import { listProjects } from "./projects"; import { promptSelectProject } from "./prompt-select-project"; +import { getPagesProjectRoot, getPagesTmpDir } from "./utils"; import type { Config } from "../config"; import type { CommonYargsArgv, @@ -29,6 +33,7 @@ import type { Project, UnifiedDeploymentLogMessages, } from "@cloudflare/types"; +import type { File } from "undici"; type PagesDeployArgs = StrictYargsOptionsToInterface; @@ -340,7 +345,7 @@ export const Handler = async (args: PagesDeployArgs) => { } } - const deploymentResponse = await deploy({ + const { deploymentResponse, formData } = await deploy({ directory, accountId, projectName, @@ -425,6 +430,13 @@ export const Handler = async (args: PagesDeployArgs) => { .replace("Error:", "") .trim(); + if (failureMessage.includes("Script startup exceeded CPU time limit")) { + const workerBundle = formData.get("_worker.bundle") as File; + const filePath = path.join(getPagesTmpDir(), "_worker.bundle"); + await writeFile(filePath, workerBundle.stream()); + await handleStartupError(filePath, getPagesProjectRoot()); + } + throw new FatalError( `Deployment failed! ${failureMessage}`, diff --git a/packages/wrangler/src/paths.ts b/packages/wrangler/src/paths.ts index aeb0ccd27c27..5ed4b3e31ae7 100644 --- a/packages/wrangler/src/paths.ts +++ b/packages/wrangler/src/paths.ts @@ -90,7 +90,8 @@ export interface EphemeralDirectory { */ export function getWranglerTmpDir( projectRoot: string | undefined, - prefix: string + prefix: string, + cleanup = true ): EphemeralDirectory { projectRoot ??= process.cwd(); const tmpRoot = path.join(projectRoot, ".wrangler", "tmp"); @@ -100,10 +101,12 @@ export function getWranglerTmpDir( const tmpDir = fs.realpathSync(fs.mkdtempSync(tmpPrefix)); const removeDir = () => { - try { - return fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch (e) { - // This sometimes fails on Windows with EBUSY + if (cleanup) { + try { + return fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch (e) { + // This sometimes fails on Windows with EBUSY + } } }; const removeExitListener = onExit(removeDir); diff --git a/packages/wrangler/src/utils/friendly-validator-errors.ts b/packages/wrangler/src/utils/friendly-validator-errors.ts new file mode 100644 index 000000000000..bc3905cc08bd --- /dev/null +++ b/packages/wrangler/src/utils/friendly-validator-errors.ts @@ -0,0 +1,78 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import dedent from "ts-dedent"; +import { analyseBundle } from "../check/commands"; +import { printOffendingDependencies } from "../deployment-bundle/bundle-reporter"; +import { UserError } from "../errors"; +import { ParseError } from "../parse"; +import { getWranglerTmpDir } from "../paths"; +import type { FormData } from "undici"; + +function errIsScriptSize(err: unknown): err is { code: 10027 } { + if (!err) { + return false; + } + + // 10027 = workers.api.error.script_too_large + if ((err as { code: number }).code === 10027) { + return true; + } + + return false; +} +const scriptStartupErrorRegex = /startup/i; + +function errIsStartupErr(err: unknown): err is ParseError & { code: 10021 } { + if (!err) { + return false; + } + + // 10021 = validation error + // no explicit error code for more granular errors than "invalid script" + // but the error will contain a string error message directly from the + // validator. + // the error always SHOULD look like "Script startup exceeded CPU limit." + // (or the less likely "Script startup exceeded memory limits.") + if ( + (err as { code: number }).code === 10021 && + err instanceof ParseError && + scriptStartupErrorRegex.test(err.notes[0]?.text) + ) { + return true; + } + + return false; +} + +export async function handleStartupError( + workerBundle: FormData | string, + projectRoot: string | undefined +) { + const cpuProfile = await analyseBundle(workerBundle); + const tmpDir = await getWranglerTmpDir(projectRoot, "startup-profile", false); + const profile = path.relative( + projectRoot ?? process.cwd(), + path.join(tmpDir.path, `worker.cpuprofile`) + ); + await writeFile(profile, JSON.stringify(cpuProfile)); + throw new UserError(dedent` + Your Worker failed validation because it exceeded startup limits. + To ensure fast responses, there are constraints on Worker startup, such as how much CPU it can use, or how long it can take. Your Worker has hit one of these startup limits. Try reducing the amount of work done during startup (outside the event handler), either by removing code or relocating it inside the event handler. + + A CPU Profile of your Worker's startup phase has been written to ${profile} - load it into the Chrome DevTools profiler (or directly in VSCode) to view a flamegraph. + + Refer to https://developers.cloudflare.com/workers/platform/limits/#worker-startup-time for more details`); +} + +export async function helpIfErrorIsSizeOrScriptStartup( + err: unknown, + dependencies: { [path: string]: { bytesInOutput: number } }, + workerBundle: FormData, + projectRoot: string | undefined +) { + if (errIsScriptSize(err)) { + printOffendingDependencies(dependencies); + } else if (errIsStartupErr(err)) { + await handleStartupError(workerBundle, projectRoot); + } +} diff --git a/packages/wrangler/src/versions/upload.ts b/packages/wrangler/src/versions/upload.ts index 03afcb2be71c..24326d01add9 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -12,10 +12,7 @@ import { configFileName, formatConfigSnippet } from "../config"; import { createCommand } from "../core/create-command"; import { getBindings, provisionBindings } from "../deployment-bundle/bindings"; import { bundleWorker } from "../deployment-bundle/bundle"; -import { - printBundleSize, - printOffendingDependencies, -} from "../deployment-bundle/bundle-reporter"; +import { printBundleSize } from "../deployment-bundle/bundle-reporter"; import { getBundleType } from "../deployment-bundle/bundle-type"; import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; import { getEntry } from "../deployment-bundle/entry"; @@ -51,6 +48,7 @@ import { } from "../sourcemap"; import { requireAuth } from "../user"; import { collectKeyValues } from "../utils/collectKeyValues"; +import { helpIfErrorIsSizeOrScriptStartup } from "../utils/friendly-validator-errors"; import { getRules } from "../utils/getRules"; import { getScriptName } from "../utils/getScriptName"; import { isLegacyEnv } from "../utils/isLegacyEnv"; @@ -62,6 +60,7 @@ import type { Rule } from "../config/environment"; import type { Entry } from "../deployment-bundle/entry"; import type { CfPlacement, CfWorkerInit } from "../deployment-bundle/worker"; import type { RetrieveSourceMapFunction } from "../sourcemap"; +import type { FormData } from "undici"; type Props = { config: Config; @@ -85,6 +84,7 @@ type Props = { uploadSourceMaps: boolean | undefined; nodeCompat: boolean | undefined; outDir: string | undefined; + outFile: string | undefined; dryRun: boolean | undefined; noBundle: boolean | undefined; keepVars: boolean | undefined; @@ -95,43 +95,6 @@ type Props = { message: string | undefined; }; -const scriptStartupErrorRegex = /startup/i; - -function errIsScriptSize(err: unknown): err is { code: 10027 } { - if (!err) { - return false; - } - - // 10027 = workers.api.error.script_too_large - if ((err as { code: number }).code === 10027) { - return true; - } - - return false; -} - -function errIsStartupErr(err: unknown): err is ParseError & { code: 10021 } { - if (!err) { - return false; - } - - // 10021 = validation error - // no explicit error code for more granular errors than "invalid script" - // but the error will contain a string error message directly from the - // validator. - // the error always SHOULD look like "Script startup exceeded CPU limit." - // (or the less likely "Script startup exceeded memory limits.") - if ( - (err as { code: number }).code === 10021 && - err instanceof ParseError && - scriptStartupErrorRegex.test(err.notes[0]?.text) - ) { - return true; - } - - return false; -} - export const versionsUploadCommand = createCommand({ metadata: { description: "Uploads your Worker code and config as a new Version", @@ -164,6 +127,11 @@ export const versionsUploadCommand = createCommand({ type: "string", requiresArg: true, }, + outfile: { + describe: "Output file for the bundled worker", + type: "string", + requiresArg: true, + }, "compatibility-date": { describe: "Date to use for compatibility checks", type: "string", @@ -413,6 +381,7 @@ export const versionsUploadCommand = createCommand({ tag: args.tag, message: args.message, experimentalAutoCreate: args.experimentalAutoCreate, + outFile: args.outfile, }); writeOutput({ @@ -762,7 +731,10 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m } } + let workerBundle: FormData; + if (props.dryRun) { + workerBundle = createWorkerUploadForm(worker); printBindings({ ...bindings, vars: maskedVars }); } else { assert(accountId, "Missing accountId"); @@ -775,14 +747,13 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m props.config ); } + workerBundle = createWorkerUploadForm(worker); await ensureQueuesExistByConfig(config); let bindingsPrinted = false; // Upload the version. try { - const body = createWorkerUploadForm(worker); - const result = await retryOnAPIFailure(async () => fetchResult<{ id: string; @@ -792,7 +763,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m }; }>(`${workerUrl}/versions`, { method: "POST", - body, + body: workerBundle, headers: await getMetricsUsageHeaders(config.send_metrics), }) ); @@ -807,7 +778,12 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m printBindings({ ...bindings, vars: maskedVars }); } - helpIfErrorIsSizeOrScriptStartup(err, dependencies); + await helpIfErrorIsSizeOrScriptStartup( + err, + dependencies, + workerBundle, + props.projectRoot + ); // Apply source mapping to validation startup errors if possible if ( @@ -845,6 +821,15 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m throw err; } } + if (props.outFile) { + // we're using a custom output file, + // so let's first ensure it's parent directory exists + mkdirSync(path.dirname(props.outFile), { recursive: true }); + + const serializedFormData = await new Response(workerBundle).arrayBuffer(); + + writeFileSync(props.outFile, Buffer.from(serializedFormData)); + } } finally { if (typeof destination !== "string") { // this means we're using a temp dir, @@ -900,27 +885,6 @@ Changes to triggers (routes, custom domains, cron schedules, etc) must be applie return { versionId, workerTag, versionPreviewUrl }; } -function helpIfErrorIsSizeOrScriptStartup( - err: unknown, - dependencies: { [path: string]: { bytesInOutput: number } } -) { - if (errIsScriptSize(err)) { - printOffendingDependencies(dependencies); - } else if (errIsStartupErr(err)) { - const youFailed = - "Your Worker failed validation because it exceeded startup limits."; - const heresWhy = - "To ensure fast responses, we place constraints on Worker startup -- like how much CPU it can use, or how long it can take."; - const heresTheProblem = - "Your Worker failed validation, which means it hit one of these startup limits."; - const heresTheSolution = - "Try reducing the amount of work done during startup (outside the event handler), either by removing code or relocating it inside the event handler."; - logger.warn( - [youFailed, heresWhy, heresTheProblem, heresTheSolution].join("\n") - ); - } -} - function formatTime(duration: number) { return `(${(duration / 1000).toFixed(2)} sec)`; }