Skip to content

Commit 542c6ea

Browse files
penalosaemily-shen
andauthoredFeb 13, 2025··
Support profiling the startup phase of a Worker (#8026)
* Startup profiling * fixes * Support Pages * pages * fix lint * Update packages/wrangler/src/check/commands.ts Co-authored-by: emily-shen <69125074+emily-shen@users.noreply.github.com> * Automatically show message on Pages deploy failures * fix lint --------- Co-authored-by: emily-shen <69125074+emily-shen@users.noreply.github.com>
1 parent 3fb801f commit 542c6ea

15 files changed

+1009
-176
lines changed
 

‎.changeset/rich-pots-mate.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Add `--outfile` to `wrangler deploy` for generating a worker bundle.
6+
7+
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/)

‎.changeset/startup-profiling.md

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Add a `wrangler check startup` command to generate a CPU profile of your Worker's startup phase.
6+
7+
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.
8+
9+
Advanced usage:
10+
11+
- `--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.
12+
- `--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/

‎packages/wrangler/src/__tests__/deploy.test.ts

+430-29
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { mkdirSync, writeFileSync } from "node:fs";
2+
import { readFile } from "node:fs/promises";
3+
import { describe, expect, test } from "vitest";
4+
import { collectCLIOutput } from "./helpers/collect-cli-output";
5+
import { mockConsoleMethods } from "./helpers/mock-console";
6+
import { useMockIsTTY } from "./helpers/mock-istty";
7+
import { runInTempDir } from "./helpers/run-in-tmp";
8+
import { runWrangler } from "./helpers/run-wrangler";
9+
import { writeWorkerSource } from "./helpers/write-worker-source";
10+
import { writeWranglerConfig } from "./helpers/write-wrangler-config";
11+
12+
describe("wrangler check startup", () => {
13+
mockConsoleMethods();
14+
const std = collectCLIOutput();
15+
runInTempDir();
16+
const { setIsTTY } = useMockIsTTY();
17+
setIsTTY(false);
18+
19+
test("generates profile for basic worker", async () => {
20+
writeWranglerConfig({ main: "index.js" });
21+
writeWorkerSource();
22+
23+
await runWrangler("check startup");
24+
25+
expect(std.out).toContain(
26+
`CPU Profile written to worker-startup.cpuprofile`
27+
);
28+
29+
await expect(
30+
readFile("worker-startup.cpuprofile", "utf8")
31+
).resolves.toContain("callFrame");
32+
});
33+
test("--outfile works", async () => {
34+
writeWranglerConfig({ main: "index.js" });
35+
writeWorkerSource();
36+
37+
await runWrangler("check startup --outfile worker.cpuprofile");
38+
39+
expect(std.out).toContain(`CPU Profile written to worker.cpuprofile`);
40+
});
41+
test("--args passed through to deploy", async () => {
42+
writeWranglerConfig({ main: "index.js" });
43+
writeWorkerSource();
44+
45+
await expect(
46+
runWrangler("check startup --args 'abc'")
47+
).rejects.toThrowErrorMatchingInlineSnapshot(
48+
`[Error: The entry-point file at "abc" was not found.]`
49+
);
50+
});
51+
52+
test("--worker-bundle is used instead of building", async () => {
53+
writeWranglerConfig({ main: "index.js" });
54+
writeWorkerSource();
55+
56+
await runWrangler("deploy --dry-run --outfile worker.bundle");
57+
58+
await expect(readFile("worker.bundle", "utf8")).resolves.toContain(
59+
"main_module"
60+
);
61+
await runWrangler("check startup --worker-bundle worker.bundle");
62+
expect(std.out).not.toContain(`Building your Worker`);
63+
64+
await expect(
65+
readFile("worker-startup.cpuprofile", "utf8")
66+
).resolves.toContain("callFrame");
67+
});
68+
69+
test("pages (config file)", async () => {
70+
mkdirSync("public");
71+
writeFileSync("public/README.md", "This is a readme");
72+
73+
mkdirSync("functions");
74+
writeFileSync(
75+
"functions/hello.js",
76+
`
77+
const a = true;
78+
a();
79+
80+
export async function onRequest() {
81+
return new Response("Hello, world!");
82+
}
83+
`
84+
);
85+
writeWranglerConfig({ pages_build_output_dir: "public" });
86+
87+
await runWrangler("check startup");
88+
89+
expect(std.out).toContain(`Pages project detected`);
90+
91+
expect(std.out).toContain(
92+
`CPU Profile written to worker-startup.cpuprofile`
93+
);
94+
95+
await expect(
96+
readFile("worker-startup.cpuprofile", "utf8")
97+
).resolves.toContain("callFrame");
98+
});
99+
100+
test("pages (args)", async () => {
101+
mkdirSync("public");
102+
writeFileSync("public/README.md", "This is a readme");
103+
104+
mkdirSync("functions");
105+
writeFileSync(
106+
"functions/hello.js",
107+
`
108+
const a = true;
109+
a();
110+
111+
export async function onRequest() {
112+
return new Response("Hello, world!");
113+
}
114+
`
115+
);
116+
117+
await runWrangler(
118+
'check startup --args="--build-output-directory=public" --pages'
119+
);
120+
121+
expect(std.out).toContain(`Pages project detected`);
122+
123+
expect(std.out).toContain(
124+
`CPU Profile written to worker-startup.cpuprofile`
125+
);
126+
127+
await expect(
128+
readFile("worker-startup.cpuprofile", "utf8")
129+
).resolves.toContain("callFrame");
130+
});
131+
});

