From efb7c52bc2d908a4fd4dbcd4d19c90062bbe7f4b Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 4 Feb 2025 16:43:24 +0000 Subject: [PATCH 1/8] Startup profiling --- .changeset/rich-pots-mate.md | 7 + .changeset/startup-profiling.md | 12 + packages/miniflare/src/index.ts | 24 +- .../wrangler/src/__tests__/deploy.test.ts | 460 ++++++++++++++++-- .../src/__tests__/startup-profiling.test.ts | 67 +++ packages/wrangler/src/check/commands.ts | 211 ++++++++ packages/wrangler/src/deploy/deploy.ts | 95 +--- packages/wrangler/src/deploy/index.ts | 10 + .../create-worker-upload-form.ts | 4 +- .../deployment-bundle/module-collection.ts | 12 +- packages/wrangler/src/index.ts | 13 + packages/wrangler/src/logger.ts | 4 + packages/wrangler/src/paths.ts | 13 +- .../src/utils/friendly-validator-errors.ts | 75 +++ packages/wrangler/src/versions/upload.ts | 96 ++-- 15 files changed, 917 insertions(+), 186 deletions(-) create mode 100644 .changeset/rich-pots-mate.md create mode 100644 .changeset/startup-profiling.md create mode 100644 packages/wrangler/src/__tests__/startup-profiling.test.ts create mode 100644 packages/wrangler/src/check/commands.ts create mode 100644 packages/wrangler/src/utils/friendly-validator-errors.ts 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/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 398456c1055c..81ef319769c9 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -767,17 +767,17 @@ export class Miniflare { }); // Add custom headers included in response to WebSocket upgrade requests this.#webSocketExtraHeaders = new WeakMap(); - this.#webSocketServer.on("headers", (headers, req) => { - const extra = this.#webSocketExtraHeaders.get(req); - this.#webSocketExtraHeaders.delete(req); - if (extra) { - for (const [key, value] of extra) { - if (!restrictedWebSocketUpgradeHeaders.includes(key.toLowerCase())) { - headers.push(`${key}: ${value}`); - } - } - } - }); + // this.#webSocketServer.on("headers", (headers, req) => { + // const extra = this.#webSocketExtraHeaders.get(req); + // this.#webSocketExtraHeaders.delete(req); + // if (extra) { + // for (const [key, value] of extra) { + // if (!restrictedWebSocketUpgradeHeaders.includes(key.toLowerCase())) { + // headers.push(`${key}: ${value}`); + // } + // } + // } + // }); // Build path for temporary directory. We don't actually want to create this // unless it's needed (i.e. we have Durable Objects enabled). This means we @@ -1046,7 +1046,7 @@ export class Miniflare { http.createServer(this.#handleLoopback), /* grace */ 0 ); - server.on("upgrade", this.#handleLoopbackUpgrade); + // server.on("upgrade", this.#handleLoopbackUpgrade); server.listen(0, hostname, () => resolve(server)); }); } diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index 3f2cd6271507..51f3d718e2ab 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -10,6 +10,7 @@ import { http, HttpResponse } from "msw"; import dedent from "ts-dedent"; import { File } from "undici"; import { vi } from "vitest"; +import * as checkCommand from "../check/commands"; import { printBundleSize, printOffendingDependencies, @@ -65,6 +66,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 +10509,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 +11499,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..d8b7b032f335 --- /dev/null +++ b/packages/wrangler/src/__tests__/startup-profiling.test.ts @@ -0,0 +1,67 @@ +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("--deploy-args passed through to deploy", async () => { + writeWranglerConfig({ main: "index.js" }); + writeWorkerSource(); + + await expect( + runWrangler("check startup --deploy-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"); + }); +}); diff --git a/packages/wrangler/src/check/commands.ts b/packages/wrangler/src/check/commands.ts new file mode 100644 index 000000000000..f00e0a6376b4 --- /dev/null +++ b/packages/wrangler/src/check/commands.ts @@ -0,0 +1,211 @@ +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 { spinner, 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 { 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, + }, +}); + +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", + }, + deployArgs: { + alias: "args", + describe: + "Additional arguments passed to `wrangler deploy` i.e. `--no-bundle`", + type: "string", + }, + }, + validateArgs({ deployArgs, workerBundle }) { + if (workerBundle && deployArgs) { + throw new UserError( + "`--deploy-args` and `--worker` are mutually exclusive—please only specify one" + ); + } + + if (deployArgs?.includes("outfile") || deployArgs?.includes("outdir")) { + throw new UserError( + "`--deploy-args` should not contain `--outfile` or `--outdir`" + ); + } + }, + metadata: { + description: "⌛ Profile your Worker's startup performance", + owner: "Workers: Authoring and Testing", + status: "alpha", + }, + async handler({ outfile, deployArgs, workerBundle }) { + if (workerBundle === undefined) { + const tmpDir = getWranglerTmpDir(undefined, "startup-profile"); + workerBundle = path.join(tmpDir.path, "worker.bundle"); + + // Hide build logs, + logger.loggerLevel = "error"; + + await spinnerWhile({ + promise: async () => + await createCLIParser([ + "deploy", + ...(deployArgs?.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.` + ); + }, +}); + +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..31ff70c495c0 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 { FormData, 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 { @@ -100,6 +99,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 +134,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 +754,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 +771,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 +795,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 +837,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 +882,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 +936,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 +990,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 6975a4afc7b5..b9aa7b8cd329 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -64,6 +64,11 @@ export function deployOptions(yargs: CommonYargsArgv) { type: "string", requiresArg: true, }) + .option("outfile", { + describe: "Output file for the bundled worker", + type: "string", + requiresArg: true, + }) .option("compatibility-date", { describe: "Date to use for compatibility checks", type: "string", @@ -306,6 +311,10 @@ async function deployWorker(args: DeployArgs) { ); } + if (args.outfile && args.outdir) { + throw new UserError("Cannot use `--outfile` and `--outdir` together"); + } + if (config.workflows?.length) { logger.once.warn("Workflows is currently in open beta."); } @@ -390,6 +399,7 @@ async function deployWorker(args: DeployArgs) { 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 cad27fed2c20..5393d66a77f7 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"; @@ -889,6 +890,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/logger.ts b/packages/wrangler/src/logger.ts index 4dbfc8dc582a..e33206f012bc 100644 --- a/packages/wrangler/src/logger.ts +++ b/packages/wrangler/src/logger.ts @@ -65,6 +65,10 @@ export class Logger { this.overrideLoggerLevel = val; } + resetLoggerLevel() { + this.overrideLoggerLevel = undefined; + } + columns = process.stdout.columns; debug = (...args: unknown[]) => this.doLog("debug", args); 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..c13cc9dffdf6 --- /dev/null +++ b/packages/wrangler/src/utils/friendly-validator-errors.ts @@ -0,0 +1,75 @@ +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 helpIfErrorIsSizeOrScriptStartup( + err: unknown, + dependencies: { [path: string]: { bytesInOutput: number } }, + workerBundle: FormData, + projectRoot: string | undefined +) { + if (errIsScriptSize(err)) { + printOffendingDependencies(dependencies); + } else if (errIsStartupErr(err)) { + 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, 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. + + 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`); + } +} diff --git a/packages/wrangler/src/versions/upload.ts b/packages/wrangler/src/versions/upload.ts index 03afcb2be71c..0cba35a5df24 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { blue, gray } from "@cloudflare/cli/colors"; +import { FormData } from "undici"; import { getAssetsOptions, syncAssets, @@ -12,10 +13,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 +49,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"; @@ -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)`; } From 6d62eebb40f6c46f8b94d281459285ef613b4ad5 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Fri, 7 Feb 2025 16:09:28 +0000 Subject: [PATCH 2/8] fixes --- packages/miniflare/src/index.ts | 24 +++++++++---------- .../wrangler/src/__tests__/deploy.test.ts | 1 - packages/wrangler/src/check/commands.ts | 2 +- packages/wrangler/src/deploy/deploy.ts | 3 ++- packages/wrangler/src/deploy/index.ts | 4 ---- .../src/utils/friendly-validator-errors.ts | 4 ++-- packages/wrangler/src/versions/upload.ts | 2 +- 7 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 81ef319769c9..398456c1055c 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -767,17 +767,17 @@ export class Miniflare { }); // Add custom headers included in response to WebSocket upgrade requests this.#webSocketExtraHeaders = new WeakMap(); - // this.#webSocketServer.on("headers", (headers, req) => { - // const extra = this.#webSocketExtraHeaders.get(req); - // this.#webSocketExtraHeaders.delete(req); - // if (extra) { - // for (const [key, value] of extra) { - // if (!restrictedWebSocketUpgradeHeaders.includes(key.toLowerCase())) { - // headers.push(`${key}: ${value}`); - // } - // } - // } - // }); + this.#webSocketServer.on("headers", (headers, req) => { + const extra = this.#webSocketExtraHeaders.get(req); + this.#webSocketExtraHeaders.delete(req); + if (extra) { + for (const [key, value] of extra) { + if (!restrictedWebSocketUpgradeHeaders.includes(key.toLowerCase())) { + headers.push(`${key}: ${value}`); + } + } + } + }); // Build path for temporary directory. We don't actually want to create this // unless it's needed (i.e. we have Durable Objects enabled). This means we @@ -1046,7 +1046,7 @@ export class Miniflare { http.createServer(this.#handleLoopback), /* grace */ 0 ); - // server.on("upgrade", this.#handleLoopbackUpgrade); + server.on("upgrade", this.#handleLoopbackUpgrade); server.listen(0, hostname, () => resolve(server)); }); } diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index 51f3d718e2ab..df7037ebac99 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -10,7 +10,6 @@ import { http, HttpResponse } from "msw"; import dedent from "ts-dedent"; import { File } from "undici"; import { vi } from "vitest"; -import * as checkCommand from "../check/commands"; import { printBundleSize, printOffendingDependencies, diff --git a/packages/wrangler/src/check/commands.ts b/packages/wrangler/src/check/commands.ts index f00e0a6376b4..5fa75c1f4da4 100644 --- a/packages/wrangler/src/check/commands.ts +++ b/packages/wrangler/src/check/commands.ts @@ -4,7 +4,7 @@ import events from "node:events"; import { writeFile } from "node:fs/promises"; import path from "path"; import { log } from "@cloudflare/cli"; -import { spinner, spinnerWhile } from "@cloudflare/cli/interactive"; +import { spinnerWhile } from "@cloudflare/cli/interactive"; import chalk from "chalk"; import { Miniflare } from "miniflare"; import { WebSocket } from "ws"; diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index 31ff70c495c0..dd05aa730a98 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -3,7 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { URLSearchParams } from "node:url"; import { cancel } from "@cloudflare/cli"; -import { FormData, Response } from "undici"; +import { Response } from "undici"; import { syncAssets } from "../assets"; import { fetchListResult, fetchResult } from "../cfetch"; import { configFileName, formatConfigSnippet } from "../config"; @@ -74,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; diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index b9aa7b8cd329..3661a844d9b2 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -311,10 +311,6 @@ async function deployWorker(args: DeployArgs) { ); } - if (args.outfile && args.outdir) { - throw new UserError("Cannot use `--outfile` and `--outdir` together"); - } - if (config.workflows?.length) { logger.once.warn("Workflows is currently in open beta."); } diff --git a/packages/wrangler/src/utils/friendly-validator-errors.ts b/packages/wrangler/src/utils/friendly-validator-errors.ts index c13cc9dffdf6..25f62e161efe 100644 --- a/packages/wrangler/src/utils/friendly-validator-errors.ts +++ b/packages/wrangler/src/utils/friendly-validator-errors.ts @@ -66,9 +66,9 @@ export async function helpIfErrorIsSizeOrScriptStartup( await writeFile(profile, JSON.stringify(cpuProfile)); throw new UserError(dedent` 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. + 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. + 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`); } diff --git a/packages/wrangler/src/versions/upload.ts b/packages/wrangler/src/versions/upload.ts index 0cba35a5df24..24326d01add9 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -2,7 +2,6 @@ import assert from "node:assert"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { blue, gray } from "@cloudflare/cli/colors"; -import { FormData } from "undici"; import { getAssetsOptions, syncAssets, @@ -61,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; From 7754ef1ca3c69f9771a335009f45f97c03c2026e Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Mon, 10 Feb 2025 19:12:03 +0000 Subject: [PATCH 3/8] Support Pages --- packages/wrangler/src/check/commands.ts | 37 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/wrangler/src/check/commands.ts b/packages/wrangler/src/check/commands.ts index 5fa75c1f4da4..f4a92721c85c 100644 --- a/packages/wrangler/src/check/commands.ts +++ b/packages/wrangler/src/check/commands.ts @@ -48,7 +48,7 @@ export const checkStartupCommand = createCommand({ deployArgs: { alias: "args", describe: - "Additional arguments passed to `wrangler deploy` i.e. `--no-bundle`", + "Additional arguments passed to `wrangler deploy` or `wrangler pages functions build` i.e. `--no-bundle`", type: "string", }, }, @@ -70,22 +70,39 @@ export const checkStartupCommand = createCommand({ owner: "Workers: Authoring and Testing", status: "alpha", }, - async handler({ outfile, deployArgs, workerBundle }) { + async handler({ outfile, deployArgs, workerBundle }, { config }) { if (workerBundle === undefined) { const tmpDir = getWranglerTmpDir(undefined, "startup-profile"); workerBundle = path.join(tmpDir.path, "worker.bundle"); - // Hide build logs, - logger.loggerLevel = "error"; + if (config.pages_build_output_dir) { + log("Pages project detected"); + log(""); + } + + if (logger.loggerLevel !== "debug") { + // Hide build logs + logger.loggerLevel = "error"; + } await spinnerWhile({ promise: async () => - await createCLIParser([ - "deploy", - ...(deployArgs?.split(" ") ?? []), - "--dry-run", - `--outfile=${workerBundle}`, - ]).parse(), + await createCLIParser( + config.pages_build_output_dir + ? [ + "pages", + "functions", + "build", + ...(deployArgs?.split(" ") ?? []), + `--outfile=${workerBundle}`, + ] + : [ + "deploy", + ...(deployArgs?.split(" ") ?? []), + "--dry-run", + `--outfile=${workerBundle}`, + ] + ).parse(), startMessage: "Building your Worker", endMessage: chalk.green("Worker Built! 🎉"), }); From 393884abd036db740ea0b6fb36ebbaa3cff782f0 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 11 Feb 2025 10:54:11 +0000 Subject: [PATCH 4/8] pages --- .../src/__tests__/startup-profiling.test.ts | 68 +++++++++- packages/wrangler/src/check/commands.ts | 128 ++++++++++-------- 2 files changed, 137 insertions(+), 59 deletions(-) diff --git a/packages/wrangler/src/__tests__/startup-profiling.test.ts b/packages/wrangler/src/__tests__/startup-profiling.test.ts index d8b7b032f335..630211ba7e89 100644 --- a/packages/wrangler/src/__tests__/startup-profiling.test.ts +++ b/packages/wrangler/src/__tests__/startup-profiling.test.ts @@ -1,3 +1,4 @@ +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"; @@ -37,12 +38,12 @@ describe("wrangler check startup", () => { expect(std.out).toContain(`CPU Profile written to worker.cpuprofile`); }); - test("--deploy-args passed through to deploy", async () => { + test("--args passed through to deploy", async () => { writeWranglerConfig({ main: "index.js" }); writeWorkerSource(); await expect( - runWrangler("check startup --deploy-args 'abc'") + runWrangler("check startup --args 'abc'") ).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: The entry-point file at "abc" was not found.]` ); @@ -64,4 +65,67 @@ describe("wrangler check startup", () => { 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/check/commands.ts b/packages/wrangler/src/check/commands.ts index f4a92721c85c..fc30b8f5160c 100644 --- a/packages/wrangler/src/check/commands.ts +++ b/packages/wrangler/src/check/commands.ts @@ -9,6 +9,7 @@ import chalk from "chalk"; import { Miniflare } from "miniflare"; import { WebSocket } from "ws"; import { createCLIParser } from ".."; +import { Config } from "../config"; import { createCommand, createNamespace } from "../core/create-command"; import { moduleTypeMimeType } from "../deployment-bundle/create-worker-upload-form"; import { @@ -32,6 +33,65 @@ export const checkNamespace = createNamespace({ }, }); +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: { @@ -45,23 +105,26 @@ export const checkStartupCommand = createCommand({ "Path to a prebuilt worker bundle i.e the output of `wrangler deploy --outfile worker.bundle", type: "string", }, - deployArgs: { - alias: "args", + 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` i.e. `--no-bundle`", type: "string", }, }, - validateArgs({ deployArgs, workerBundle }) { - if (workerBundle && deployArgs) { + validateArgs({ args, workerBundle }) { + if (workerBundle && args) { throw new UserError( - "`--deploy-args` and `--worker` are mutually exclusive—please only specify one" + "`--args` and `--worker` are mutually exclusive—please only specify one" ); } - if (deployArgs?.includes("outfile") || deployArgs?.includes("outdir")) { + if (args?.includes("outfile") || args?.includes("outdir")) { throw new UserError( - "`--deploy-args` should not contain `--outfile` or `--outdir`" + "`--args` should not contain `--outfile` or `--outdir`" ); } }, @@ -70,56 +133,7 @@ export const checkStartupCommand = createCommand({ owner: "Workers: Authoring and Testing", status: "alpha", }, - async handler({ outfile, deployArgs, workerBundle }, { config }) { - if (workerBundle === undefined) { - const tmpDir = getWranglerTmpDir(undefined, "startup-profile"); - workerBundle = path.join(tmpDir.path, "worker.bundle"); - - if (config.pages_build_output_dir) { - 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", - "functions", - "build", - ...(deployArgs?.split(" ") ?? []), - `--outfile=${workerBundle}`, - ] - : [ - "deploy", - ...(deployArgs?.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.` - ); - }, + handler: checkStartupHandler, }); async function getEntryValue( From 5807d526aa7d93efa86e4adb8fafb7185cd0811e Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 11 Feb 2025 11:11:39 +0000 Subject: [PATCH 5/8] fix lint --- packages/wrangler/src/check/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/check/commands.ts b/packages/wrangler/src/check/commands.ts index fc30b8f5160c..38985ae83a52 100644 --- a/packages/wrangler/src/check/commands.ts +++ b/packages/wrangler/src/check/commands.ts @@ -9,7 +9,6 @@ import chalk from "chalk"; import { Miniflare } from "miniflare"; import { WebSocket } from "ws"; import { createCLIParser } from ".."; -import { Config } from "../config"; import { createCommand, createNamespace } from "../core/create-command"; import { moduleTypeMimeType } from "../deployment-bundle/create-worker-upload-form"; import { @@ -19,6 +18,7 @@ import { 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"; From 989f763748aa0a4aa272733fff7bb7ee10f6942a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Tue, 11 Feb 2025 14:32:05 +0000 Subject: [PATCH 6/8] Update packages/wrangler/src/check/commands.ts Co-authored-by: emily-shen <69125074+emily-shen@users.noreply.github.com> --- packages/wrangler/src/check/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/check/commands.ts b/packages/wrangler/src/check/commands.ts index 38985ae83a52..648849844e5e 100644 --- a/packages/wrangler/src/check/commands.ts +++ b/packages/wrangler/src/check/commands.ts @@ -111,7 +111,7 @@ export const checkStartupCommand = createCommand({ }, args: { describe: - "Additional arguments passed to `wrangler deploy` or `wrangler pages functions build` i.e. `--no-bundle`", + "Additional arguments passed to `wrangler deploy` or `wrangler pages functions build` e.g. `--no-bundle`", type: "string", }, }, From afd7e154641c567c8a2b07d00458f58420d4cbb9 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 13 Feb 2025 13:36:44 +0000 Subject: [PATCH 7/8] Automatically show message on Pages deploy failures --- packages/wrangler/src/api/pages/deploy.ts | 2 +- packages/wrangler/src/pages/deploy.ts | 14 ++++++- .../src/utils/friendly-validator-errors.ts | 39 ++++++++++--------- 3 files changed, 35 insertions(+), 20 deletions(-) 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/pages/deploy.ts b/packages/wrangler/src/pages/deploy.ts index 985ef008d0b2..5c84999eb410 100644 --- a/packages/wrangler/src/pages/deploy.ts +++ b/packages/wrangler/src/pages/deploy.ts @@ -1,4 +1,7 @@ import { execSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { File } from "undici"; import { deploy } from "../api/pages/deploy"; import { fetchResult } from "../cfetch"; import { configFileName, readPagesConfig } from "../config"; @@ -10,6 +13,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 +21,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, @@ -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/utils/friendly-validator-errors.ts b/packages/wrangler/src/utils/friendly-validator-errors.ts index 25f62e161efe..e347ed953cca 100644 --- a/packages/wrangler/src/utils/friendly-validator-errors.ts +++ b/packages/wrangler/src/utils/friendly-validator-errors.ts @@ -44,6 +44,26 @@ function errIsStartupErr(err: unknown): err is ParseError & { code: 10021 } { 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 } }, @@ -53,23 +73,6 @@ export async function helpIfErrorIsSizeOrScriptStartup( if (errIsScriptSize(err)) { printOffendingDependencies(dependencies); } else if (errIsStartupErr(err)) { - 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`); + handleStartupError(workerBundle, projectRoot); } } From 9a616f90c7754c7f910c289a34c11fcd3b3a9fd2 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 13 Feb 2025 14:24:56 +0000 Subject: [PATCH 8/8] fix lint --- packages/wrangler/src/pages/deploy.ts | 2 +- packages/wrangler/src/utils/friendly-validator-errors.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wrangler/src/pages/deploy.ts b/packages/wrangler/src/pages/deploy.ts index 5c84999eb410..df99300ca3c6 100644 --- a/packages/wrangler/src/pages/deploy.ts +++ b/packages/wrangler/src/pages/deploy.ts @@ -1,7 +1,6 @@ import { execSync } from "node:child_process"; import { writeFile } from "node:fs/promises"; import path from "node:path"; -import { File } from "undici"; import { deploy } from "../api/pages/deploy"; import { fetchResult } from "../cfetch"; import { configFileName, readPagesConfig } from "../config"; @@ -34,6 +33,7 @@ import type { Project, UnifiedDeploymentLogMessages, } from "@cloudflare/types"; +import type { File } from "undici"; type PagesDeployArgs = StrictYargsOptionsToInterface; diff --git a/packages/wrangler/src/utils/friendly-validator-errors.ts b/packages/wrangler/src/utils/friendly-validator-errors.ts index e347ed953cca..bc3905cc08bd 100644 --- a/packages/wrangler/src/utils/friendly-validator-errors.ts +++ b/packages/wrangler/src/utils/friendly-validator-errors.ts @@ -73,6 +73,6 @@ export async function helpIfErrorIsSizeOrScriptStartup( if (errIsScriptSize(err)) { printOffendingDependencies(dependencies); } else if (errIsStartupErr(err)) { - handleStartupError(workerBundle, projectRoot); + await handleStartupError(workerBundle, projectRoot); } }