Skip to content

Commit 557ddba

Browse files
implement example apps benchmarking
1 parent 30bab8d commit 557ddba

File tree

11 files changed

+474
-19
lines changed

11 files changed

+474
-19
lines changed

benchmarking/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
results/

benchmarking/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Benchmarking
2+
3+
This directory contains a script for running full end to end benchmarks again the example applications
4+
5+
> [!note]
6+
> This is the first cut at benchmarking our solution, later we can take the script in this directory,
7+
> generalize it and make it more reusable if we want

benchmarking/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "@opennextjs-cloudflare/benchmarking",
3+
"private": true,
4+
"type": "module",
5+
"devDependencies": {
6+
"tsx": "catalog:",
7+
"@tsconfig/strictest": "catalog:",
8+
"@types/node": "catalog:",
9+
"ora": "^8.1.0"
10+
},
11+
"scripts": {
12+
"benchmark": "tsx src/index.ts"
13+
}
14+
}

benchmarking/src/benchmarking.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import nodeTimesPromises from "node:timers/promises";
2+
import nodeFsPromises from "node:fs/promises";
3+
import nodePath from "node:path";
4+
5+
export type FetchBenchmark = {
6+
calls: number[];
7+
average: number;
8+
};
9+
10+
export type BenchmarkingResults = {
11+
name: string;
12+
path: string;
13+
fetchBenchmark: FetchBenchmark;
14+
}[];
15+
16+
/**
17+
* Benchmarks the response time of an application end-to-end by:
18+
* - building the application
19+
* - deploying it
20+
* - and fetching from it (multiple times)
21+
*
22+
* @param options.build function implementing how the application is to be built
23+
* @param options.deploy function implementing how the application is deployed (returning the url of the deployment)
24+
* @param options.fetch function indicating how to fetch from the application (in case a specific route needs to be hit, cookies need to be applied, etc...)
25+
* @returns the benchmarking results for the application
26+
*/
27+
export async function benchmarkApplicationResponseTime({
28+
build,
29+
deploy,
30+
fetch,
31+
}: {
32+
build: () => Promise<void>;
33+
deploy: () => Promise<string>;
34+
fetch: (deploymentUrl: string) => Promise<Response>;
35+
}): Promise<FetchBenchmark> {
36+
await build();
37+
const deploymentUrl = await deploy();
38+
return benchmarkFetch(deploymentUrl, { fetch });
39+
}
40+
41+
type BenchmarkFetchOptions = {
42+
numberOfCalls?: number;
43+
randomDelayMax?: number;
44+
fetch: (deploymentUrl: string) => Promise<Response>;
45+
};
46+
47+
const defaultOptions: Required<Omit<BenchmarkFetchOptions, "fetch">> = {
48+
numberOfCalls: 20,
49+
randomDelayMax: 15_000,
50+
};
51+
52+
/**
53+
* Benchmarks a fetch operation by running it multiple times and computing the average time (in milliseconds) such fetch operation takes.
54+
*
55+
* @param url The url to fetch from
56+
* @param options options for the benchmarking
57+
* @returns the computed average alongside all the single call times
58+
*/
59+
async function benchmarkFetch(url: string, options: BenchmarkFetchOptions): Promise<FetchBenchmark> {
60+
const benchmarkFetchCall = async () => {
61+
const preTime = performance.now();
62+
const resp = await options.fetch(url);
63+
const postTime = performance.now();
64+
65+
if (!resp.ok) {
66+
throw new Error(`Error: Failed to fetch from "${url}"`);
67+
}
68+
69+
return postTime - preTime;
70+
};
71+
72+
const calls = await Promise.all(
73+
new Array(options?.numberOfCalls ?? defaultOptions.numberOfCalls).fill(null).map(async () => {
74+
// let's add a random delay before we make the fetch
75+
await nodeTimesPromises.setTimeout(
76+
Math.round(Math.random() * (options?.randomDelayMax ?? defaultOptions.randomDelayMax))
77+
);
78+
79+
return benchmarkFetchCall();
80+
})
81+
);
82+
83+
const average = calls.reduce((time, sum) => sum + time) / calls.length;
84+
85+
return {
86+
calls,
87+
average,
88+
};
89+
}
90+
91+
/**
92+
* Saves benchmarking results in a local json file
93+
*
94+
* @param results the benchmarking results to save
95+
* @returns the path to the created json file
96+
*/
97+
export async function saveResultsToDisk(results: BenchmarkingResults): Promise<string> {
98+
const date = new Date();
99+
100+
const fileName = `${date.toISOString().split(".")[0]!.replace("T", "_").replaceAll(":", "-")}.json`;
101+
102+
const outputFile = nodePath.resolve(`./results/${fileName}`);
103+
104+
await nodeFsPromises.mkdir(nodePath.dirname(outputFile), { recursive: true });
105+
106+
const resultStr = JSON.stringify(results, null, 2);
107+
await nodeFsPromises.writeFile(outputFile, resultStr);
108+
109+
return outputFile;
110+
}