‎packages/wrangler/src/api/pages/deploy.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,7 @@ export async function deploy({
447447
body: formData,
448448
}
449449
);
450-
return deploymentResponse;
450+
return { deploymentResponse, formData };
451451
} catch (e) {
452452
lastErr = e;
453453
if (
+242
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { randomUUID } from "crypto";
2+
import { readFile } from "fs/promises";
3+
import events from "node:events";
4+
import { writeFile } from "node:fs/promises";
5+
import path from "path";
6+
import { log } from "@cloudflare/cli";
7+
import { spinnerWhile } from "@cloudflare/cli/interactive";
8+
import chalk from "chalk";
9+
import { Miniflare } from "miniflare";
10+
import { WebSocket } from "ws";
11+
import { createCLIParser } from "..";
12+
import { createCommand, createNamespace } from "../core/create-command";
13+
import { moduleTypeMimeType } from "../deployment-bundle/create-worker-upload-form";
14+
import {
15+
flipObject,
16+
ModuleTypeToRuleType,
17+
} from "../deployment-bundle/module-collection";
18+
import { UserError } from "../errors";
19+
import { logger } from "../logger";
20+
import { getWranglerTmpDir } from "../paths";
21+
import type { Config } from "../config";
22+
import type { ModuleDefinition } from "miniflare";
23+
import type { FormData, FormDataEntryValue } from "undici";
24+
25+
const mimeTypeModuleType = flipObject(moduleTypeMimeType);
26+
27+
export const checkNamespace = createNamespace({
28+
metadata: {
29+
description: "☑︎ Run checks on your Worker",
30+
owner: "Workers: Authoring and Testing",
31+
status: "alpha",
32+
hidden: true,
33+
},
34+
});
35+
36+
async function checkStartupHandler(
37+
{
38+
outfile,
39+
args,
40+
workerBundle,
41+
pages,
42+
}: { outfile: string; args?: string; workerBundle?: string; pages?: boolean },
43+
{ config }: { config: Config }
44+
) {
45+
if (workerBundle === undefined) {
46+
const tmpDir = getWranglerTmpDir(undefined, "startup-profile");
47+
workerBundle = path.join(tmpDir.path, "worker.bundle");
48+
49+
if (config.pages_build_output_dir || pages) {
50+
log("Pages project detected");
51+
log("");
52+
}
53+
54+
if (logger.loggerLevel !== "debug") {
55+
// Hide build logs
56+
logger.loggerLevel = "error";
57+
}
58+
59+
await spinnerWhile({
60+
promise: async () =>
61+
await createCLIParser(
62+
config.pages_build_output_dir || pages
63+
? [
64+
"pages",
65+
"functions",
66+
"build",
67+
...(args?.split(" ") ?? []),
68+
`--outfile=${workerBundle}`,
69+
]
70+
: [
71+
"deploy",
72+
...(args?.split(" ") ?? []),
73+
"--dry-run",
74+
`--outfile=${workerBundle}`,
75+
]
76+
).parse(),
77+
startMessage: "Building your Worker",
78+
endMessage: chalk.green("Worker Built! 🎉"),
79+
});
80+
logger.resetLoggerLevel();
81+
}
82+
const cpuProfileResult = await spinnerWhile({
83+
promise: analyseBundle(workerBundle),
84+
startMessage: "Analysing",
85+
endMessage: chalk.green("Startup phase analysed"),
86+
});
87+
88+
await writeFile(outfile, JSON.stringify(await cpuProfileResult));
89+
90+
log(
91+
`CPU Profile written to ${outfile}. Load it into the Chrome DevTools profiler (or directly in VSCode) to view a flamegraph.`
92+
);
93+
}
94+
95+
export const checkStartupCommand = createCommand({
96+
args: {
97+
outfile: {
98+
describe: "Output file for startup phase cpuprofile",
99+
type: "string",
100+
default: "worker-startup.cpuprofile",
101+
},
102+
workerBundle: {
103+
alias: "worker",
104+
describe:
105+
"Path to a prebuilt worker bundle i.e the output of `wrangler deploy --outfile worker.bundle",
106+
type: "string",
107+
},
108+
pages: {
109+
describe: "Force this project to be treated as a Pages project",
110+
type: "boolean",
111+
},
112+
args: {
113+
describe:
114+
"Additional arguments passed to `wrangler deploy` or `wrangler pages functions build` e.g. `--no-bundle`",
115+
type: "string",
116+
},
117+
},
118+
validateArgs({ args, workerBundle }) {
119+
if (workerBundle && args) {
120+
throw new UserError(
121+
"`--args` and `--worker` are mutually exclusive—please only specify one"
122+
);
123+
}
124+
125+
if (args?.includes("outfile") || args?.includes("outdir")) {
126+
throw new UserError(
127+
"`--args` should not contain `--outfile` or `--outdir`"
128+
);
129+
}
130+
},
131+
metadata: {
132+
description: "⌛ Profile your Worker's startup performance",
133+
owner: "Workers: Authoring and Testing",
134+
status: "alpha",
135+
},
136+
handler: checkStartupHandler,
137+
});
138+
139+
async function getEntryValue(
140+
entry: FormDataEntryValue
141+
): Promise<Uint8Array<ArrayBuffer> | string> {
142+
if (entry instanceof Blob) {
143+
return new Uint8Array(await entry.arrayBuffer());
144+
} else {
145+
return entry as string;
146+
}
147+
}
148+
149+
function getModuleType(entry: FormDataEntryValue) {
150+
if (entry instanceof Blob) {
151+
return ModuleTypeToRuleType[mimeTypeModuleType[entry.type]];
152+
} else {
153+
return "Text";
154+
}
155+
}
156+
157+
async function convertWorkerBundleToModules(
158+
workerBundle: FormData
159+
): Promise<ModuleDefinition[]> {
160+
return await Promise.all(
161+
[...workerBundle.entries()].map(async (m) => ({
162+
type: getModuleType(m[1]),
163+
path: m[0],
164+
contents: await getEntryValue(m[1]),
165+
}))
166+
);
167+
}
168+
169+
async function parseFormDataFromFile(file: string): Promise<FormData> {
170+
const bundle = await readFile(file);
171+
const firstLine = bundle.findIndex((v) => v === 10);
172+
const boundary = Uint8Array.prototype.slice
173+
.call(bundle, 2, firstLine)
174+
.toString();
175+
return await new Response(bundle, {
176+
headers: {
177+
"Content-Type": "multipart/form-data; boundary=" + boundary,
178+
},
179+
}).formData();
180+
}
181+
182+
export async function analyseBundle(
183+
workerBundle: string | FormData
184+
): Promise<Record<string, unknown>> {
185+
if (typeof workerBundle === "string") {
186+
workerBundle = await parseFormDataFromFile(workerBundle);
187+
}
188+
189+
const metadata = JSON.parse(workerBundle.get("metadata") as string);
190+
191+
if (!("main_module" in metadata)) {
192+
throw new UserError(
193+
"`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."
194+
);
195+
}
196+
const mf = new Miniflare({
197+
name: "profiler",
198+
compatibilityDate: metadata.compatibility_date,
199+
compatibilityFlags: metadata.compatibility_flags,
200+
modulesRoot: "/",
201+
modules: [
202+
{
203+
type: "ESModule",
204+
// Make sure the entrypoint path doesn't conflict with a user worker module
205+
path: randomUUID(),
206+
contents: /* javascript */ `
207+
async function startup() {
208+
await import("${metadata.main_module}");
209+
}
210+
export default {
211+
async fetch() {
212+
await startup()
213+
return new Response("ok")
214+
}
215+
}
216+
`,
217+
},
218+
...(await convertWorkerBundleToModules(workerBundle)),
219+
],
220+
inspectorPort: 0,
221+
});
222+
await mf.ready;
223+
const inspectorUrl = await mf.getInspectorURL();
224+
const ws = new WebSocket(new URL("/core:user:profiler", inspectorUrl.href));
225+
await events.once(ws, "open");
226+
ws.send(JSON.stringify({ id: 1, method: "Profiler.enable", params: {} }));
227+
ws.send(JSON.stringify({ id: 2, method: "Profiler.start", params: {} }));
228+
229+
const cpuProfileResult = new Promise<Record<string, unknown>>((accept) => {
230+
ws.addEventListener("message", (e) => {
231+
const data = JSON.parse(e.data as string);
232+
if (data.method === "Profiler.stop") {
233+
void mf.dispose().then(() => accept(data.result.profile));
234+
}
235+
});
236+
});
237+
238+
await (await mf.dispatchFetch("https://example.com")).text();
239+
ws.send(JSON.stringify({ id: 3, method: "Profiler.stop", params: {} }));
240+
241+
return cpuProfileResult;
242+
}

‎packages/wrangler/src/deploy/deploy.ts

+27-69
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,13 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
33
import path from "node:path";
44
import { URLSearchParams } from "node:url";
55
import { cancel } from "@cloudflare/cli";
6+
import { Response } from "undici";
67
import { syncAssets } from "../assets";
78
import { fetchListResult, fetchResult } from "../cfetch";
89
import { configFileName, formatConfigSnippet } from "../config";
910
import { getBindings, provisionBindings } from "../deployment-bundle/bindings";
1011
import { bundleWorker } from "../deployment-bundle/bundle";
11-
import {
12-
printBundleSize,
13-
printOffendingDependencies,
14-
} from "../deployment-bundle/bundle-reporter";
12+
import { printBundleSize } from "../deployment-bundle/bundle-reporter";
1513
import { getBundleType } from "../deployment-bundle/bundle-type";
1614
import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form";
1715
import { logBuildOutput } from "../deployment-bundle/esbuild-plugins/log-build-output";
@@ -48,6 +46,7 @@ import {
4846
maybeRetrieveFileSourceMap,
4947
} from "../sourcemap";
5048
import triggersDeploy from "../triggers/deploy";
49+
import { helpIfErrorIsSizeOrScriptStartup } from "../utils/friendly-validator-errors";
5150
import { printBindings } from "../utils/print-bindings";
5251
import { retryOnAPIFailure } from "../utils/retry";
5352
import {
@@ -75,6 +74,7 @@ import type { PostQueueBody, PostTypedConsumerBody } from "../queues/client";
7574
import type { LegacyAssetPaths } from "../sites";
7675
import type { RetrieveSourceMapFunction } from "../sourcemap";
7776
import type { ApiVersion, Percentage, VersionId } from "../versions/types";
77+
import type { FormData } from "undici";
7878

7979
type Props = {
8080
config: Config;
@@ -100,6 +100,7 @@ type Props = {
100100
minify: boolean | undefined;
101101
nodeCompat: boolean | undefined;
102102
outDir: string | undefined;
103+
outFile: string | undefined;
103104
dryRun: boolean | undefined;
104105
noBundle: boolean | undefined;
105106
keepVars: boolean | undefined;
@@ -134,47 +135,6 @@ export type CustomDomainChangeset = {
134135
conflicting: ConflictingCustomDomain[];
135136
};
136137

137-
export function sleep(ms: number) {
138-
return new Promise((resolve) => setTimeout(resolve, ms));
139-
}
140-
141-
const scriptStartupErrorRegex = /startup/i;
142-
143-
function errIsScriptSize(err: unknown): err is { code: 10027 } {
144-
if (!err) {
145-
return false;
146-
}
147-
148-
// 10027 = workers.api.error.script_too_large
149-
if ((err as { code: number }).code === 10027) {
150-
return true;
151-
}
152-
153-
return false;
154-
}
155-
156-
function errIsStartupErr(err: unknown): err is ParseError & { code: 10021 } {
157-
if (!err) {
158-
return false;
159-
}
160-
161-
// 10021 = validation error
162-
// no explicit error code for more granular errors than "invalid script"
163-
// but the error will contain a string error message directly from the
164-
// validator.
165-
// the error always SHOULD look like "Script startup exceeded CPU limit."
166-
// (or the less likely "Script startup exceeded memory limits.")
167-
if (
168-
(err as { code: number }).code === 10021 &&
169-
err instanceof ParseError &&
170-
scriptStartupErrorRegex.test(err.notes[0]?.text)
171-
) {
172-
return true;
173-
}
174-
175-
return false;
176-
}
177-
178138
export const validateRoutes = (routes: Route[], assets?: AssetsOptions) => {
179139
const invalidRoutes: Record<string, string[]> = {};
180140
const mountedAssetRoutes: string[] = [];
@@ -795,7 +755,10 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
795755
migrations === undefined &&
796756
!config.first_party_worker;
797757

758+
let workerBundle: FormData;
759+
798760
if (props.dryRun) {
761+
workerBundle = createWorkerUploadForm(worker);
799762
printBindings({ ...withoutStaticAssets, vars: maskedVars });
800763
} else {
801764
assert(accountId, "Missing accountId");
@@ -809,6 +772,8 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
809772
props.config
810773
);
811774
}
775+
workerBundle = createWorkerUploadForm(worker);
776+
812777
await ensureQueuesExistByConfig(config);
813778
let bindingsPrinted = false;
814779

@@ -831,7 +796,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
831796
`/accounts/${accountId}/workers/scripts/${scriptName}/versions`,
832797
{
833798
method: "POST",
834-
body: createWorkerUploadForm(worker),
799+
body: workerBundle,
835800
headers: await getMetricsUsageHeaders(config.send_metrics),
836801
}
837802
)
@@ -873,7 +838,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
873838
workerUrl,
874839
{
875840
method: "PUT",
876-
body: createWorkerUploadForm(worker),
841+
body: workerBundle,
877842
headers: await getMetricsUsageHeaders(config.send_metrics),
878843
},
879844
new URLSearchParams({
@@ -918,7 +883,12 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
918883
if (!bindingsPrinted) {
919884
printBindings({ ...withoutStaticAssets, vars: maskedVars });
920885
}
921-
helpIfErrorIsSizeOrScriptStartup(err, dependencies);
886+
await helpIfErrorIsSizeOrScriptStartup(
887+
err,
888+
dependencies,
889+
workerBundle,
890+
props.projectRoot
891+
);
922892

923893
// Apply source mapping to validation startup errors if possible
924894
if (
@@ -967,6 +937,15 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
967937
throw err;
968938
}
969939
}
940+
if (props.outFile) {
941+
// we're using a custom output file,
942+
// so let's first ensure it's parent directory exists
943+
mkdirSync(path.dirname(props.outFile), { recursive: true });
944+
945+
const serializedFormData = await new Response(workerBundle).arrayBuffer();
946+
947+
writeFileSync(props.outFile, Buffer.from(serializedFormData));
948+
}
970949
} finally {
971950
if (typeof destination !== "string") {
972951
// this means we're using a temp dir,
@@ -1012,27 +991,6 @@ function deployWfpUserWorker(
1012991
logger.log("Current Version ID:", versionId);
1013992
}
1014993

1015-
function helpIfErrorIsSizeOrScriptStartup(
1016-
err: unknown,
1017-
dependencies: { [path: string]: { bytesInOutput: number } }
1018-
) {
1019-
if (errIsScriptSize(err)) {
1020-
printOffendingDependencies(dependencies);
1021-
} else if (errIsStartupErr(err)) {
1022-
const youFailed =
1023-
"Your Worker failed validation because it exceeded startup limits.";
1024-
const heresWhy =
1025-
"To ensure fast responses, we place constraints on Worker startup -- like how much CPU it can use, or how long it can take.";
1026-
const heresTheProblem =
1027-
"Your Worker failed validation, which means it hit one of these startup limits.";
1028-
const heresTheSolution =
1029-
"Try reducing the amount of work done during startup (outside the event handler), either by removing code or relocating it inside the event handler.";
1030-
logger.warn(
1031-
[youFailed, heresWhy, heresTheProblem, heresTheSolution].join("\n")
1032-
);
1033-
}
1034-
}
1035-
1036994
export function formatTime(duration: number) {
1037995
return `(${(duration / 1000).toFixed(2)} sec)`;
1038996
}

‎packages/wrangler/src/deploy/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ export const deployCommand = createCommand({
6464
type: "string",
6565
requiresArg: true,
6666
},
67+
outfile: {
68+
describe: "Output file for the bundled worker",
69+
type: "string",
70+
requiresArg: true,
71+
},
6772
"compatibility-date": {
6873
describe: "Date to use for compatibility checks",
6974
type: "string",
@@ -371,6 +376,7 @@ export const deployCommand = createCommand({
371376
nodeCompat: args.nodeCompat,
372377
isWorkersSite: Boolean(args.site || config.site),
373378
outDir: args.outdir,
379+
outFile: args.outfile,
374380
dryRun: args.dryRun,
375381
noBundle: !(args.bundle ?? !config.no_bundle),
376382
keepVars: args.keepVars,

‎packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import type {
1717
import type { AssetConfig } from "@cloudflare/workers-shared";
1818
import type { Json } from "miniflare";
1919

20-
const moduleTypeMimeType: { [type in CfModuleType]: string | undefined } = {
20+
export const moduleTypeMimeType: {
21+
[type in CfModuleType]: string | undefined;
22+
} = {
2123
esm: "application/javascript+module",
2224
commonjs: "application/javascript",
2325
"compiled-wasm": "application/wasm",

‎packages/wrangler/src/deployment-bundle/module-collection.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ import type { Entry } from "./entry";
1616
import type { CfModule, CfModuleType } from "./worker";
1717
import type esbuild from "esbuild";
1818

19-
function flipObject<
19+
export function flipObject<
2020
K extends string | number | symbol,
21-
V extends string | number | symbol,
22-
>(obj: Record<K, V>): Record<V, K> {
23-
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k]));
21+
V extends string | number | symbol | undefined,
22+
>(obj: Record<K, V>): Record<NonNullable<V>, K> {
23+
return Object.fromEntries(
24+
Object.entries(obj)
25+
.filter(([_, v]) => !!v)
26+
.map(([k, v]) => [v, k])
27+
);
2428
}
2529

2630
export const RuleTypeToModuleType: Record<ConfigModuleRuleType, CfModuleType> =

‎packages/wrangler/src/index.ts

+13
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
certUploadMtlsCommand,
1414
certUploadNamespace,
1515
} from "./cert/cert";
16+
import { checkNamespace, checkStartupCommand } from "./check/commands";
1617
import { cloudchamber } from "./cloudchamber";
1718
import { experimental_readRawConfig, loadDotEnv } from "./config";
1819
import { demandSingleValue } from "./core";
@@ -893,6 +894,18 @@ export function createCLIParser(argv: string[]) {
893894
]);
894895
registry.registerNamespace("telemetry");
895896

897+
registry.define([
898+
{
899+
command: "wrangler check",
900+
definition: checkNamespace,
901+
},
902+
{
903+
command: "wrangler check startup",
904+
definition: checkStartupCommand,
905+
},
906+
]);
907+
registry.registerNamespace("check");
908+
896909
/******************************************************/
897910
/* DEPRECATED COMMANDS */
898911
/******************************************************/

‎packages/wrangler/src/pages/deploy.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { execSync } from "node:child_process";
2+
import { writeFile } from "node:fs/promises";
3+
import path from "node:path";
24
import { deploy } from "../api/pages/deploy";
35
import { fetchResult } from "../cfetch";
46
import { configFileName, readPagesConfig } from "../config";
@@ -10,13 +12,15 @@ import { logger } from "../logger";
1012
import * as metrics from "../metrics";
1113
import { writeOutput } from "../output";
1214
import { requireAuth } from "../user";
15+
import { handleStartupError } from "../utils/friendly-validator-errors";
1316
import {
1417
MAX_DEPLOYMENT_STATUS_ATTEMPTS,
1518
PAGES_CONFIG_CACHE_FILENAME,
1619
} from "./constants";
1720
import { EXIT_CODE_INVALID_PAGES_CONFIG } from "./errors";
1821
import { listProjects } from "./projects";
1922
import { promptSelectProject } from "./prompt-select-project";
23+
import { getPagesProjectRoot, getPagesTmpDir } from "./utils";
2024
import type { Config } from "../config";
2125
import type {
2226
CommonYargsArgv,
@@ -29,6 +33,7 @@ import type {
2933
Project,
3034
UnifiedDeploymentLogMessages,
3135
} from "@cloudflare/types";
36+
import type { File } from "undici";
3237

3338
type PagesDeployArgs = StrictYargsOptionsToInterface<typeof Options>;
3439

@@ -340,7 +345,7 @@ export const Handler = async (args: PagesDeployArgs) => {
340345
}
341346
}
342347

343-
const deploymentResponse = await deploy({
348+
const { deploymentResponse, formData } = await deploy({
344349
directory,
345350
accountId,
346351
projectName,
@@ -425,6 +430,13 @@ export const Handler = async (args: PagesDeployArgs) => {
425430
.replace("Error:", "")
426431
.trim();
427432

433+
if (failureMessage.includes("Script startup exceeded CPU time limit")) {
434+
const workerBundle = formData.get("_worker.bundle") as File;
435+
const filePath = path.join(getPagesTmpDir(), "_worker.bundle");
436+
await writeFile(filePath, workerBundle.stream());
437+
await handleStartupError(filePath, getPagesProjectRoot());
438+
}
439+
428440
throw new FatalError(
429441
`Deployment failed!
430442
${failureMessage}`,

‎packages/wrangler/src/paths.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ export interface EphemeralDirectory {
9090
*/
9191
export function getWranglerTmpDir(
9292
projectRoot: string | undefined,
93-
prefix: string
93+
prefix: string,
94+
cleanup = true
9495
): EphemeralDirectory {
9596
projectRoot ??= process.cwd();
9697
const tmpRoot = path.join(projectRoot, ".wrangler", "tmp");
@@ -100,10 +101,12 @@ export function getWranglerTmpDir(
100101
const tmpDir = fs.realpathSync(fs.mkdtempSync(tmpPrefix));
101102

102103
const removeDir = () => {
103-
try {
104-
return fs.rmSync(tmpDir, { recursive: true, force: true });
105-
} catch (e) {
106-
// This sometimes fails on Windows with EBUSY
104+
if (cleanup) {
105+
try {
106+
return fs.rmSync(tmpDir, { recursive: true, force: true });
107+
} catch (e) {
108+
// This sometimes fails on Windows with EBUSY
109+
}
107110
}
108111
};
109112
const removeExitListener = onExit(removeDir);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { writeFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import dedent from "ts-dedent";
4+
import { analyseBundle } from "../check/commands";
5+
import { printOffendingDependencies } from "../deployment-bundle/bundle-reporter";
6+
import { UserError } from "../errors";
7+
import { ParseError } from "../parse";
8+
import { getWranglerTmpDir } from "../paths";
9+
import type { FormData } from "undici";
10+
11+
function errIsScriptSize(err: unknown): err is { code: 10027 } {
12+
if (!err) {
13+
return false;
14+
}
15+
16+
// 10027 = workers.api.error.script_too_large
17+
if ((err as { code: number }).code === 10027) {
18+
return true;
19+
}
20+
21+
return false;
22+
}
23+
const scriptStartupErrorRegex = /startup/i;
24+
25+
function errIsStartupErr(err: unknown): err is ParseError & { code: 10021 } {
26+
if (!err) {
27+
return false;
28+
}
29+
30+
// 10021 = validation error
31+
// no explicit error code for more granular errors than "invalid script"
32+
// but the error will contain a string error message directly from the
33+
// validator.
34+
// the error always SHOULD look like "Script startup exceeded CPU limit."
35+
// (or the less likely "Script startup exceeded memory limits.")
36+
if (
37+
(err as { code: number }).code === 10021 &&
38+
err instanceof ParseError &&
39+
scriptStartupErrorRegex.test(err.notes[0]?.text)
40+
) {
41+
return true;
42+
}
43+
44+
return false;
45+
}
46+
47+
export async function handleStartupError(
48+
workerBundle: FormData | string,
49+
projectRoot: string | undefined
50+
) {
51+
const cpuProfile = await analyseBundle(workerBundle);
52+
const tmpDir = await getWranglerTmpDir(projectRoot, "startup-profile", false);
53+
const profile = path.relative(
54+
projectRoot ?? process.cwd(),
55+
path.join(tmpDir.path, `worker.cpuprofile`)
56+
);
57+
await writeFile(profile, JSON.stringify(cpuProfile));
58+
throw new UserError(dedent`
59+
Your Worker failed validation because it exceeded startup limits.
60+
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.
61+
62+
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.
63+
64+
Refer to https://developers.cloudflare.com/workers/platform/limits/#worker-startup-time for more details`);
65+
}
66+
67+
export async function helpIfErrorIsSizeOrScriptStartup(
68+
err: unknown,
69+
dependencies: { [path: string]: { bytesInOutput: number } },
70+
workerBundle: FormData,
71+
projectRoot: string | undefined
72+
) {
73+
if (errIsScriptSize(err)) {
74+
printOffendingDependencies(dependencies);
75+
} else if (errIsStartupErr(err)) {
76+
await handleStartupError(workerBundle, projectRoot);
77+
}
78+
}

‎packages/wrangler/src/versions/upload.ts

+30-66
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,7 @@ import { configFileName, formatConfigSnippet } from "../config";
1212
import { createCommand } from "../core/create-command";
1313
import { getBindings, provisionBindings } from "../deployment-bundle/bindings";
1414
import { bundleWorker } from "../deployment-bundle/bundle";
15-
import {
16-
printBundleSize,
17-
printOffendingDependencies,
18-
} from "../deployment-bundle/bundle-reporter";
15+
import { printBundleSize } from "../deployment-bundle/bundle-reporter";
1916
import { getBundleType } from "../deployment-bundle/bundle-type";
2017
import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form";
2118
import { getEntry } from "../deployment-bundle/entry";
@@ -51,6 +48,7 @@ import {
5148
} from "../sourcemap";
5249
import { requireAuth } from "../user";
5350
import { collectKeyValues } from "../utils/collectKeyValues";
51+
import { helpIfErrorIsSizeOrScriptStartup } from "../utils/friendly-validator-errors";
5452
import { getRules } from "../utils/getRules";
5553
import { getScriptName } from "../utils/getScriptName";
5654
import { isLegacyEnv } from "../utils/isLegacyEnv";
@@ -62,6 +60,7 @@ import type { Rule } from "../config/environment";
6260
import type { Entry } from "../deployment-bundle/entry";
6361
import type { CfPlacement, CfWorkerInit } from "../deployment-bundle/worker";
6462
import type { RetrieveSourceMapFunction } from "../sourcemap";
63+
import type { FormData } from "undici";
6564

6665
type Props = {
6766
config: Config;
@@ -85,6 +84,7 @@ type Props = {
8584
uploadSourceMaps: boolean | undefined;
8685
nodeCompat: boolean | undefined;
8786
outDir: string | undefined;
87+
outFile: string | undefined;
8888
dryRun: boolean | undefined;
8989
noBundle: boolean | undefined;
9090
keepVars: boolean | undefined;
@@ -95,43 +95,6 @@ type Props = {
9595
message: string | undefined;
9696
};
9797

98-
const scriptStartupErrorRegex = /startup/i;
99-
100-
function errIsScriptSize(err: unknown): err is { code: 10027 } {
101-
if (!err) {
102-
return false;
103-
}
104-
105-
// 10027 = workers.api.error.script_too_large
106-
if ((err as { code: number }).code === 10027) {
107-
return true;
108-
}
109-
110-
return false;
111-
}
112-
113-
function errIsStartupErr(err: unknown): err is ParseError & { code: 10021 } {
114-
if (!err) {
115-
return false;
116-
}
117-
118-
// 10021 = validation error
119-
// no explicit error code for more granular errors than "invalid script"
120-
// but the error will contain a string error message directly from the
121-
// validator.
122-
// the error always SHOULD look like "Script startup exceeded CPU limit."
123-
// (or the less likely "Script startup exceeded memory limits.")
124-
if (
125-
(err as { code: number }).code === 10021 &&
126-
err instanceof ParseError &&
127-
scriptStartupErrorRegex.test(err.notes[0]?.text)
128-
) {
129-
return true;
130-
}
131-
132-
return false;
133-
}
134-
13598
export const versionsUploadCommand = createCommand({
13699
metadata: {
137100
description: "Uploads your Worker code and config as a new Version",
@@ -164,6 +127,11 @@ export const versionsUploadCommand = createCommand({
164127
type: "string",
165128
requiresArg: true,
166129
},
130+
outfile: {
131+
describe: "Output file for the bundled worker",
132+
type: "string",
133+
requiresArg: true,
134+
},
167135
"compatibility-date": {
168136
describe: "Date to use for compatibility checks",
169137
type: "string",
@@ -413,6 +381,7 @@ export const versionsUploadCommand = createCommand({
413381
tag: args.tag,
414382
message: args.message,
415383
experimentalAutoCreate: args.experimentalAutoCreate,
384+
outFile: args.outfile,
416385
});
417386

418387
writeOutput({
@@ -762,7 +731,10 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
762731
}
763732
}
764733

734+
let workerBundle: FormData;
735+
765736
if (props.dryRun) {
737+
workerBundle = createWorkerUploadForm(worker);
766738
printBindings({ ...bindings, vars: maskedVars });
767739
} else {
768740
assert(accountId, "Missing accountId");
@@ -775,14 +747,13 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
775747
props.config
776748
);
777749
}
750+
workerBundle = createWorkerUploadForm(worker);
778751

779752
await ensureQueuesExistByConfig(config);
780753
let bindingsPrinted = false;
781754

782755
// Upload the version.
783756
try {
784-
const body = createWorkerUploadForm(worker);
785-
786757
const result = await retryOnAPIFailure(async () =>
787758
fetchResult<{
788759
id: string;
@@ -792,7 +763,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
792763
};
793764
}>(`${workerUrl}/versions`, {
794765
method: "POST",
795-
body,
766+
body: workerBundle,
796767
headers: await getMetricsUsageHeaders(config.send_metrics),
797768
})
798769
);
@@ -807,7 +778,12 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
807778
printBindings({ ...bindings, vars: maskedVars });
808779
}
809780

810-
helpIfErrorIsSizeOrScriptStartup(err, dependencies);
781+
await helpIfErrorIsSizeOrScriptStartup(
782+
err,
783+
dependencies,
784+
workerBundle,
785+
props.projectRoot
786+
);
811787

812788
// Apply source mapping to validation startup errors if possible
813789
if (
@@ -845,6 +821,15 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
845821
throw err;
846822
}
847823
}
824+
if (props.outFile) {
825+
// we're using a custom output file,
826+
// so let's first ensure it's parent directory exists
827+
mkdirSync(path.dirname(props.outFile), { recursive: true });
828+
829+
const serializedFormData = await new Response(workerBundle).arrayBuffer();
830+
831+
writeFileSync(props.outFile, Buffer.from(serializedFormData));
832+
}
848833
} finally {
849834
if (typeof destination !== "string") {
850835
// this means we're using a temp dir,
@@ -900,27 +885,6 @@ Changes to triggers (routes, custom domains, cron schedules, etc) must be applie
900885
return { versionId, workerTag, versionPreviewUrl };
901886
}
902887

903-
function helpIfErrorIsSizeOrScriptStartup(
904-
err: unknown,
905-
dependencies: { [path: string]: { bytesInOutput: number } }
906-
) {
907-
if (errIsScriptSize(err)) {
908-
printOffendingDependencies(dependencies);
909-
} else if (errIsStartupErr(err)) {
910-
const youFailed =
911-
"Your Worker failed validation because it exceeded startup limits.";
912-
const heresWhy =
913-
"To ensure fast responses, we place constraints on Worker startup -- like how much CPU it can use, or how long it can take.";
914-
const heresTheProblem =
915-
"Your Worker failed validation, which means it hit one of these startup limits.";
916-
const heresTheSolution =
917-
"Try reducing the amount of work done during startup (outside the event handler), either by removing code or relocating it inside the event handler.";
918-
logger.warn(
919-
[youFailed, heresWhy, heresTheProblem, heresTheSolution].join("\n")
920-
);
921-
}
922-
}
923-
924888
function formatTime(duration: number) {
925889
return `(${(duration / 1000).toFixed(2)} sec)`;
926890
}

0 commit comments

Comments
 (0)
Please sign in to comment.