benchmarking/src/cloudflare.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import nodeFsPromises from "node:fs/promises";
2+
import nodeFs from "node:fs";
3+
import nodePath from "node:path";
4+
import nodeChildProcess from "node:child_process";
5+
import nodeUtil from "node:util";
6+
7+
const promiseExec = nodeUtil.promisify(nodeChildProcess.exec);
8+
9+
await ensureWranglerSetup();
10+
11+
/**
12+
* Collects name and absolute paths of apps (in this repository) that we want to benchmark
13+
*
14+
* @returns Array of objects containing the app's name and absolute path
15+
*/
16+
export async function collectAppPathsToBenchmark(): Promise<
17+
{
18+
name: string;
19+
path: string;
20+
}[]
21+
> {
22+
const allExampleNames = await nodeFsPromises.readdir("../examples");
23+
24+
const examplesToIgnore = new Set(["vercel-commerce"]);
25+
26+
const examplePaths = allExampleNames
27+
.filter((exampleName) => !examplesToIgnore.has(exampleName))
28+
.map((exampleName) => ({
29+
name: exampleName,
30+
path: nodePath.resolve(`../examples/${exampleName}`),
31+
}));
32+
33+
return examplePaths;
34+
}
35+
36+
/**
37+
* Builds an application using their "build:worker" script
38+
* (an error is thrown if the application doesn't have such a script)
39+
*
40+
* @param dir Path to the application to build
41+
*/
42+
export async function buildApp(dir: string): Promise<void> {
43+
const packageJsonPath = `${dir}/package.json`;
44+
if (!nodeFs.existsSync(packageJsonPath)) {
45+
throw new Error(`Error: package.json for app at "${dir}" not found`);
46+
}
47+
48+
const packageJsonContent = JSON.parse(await nodeFsPromises.readFile(packageJsonPath, "utf8"));
49+
50+
if (!("scripts" in packageJsonContent) || !("build:worker" in packageJsonContent.scripts)) {
51+
throw new Error(`Error: package.json for app at "${dir}" does not include a "build:worker" script`);
52+
}
53+
54+
const command = "pnpm build:worker";
55+
56+
await promiseExec(command, { cwd: dir });
57+
}
58+
59+
/**
60+
* Deploys a built application using wrangler
61+
*
62+
* @param dir Path to the application to build
63+
* @returns the url of the deployed application
64+
*/
65+
export async function deployBuiltApp(dir: string): Promise<string> {
66+
const { stdout } = await promiseExec("pnpm exec wrangler deploy", { cwd: dir });
67+
68+
const deploymentUrl = stdout.match(/\bhttps:\/\/(?:[a-zA-Z0-9.\-])*\.workers\.dev\b/)?.[0];
69+
70+
if (!deploymentUrl) {
71+
throw new Error(`Could not obtain a deployment url for app at "${dir}"`);
72+
}
73+
74+
return deploymentUrl;
75+
}
76+
77+
/**
78+
* Makes sure that everything is set up so that wrangler can actually deploy the applications.
79+
* This means that:
80+
* - the user has logged in
81+
* - if they have more than one account they have set a CLOUDFLARE_ACCOUNT_ID env variable
82+
*/
83+
async function ensureWranglerSetup(): Promise<void> {
84+
const { stdout } = await promiseExec("pnpm dlx wrangler whoami");
85+
86+
if (stdout.includes("You are not authenticated")) {
87+
throw new Error("Please log in using wrangler by running `pnpm dlx wrangler login`");
88+
}
89+
90+
if (!(process.env as Record<string, unknown>)["CLOUDFLARE_ACCOUNT_ID"]) {
91+
throw new Error(
92+
"Please set the CLOUDFLARE_ACCOUNT_ID environment variable to the id of the account you want to use to deploy the applications"
93+
);
94+
}
95+
}

benchmarking/src/index.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import nodeTimesPromises from "node:timers/promises";
2+
import * as cloudflare from "./cloudflare";
3+
import { benchmarkApplicationResponseTime, BenchmarkingResults, saveResultsToDisk } from "./benchmarking";
4+
import { runOperationsWithSpinner } from "./utils";
5+
6+
const appPathsToBenchmark = await cloudflare.collectAppPathsToBenchmark();
7+
8+
const benchmarkingResults: BenchmarkingResults = await runOperationsWithSpinner(
9+
"Benchmarking Apps",
10+
appPathsToBenchmark.map(({ name, path }, i) => async () => {
11+
await nodeTimesPromises.setTimeout(i * 1_000);
12+
const fetchBenchmark = await benchmarkApplicationResponseTime({
13+
build: async () => cloudflare.buildApp(path),
14+
deploy: async () => cloudflare.deployBuiltApp(path),
15+
fetch,
16+
});
17+
18+
return {
19+
name,
20+
path,
21+
fetchBenchmark,
22+
};
23+
})
24+
);
25+
26+
console.log();
27+
28+
const outputFile = await saveResultsToDisk(benchmarkingResults);
29+
30+
console.log(`The benchmarking results have been written in ${outputFile}`);
31+
32+
console.log("\n\nSummary: ");
33+
const summary = benchmarkingResults.map(({ name, fetchBenchmark }) => ({
34+
name,
35+
"average fetch duration (ms)": Math.round(fetchBenchmark.average),
36+
}));
37+
console.table(summary);
38+
39+
console.log();
40+
41+
process.exit(0);

benchmarking/src/utils.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import ora from "ora";
2+
3+
/**
4+
* Runs a number of operations while presenting a loading spinner with some text
5+
*
6+
* @param spinnerText The text to add to the spinner
7+
* @param operations The operations to run
8+
* @returns The operations results
9+
*/
10+
export async function runOperationsWithSpinner<T>(
11+
spinnerText: string,
12+
operations: (() => Promise<T>)[]
13+
): Promise<T[]> {
14+
const spinner = ora({
15+
discardStdin: false,
16+
hideCursor: false,
17+
}).start();
18+
19+
let doneCount = 0;
20+
21+
const updateSpinnerText = () => {
22+
doneCount++;
23+
spinner.text = `${spinnerText} (${doneCount}/${operations.length})`;
24+
};
25+
26+
updateSpinnerText();
27+
28+
const results = await Promise.all(
29+
operations.map(async (operation) => {
30+
const result = await operation();
31+
updateSpinnerText();
32+
return result;
33+
})
34+
);
35+
36+
spinner.stop();
37+
38+
return results;
39+
}

benchmarking/tsconfig.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "https://json.schemastore.org/tsconfig",
3+
"extends": "@tsconfig/strictest/tsconfig.json",
4+
"compilerOptions": {
5+
"target": "ESNext",
6+
"module": "ESNext",
7+
"lib": ["ESNext"],
8+
"types": ["node"],
9+
"moduleResolution": "Bundler",
10+
"forceConsistentCasingInFileNames": true,
11+
"noImplicitReturns": false,
12+
"exactOptionalPropertyTypes": false
13+
},
14+
"include": ["./src/**/*.ts"]
15+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"postinstall": "pnpm --filter cloudflare build",
2222
"build": "pnpm --filter cloudflare build",
2323
"e2e": "pnpm build && pnpm -r e2e",
24-
"e2e:dev": "pnpm build && pnpm -r e2e:dev"
24+
"e2e:dev": "pnpm build && pnpm -r e2e:dev",
25+
"benchmark": "pnpm run --filter benchmarking benchmark"
2526
}
2627
}

0 commit comments

Comments
 (0)