diff options
author | 2025-06-05 14:25:23 +0000 | |
---|---|---|
committer | 2025-06-05 14:25:23 +0000 | |
commit | e586d7d704d475afe3373a1de6ae20d504f79d6d (patch) | |
tree | 7e3fa24807cebd48a86bd40f866d792181191ee9 /benchmark | |
download | astro-latest.tar.gz astro-latest.tar.zst astro-latest.zip |
Sync from a8e1c0a7402940e0fc5beef669522b315052df1blatest
Diffstat (limited to 'benchmark')
36 files changed, 1565 insertions, 0 deletions
diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 000000000..79d63f4da --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,5 @@ +# benchmark + +Astro's main benchmark suite. It exposes the `astro-benchmark` CLI command. Run `astro-benchmark --help` to see all available commands! + +If you'd like to understand how the benchmark works, check out the other READMEs in the subfolders. diff --git a/benchmark/bench/README.md b/benchmark/bench/README.md new file mode 100644 index 000000000..9d3312880 --- /dev/null +++ b/benchmark/bench/README.md @@ -0,0 +1,7 @@ +# bench + +This `bench` folder contains different benchmarking files that you can run via `astro-benchmark <bench-file-name>`, e.g. `astro-benchmark memory`. Files that start with an underscore are not benchmarking files. + +Benchmarking files will run against a project to measure its performance, and write the results down as JSON in the `results` folder. The `results` folder is gitignored and its result files can be safely deleted if you're not using them. + +You can duplicate `_template.js` to start a new benchmark test. All shared utilities are kept in `_util.js`. diff --git a/benchmark/bench/_template.js b/benchmark/bench/_template.js new file mode 100644 index 000000000..ae96d72ad --- /dev/null +++ b/benchmark/bench/_template.js @@ -0,0 +1,12 @@ +/** Default project to run for this benchmark if not specified */ +export const defaultProject = 'project-name'; + +/** + * Run benchmark on `projectDir` and write results to `outputFile`. + * Use `console.log` to report the results too. Logs that start with 10 `=` + * and end with 10 `=` will be extracted by CI to display in the PR comment. + * Usually after the first 10 `=` you'll want to add a title like `#### Test`. + * @param {URL} _projectDir + * @param {URL} _outputFile + */ +export async function run(_projectDir, _outputFile) {} diff --git a/benchmark/bench/_util.js b/benchmark/bench/_util.js new file mode 100644 index 000000000..d9dfe5b19 --- /dev/null +++ b/benchmark/bench/_util.js @@ -0,0 +1,32 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const astroPkgPath = createRequire(import.meta.url).resolve('astro/package.json'); + +export const astroBin = path.resolve(astroPkgPath, '../astro.js'); + +/** @typedef {{ avg: number, stdev: number, max: number }} Stat */ + +/** + * @param {number[]} numbers + * @returns {Stat} + */ +export function calculateStat(numbers) { + const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length; + const stdev = Math.sqrt( + numbers.map((x) => Math.pow(x - avg, 2)).reduce((a, b) => a + b, 0) / numbers.length, + ); + const max = Math.max(...numbers); + return { avg, stdev, max }; +} + +export async function makeProject(name) { + console.log('Making project:', name); + const projectDir = new URL(`../projects/${name}/`, import.meta.url); + + const makeProjectMod = await import(`../make-project/${name}.js`); + await makeProjectMod.run(projectDir); + + console.log('Finished making project:', name); + return projectDir; +} diff --git a/benchmark/bench/cli-startup.js b/benchmark/bench/cli-startup.js new file mode 100644 index 000000000..9144797d7 --- /dev/null +++ b/benchmark/bench/cli-startup.js @@ -0,0 +1,73 @@ +import { fileURLToPath } from 'node:url'; +import { markdownTable } from 'markdown-table'; +import { exec } from 'tinyexec'; +import { astroBin, calculateStat } from './_util.js'; + +/** Default project to run for this benchmark if not specified */ +export const defaultProject = 'render-default'; + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + const root = fileURLToPath(projectDir); + + console.log('Benchmarking `astro --help`...'); + const helpStat = await benchmarkCommand('node', [astroBin, '--help'], root); + console.log('Done'); + + console.log('Benchmarking `astro preferences list`...'); + const infoStat = await benchmarkCommand('node', [astroBin, 'preferences', 'list'], root); + console.log('Done'); + + console.log('Result preview:'); + console.log('='.repeat(10)); + console.log(`#### CLI Startup\n\n`); + console.log( + printResult({ + 'astro --help': helpStat, + 'astro info': infoStat, + }), + ); + console.log('='.repeat(10)); +} + +/** + * @param {string} command + * @param {string[]} args + * @param {string} root + * @returns {Promise<import('./_util.js').Stat>} + */ +async function benchmarkCommand(command, args, root) { + /** @type {number[]} */ + const durations = []; + + for (let i = 0; i < 10; i++) { + const start = performance.now(); + await exec(command, args, { nodeOptions: { cwd: root }, throwOnError: true }); + durations.push(performance.now() - start); + } + + // From the 10 durations, calculate average, standard deviation, and max value + return calculateStat(durations); +} + +/** + * @param {Record<string, import('./_util.js').Stat>} result + */ +function printResult(result) { + return markdownTable( + [ + ['Command', 'Avg (ms)', 'Stdev (ms)', 'Max (ms)'], + ...Object.entries(result).map(([command, { avg, stdev, max }]) => [ + command, + avg.toFixed(2), + stdev.toFixed(2), + max.toFixed(2), + ]), + ], + { + align: ['l', 'r', 'r', 'r'], + }, + ); +} diff --git a/benchmark/bench/codspeed.bench.js b/benchmark/bench/codspeed.bench.js new file mode 100644 index 000000000..4073ebed8 --- /dev/null +++ b/benchmark/bench/codspeed.bench.js @@ -0,0 +1,48 @@ +import { fileURLToPath } from 'node:url'; +import { exec } from 'tinyexec'; +import { beforeAll, bench, describe } from 'vitest'; +import { astroBin, makeProject } from './_util.js'; +let streamingApp; +let nonStreamingApp; +beforeAll(async () => { + const render = await makeProject('render-bench'); + const root = fileURLToPath(render); + await exec(astroBin, ['build'], { + nodeOptions: { + cwd: root, + stdio: 'inherit', + }, + }); + const entry = new URL('./dist/server/entry.mjs', `file://${root}`); + const { manifest, createApp } = await import(entry); + streamingApp = createApp(manifest, true); + nonStreamingApp = createApp(manifest, false); +}, 900000); + +describe('Bench rendering', () => { + bench('Rendering: streaming [true], .astro file', async () => { + const request = new Request(new URL('http://exmpale.com/astro')); + await streamingApp.render(request); + }); + bench('Rendering: streaming [true], .md file', async () => { + const request = new Request(new URL('http://exmpale.com/md')); + await streamingApp.render(request); + }); + bench('Rendering: streaming [true], .mdx file', async () => { + const request = new Request(new URL('http://exmpale.com/mdx')); + await streamingApp.render(request); + }); + + bench('Rendering: streaming [false], .astro file', async () => { + const request = new Request(new URL('http://exmpale.com/astro')); + await nonStreamingApp.render(request); + }); + bench('Rendering: streaming [false], .md file', async () => { + const request = new Request(new URL('http://exmpale.com/md')); + await nonStreamingApp.render(request); + }); + bench('Rendering: streaming [false], .mdx file', async () => { + const request = new Request(new URL('http://exmpale.com/mdx')); + await nonStreamingApp.render(request); + }); +}); diff --git a/benchmark/bench/memory.js b/benchmark/bench/memory.js new file mode 100644 index 000000000..4f9153cc0 --- /dev/null +++ b/benchmark/bench/memory.js @@ -0,0 +1,61 @@ +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { markdownTable } from 'markdown-table'; +import { exec } from 'tinyexec'; +import { astroBin } from './_util.js'; + +/** @typedef {Record<string, import('../../packages/astro/src/core/config/timer').Stat>} AstroTimerStat */ + +/** Default project to run for this benchmark if not specified */ +export const defaultProject = 'memory-default'; + +/** + * @param {URL} projectDir + * @param {URL} outputFile + */ +export async function run(projectDir, outputFile) { + const root = fileURLToPath(projectDir); + const outputFilePath = fileURLToPath(outputFile); + + console.log('Building and benchmarking...'); + await exec('node', ['--expose-gc', '--max_old_space_size=10000', astroBin, 'build'], { + nodeOptions: { + cwd: root, + stdio: 'inherit', + env: { + ASTRO_TIMER_PATH: outputFilePath, + }, + }, + throwOnError: true, + }); + + console.log('Raw results written to', outputFilePath); + + console.log('Result preview:'); + console.log('='.repeat(10)); + console.log(`#### Memory\n\n`); + console.log(printResult(JSON.parse(await fs.readFile(outputFilePath, 'utf-8')))); + console.log('='.repeat(10)); + + console.log('Done!'); +} + +/** + * @param {AstroTimerStat} output + */ +function printResult(output) { + return markdownTable( + [ + ['', 'Elapsed time (s)', 'Memory used (MB)', 'Final memory (MB)'], + ...Object.entries(output).map(([name, stat]) => [ + name, + (stat.elapsedTime / 1000).toFixed(2), + (stat.heapUsedChange / 1024 / 1024).toFixed(2), + (stat.heapUsedTotal / 1024 / 1024).toFixed(2), + ]), + ], + { + align: ['l', 'r', 'r', 'r'], + }, + ); +} diff --git a/benchmark/bench/render.js b/benchmark/bench/render.js new file mode 100644 index 000000000..02f75a73b --- /dev/null +++ b/benchmark/bench/render.js @@ -0,0 +1,121 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { markdownTable } from 'markdown-table'; +import { waitUntilBusy } from 'port-authority'; +import { exec } from 'tinyexec'; +import { renderPages } from '../make-project/render-default.js'; +import { astroBin, calculateStat } from './_util.js'; + +const port = 4322; + +export const defaultProject = 'render-default'; + +/** + * @param {URL} projectDir + * @param {URL} outputFile + */ +export async function run(projectDir, outputFile) { + const root = fileURLToPath(projectDir); + + console.log('Building...'); + await exec(astroBin, ['build'], { + nodeOptions: { + cwd: root, + stdio: 'inherit', + }, + throwOnError: true, + }); + + console.log('Previewing...'); + const previewProcess = exec(astroBin, ['preview', '--port', port], { + nodeOptions: { + cwd: root, + stdio: 'inherit', + }, + throwOnError: true, + }); + + console.log('Waiting for server ready...'); + await waitUntilBusy(port, { timeout: 5000 }); + + console.log('Running benchmark...'); + const result = await benchmarkRenderTime(); + + console.log('Killing server...'); + if (!previewProcess.kill('SIGTERM')) { + console.warn('Failed to kill server process id:', previewProcess.pid); + } + + console.log('Writing results to', fileURLToPath(outputFile)); + await fs.writeFile(outputFile, JSON.stringify(result, null, 2)); + + console.log('Result preview:'); + console.log('='.repeat(10)); + console.log(`#### Render\n\n`); + console.log(printResult(result)); + console.log('='.repeat(10)); + + console.log('Done!'); +} + +export async function benchmarkRenderTime(portToListen = port) { + /** @type {Record<string, number[]>} */ + const result = {}; + for (const fileName of renderPages) { + // Render each file 100 times and push to an array + for (let i = 0; i < 100; i++) { + const pathname = '/' + fileName.slice(0, -path.extname(fileName).length); + const renderTime = await fetchRenderTime(`http://localhost:${portToListen}${pathname}`); + if (!result[pathname]) result[pathname] = []; + result[pathname].push(renderTime); + } + } + /** @type {Record<string, import('./_util.js').Stat>} */ + const processedResult = {}; + for (const [pathname, times] of Object.entries(result)) { + // From the 100 results, calculate average, standard deviation, and max value + processedResult[pathname] = calculateStat(times); + } + return processedResult; +} + +/** + * @param {Record<string, import('./_util.js').Stat>} result + */ +function printResult(result) { + return markdownTable( + [ + ['Page', 'Avg (ms)', 'Stdev (ms)', 'Max (ms)'], + ...Object.entries(result).map(([pathname, { avg, stdev, max }]) => [ + pathname, + avg.toFixed(2), + stdev.toFixed(2), + max.toFixed(2), + ]), + ], + { + align: ['l', 'r', 'r', 'r'], + }, + ); +} + +/** + * Simple fetch utility to get the render time sent by `@benchmark/timer` in plain text + * @param {string} url + * @returns {Promise<number>} + */ +function fetchRenderTime(url) { + return new Promise((resolve, reject) => { + const req = http.request(url, (res) => { + res.setEncoding('utf8'); + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('error', (e) => reject(e)); + res.on('end', () => resolve(+data)); + }); + req.on('error', (e) => reject(e)); + req.end(); + }); +} diff --git a/benchmark/bench/server-stress.js b/benchmark/bench/server-stress.js new file mode 100644 index 000000000..5bcaa6963 --- /dev/null +++ b/benchmark/bench/server-stress.js @@ -0,0 +1,115 @@ +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import autocannon from 'autocannon'; +import { markdownTable } from 'markdown-table'; +import { waitUntilBusy } from 'port-authority'; +import pb from 'pretty-bytes'; +import { exec } from 'tinyexec'; +import { astroBin } from './_util.js'; + +const port = 4321; + +export const defaultProject = 'server-stress-default'; + +/** + * @param {URL} projectDir + * @param {URL} outputFile + */ +export async function run(projectDir, outputFile) { + const root = fileURLToPath(projectDir); + + console.log('Building...'); + await exec(astroBin, ['build'], { + nodeOptions: { + cwd: root, + stdio: 'inherit', + }, + throwOnError: true, + }); + + console.log('Previewing...'); + const previewProcess = await exec(astroBin, ['preview', '--port', port], { + nodeOptions: { + cwd: root, + stdio: 'inherit', + }, + }); + + console.log('Waiting for server ready...'); + await waitUntilBusy(port, { timeout: 5000 }); + + console.log('Running benchmark...'); + const result = await benchmarkCannon(); + + console.log('Killing server...'); + if (!previewProcess.kill('SIGTERM')) { + console.warn('Failed to kill server process id:', previewProcess.pid); + } + + console.log('Writing results to', fileURLToPath(outputFile)); + await fs.writeFile(outputFile, JSON.stringify(result, null, 2)); + + console.log('Result preview:'); + console.log('='.repeat(10)); + console.log(`#### Server stress\n\n`); + console.log(printResult(result)); + console.log('='.repeat(10)); + + console.log('Done!'); +} + +/** + * @returns {Promise<import('autocannon').Result>} + */ +export async function benchmarkCannon() { + return new Promise((resolve, reject) => { + const instance = autocannon( + { + url: `http://localhost:${port}`, + connections: 100, + duration: 30, + pipelining: 10, + }, + (err, result) => { + if (err) { + reject(err); + } else { + // @ts-expect-error untyped but documented + instance.stop(); + resolve(result); + } + }, + ); + autocannon.track(instance, { renderResultsTable: false }); + }); +} + +/** + * @param {import('autocannon').Result} output + */ +function printResult(output) { + const { latency: l, requests: r, throughput: t } = output; + + const latencyTable = markdownTable( + [ + ['', 'Avg', 'Stdev', 'Max'], + ['Latency', `${l.average} ms`, `${l.stddev} ms`, `${l.max} ms`], + ], + { + align: ['l', 'r', 'r', 'r'], + }, + ); + + const reqAndBytesTable = markdownTable( + [ + ['', 'Avg', 'Stdev', 'Min', 'Total in 30s'], + ['Req/Sec', r.average, r.stddev, r.min, `${(r.total / 1000).toFixed(1)}k requests`], + ['Bytes/Sec', pb(t.average), pb(t.stddev), pb(t.min), `${pb(t.total)} read`], + ], + { + align: ['l', 'r', 'r', 'r', 'r'], + }, + ); + + return `${latencyTable}\n\n${reqAndBytesTable}`; +} diff --git a/benchmark/ci-helper.js b/benchmark/ci-helper.js new file mode 100644 index 000000000..2dbdf5acf --- /dev/null +++ b/benchmark/ci-helper.js @@ -0,0 +1,13 @@ +// This script helps extract the benchmark logs that are between the `==========` lines. +// They are a convention defined in the `./bench/_template.js` file, which are used to log +// out with the `!bench` command. See `/.github/workflows/benchmark.yml` to see how it's used. +const benchLogs = process.argv[2]; +const resultRegex = /==========(.*?)==========/gs; + +let processedLog = ''; +let m; +while ((m = resultRegex.exec(benchLogs))) { + processedLog += m[1] + '\n'; +} + +console.log(processedLog); diff --git a/benchmark/index.js b/benchmark/index.js new file mode 100755 index 000000000..0c62036d9 --- /dev/null +++ b/benchmark/index.js @@ -0,0 +1,72 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import mri from 'mri'; +import { makeProject } from './bench/_util.js'; + +const args = mri(process.argv.slice(2)); + +if (args.help || args.h) { + console.log(`\ +astro-benchmark <command> [options] + +Command + [empty] Run all benchmarks + memory Run build memory and speed test + render Run rendering speed test + server-stress Run server stress test + cli-startup Run CLI startup speed test + +Options + --project <project-name> Project to use for benchmark, see benchmark/make-project/ for available names + --output <output-file> Output file to write results to +`); + process.exit(0); +} + +const commandName = args._[0]; +const benchmarks = { + memory: () => import('./bench/memory.js'), + render: () => import('./bench/render.js'), + 'server-stress': () => import('./bench/server-stress.js'), + 'cli-startup': () => import('./bench/cli-startup.js'), +}; + +if (commandName && !(commandName in benchmarks)) { + console.error(`Invalid benchmark name: ${commandName}`); + process.exit(1); +} + +if (commandName) { + // Run single benchmark + const bench = benchmarks[commandName]; + const benchMod = await bench(); + const projectDir = await makeProject(args.project || benchMod.defaultProject); + const outputFile = await getOutputFile(commandName); + await benchMod.run(projectDir, outputFile); +} else { + // Run all benchmarks + for (const name in benchmarks) { + const bench = benchmarks[name]; + const benchMod = await bench(); + const projectDir = await makeProject(args.project || benchMod.defaultProject); + const outputFile = await getOutputFile(name); + await benchMod.run(projectDir, outputFile); + } +} + +/** + * @param {string} benchmarkName + */ +export async function getOutputFile(benchmarkName) { + let file; + if (args.output) { + file = pathToFileURL(path.resolve(args.output)); + } else { + file = new URL(`./results/${benchmarkName}-bench-${Date.now()}.json`, import.meta.url); + } + + // Prepare output file directory + await fs.mkdir(new URL('./', file), { recursive: true }); + return file; +} diff --git a/benchmark/make-project/README.md b/benchmark/make-project/README.md new file mode 100644 index 000000000..9d1a421c9 --- /dev/null +++ b/benchmark/make-project/README.md @@ -0,0 +1,7 @@ +# make-project + +This `make-project` folder contains different files to programmatically create a new Astro project. They are created inside the `projects` folder and are gitignored. These projects are used by benchmarks for testing. + +Each benchmark can specify the default project to run in its `defaultProject` export, but it can be overridden if `--project <project-name>` is passed through the CLI. + +You can duplicate `_template.js` to start a new project script. All shared utilities are kept in `_util.js`. diff --git a/benchmark/make-project/_template.js b/benchmark/make-project/_template.js new file mode 100644 index 000000000..a99c52428 --- /dev/null +++ b/benchmark/make-project/_template.js @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * Create a new project in the `projectDir` directory. Make sure to clean up the + * previous artifacts here before generating files. + * @param {URL} projectDir + */ +// biome-ignore lint/correctness/noUnusedVariables: parameters here are template placeholders +// biome-ignore lint/correctness/noUnusedFunctionParameters: (same as above) +export async function run(projectDir) {} diff --git a/benchmark/make-project/_util.js b/benchmark/make-project/_util.js new file mode 100644 index 000000000..65c91dbf3 --- /dev/null +++ b/benchmark/make-project/_util.js @@ -0,0 +1,12 @@ +export const loremIpsum = + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum."; + +export const loremIpsumHtml = loremIpsum + .replace(/Lorem/g, '<strong>Lorem</strong>') + .replace(/Ipsum/g, '<em>Ipsum</em>') + .replace(/dummy/g, '<span>dummy</span>'); + +export const loremIpsumMd = loremIpsum + .replace(/Lorem/g, '**Lorem**') + .replace(/Ipsum/g, '_Ipsum_') + .replace(/dummy/g, '`dummy`'); diff --git a/benchmark/make-project/image.jpg b/benchmark/make-project/image.jpg Binary files differnew file mode 100644 index 000000000..80b8ea67b --- /dev/null +++ b/benchmark/make-project/image.jpg diff --git a/benchmark/make-project/markdown-cc1.js b/benchmark/make-project/markdown-cc1.js new file mode 100644 index 000000000..1e3aaa517 --- /dev/null +++ b/benchmark/make-project/markdown-cc1.js @@ -0,0 +1,64 @@ +import fs from 'node:fs/promises'; +import { loremIpsumMd } from './_util.js'; + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.mkdir(new URL('./src/pages/blog', projectDir), { recursive: true }); + await fs.mkdir(new URL('./src/content/blog', projectDir), { recursive: true }); + await fs.copyFile( + new URL('./image.jpg', import.meta.url), + new URL('./src/image.jpg', projectDir), + ); + + const promises = []; + + for (let i = 0; i < 10000; i++) { + const content = `\ +# Article ${i} + +${loremIpsumMd} + + + + +`; + promises.push( + fs.writeFile(new URL(`./src/content/blog/article-${i}.md`, projectDir), content, 'utf-8'), + ); + } + + await fs.writeFile( + new URL(`./src/pages/blog/[...slug].astro`, projectDir), + `\ +--- +import { getCollection } from 'astro:content'; +export async function getStaticPaths() { + const blogEntries = await getCollection('blog'); + return blogEntries.map(entry => ({ + params: { slug: entry.slug }, props: { entry }, + })); +} +const { entry } = Astro.props; +const { Content } = await entry.render(); +--- +<h1>{entry.data.title}</h1> +<Content /> +`, + 'utf-8', + ); + + await Promise.all(promises); + + await fs.writeFile( + new URL('./astro.config.js', projectDir), + `\ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ +});`, + 'utf-8', + ); +} diff --git a/benchmark/make-project/markdown-cc2.js b/benchmark/make-project/markdown-cc2.js new file mode 100644 index 000000000..ba60813c0 --- /dev/null +++ b/benchmark/make-project/markdown-cc2.js @@ -0,0 +1,76 @@ +import fs from 'node:fs/promises'; +import { loremIpsumMd } from './_util.js'; + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.mkdir(new URL('./src/pages/blog', projectDir), { recursive: true }); + await fs.mkdir(new URL('./data/blog', projectDir), { recursive: true }); + await fs.mkdir(new URL('./src/content', projectDir), { recursive: true }); + await fs.copyFile(new URL('./image.jpg', import.meta.url), new URL('./image.jpg', projectDir)); + + const promises = []; + + for (let i = 0; i < 10000; i++) { + const content = `\ +# Article ${i} + +${loremIpsumMd} + + + +`; + promises.push( + fs.writeFile(new URL(`./data/blog/article-${i}.md`, projectDir), content, 'utf-8'), + ); + } + + await fs.writeFile( + new URL(`./src/content/config.ts`, projectDir), + /*ts */ ` + import { defineCollection, z } from 'astro:content'; + import { glob } from 'astro/loaders'; + + const blog = defineCollection({ + loader: glob({ pattern: '*', base: './data/blog' }), + }); + + export const collections = { blog } + + `, + ); + + await fs.writeFile( + new URL(`./src/pages/blog/[...slug].astro`, projectDir), + `\ +--- +import { getCollection, render } from 'astro:content'; +export async function getStaticPaths() { + const blogEntries = await getCollection('blog'); + return blogEntries.map(entry => ({ + params: { slug: entry.id }, props: { entry }, + })); +} +const { entry } = Astro.props; +const { Content } = await render(entry); + +--- +<h1>{entry.data.title}</h1> +<Content /> +`, + 'utf-8', + ); + + await Promise.all(promises); + + await fs.writeFile( + new URL('./astro.config.js', projectDir), + `\ +import { defineConfig } from 'astro/config'; + +export default defineConfig({});`, + 'utf-8', + ); +} diff --git a/benchmark/make-project/mdx-cc1.js b/benchmark/make-project/mdx-cc1.js new file mode 100644 index 000000000..a948ce194 --- /dev/null +++ b/benchmark/make-project/mdx-cc1.js @@ -0,0 +1,67 @@ +import fs from 'node:fs/promises'; +import { loremIpsumMd } from './_util.js'; + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.mkdir(new URL('./src/pages/blog', projectDir), { recursive: true }); + await fs.mkdir(new URL('./src/content/blog', projectDir), { recursive: true }); + await fs.copyFile( + new URL('./image.jpg', import.meta.url), + new URL('./src/image.jpg', projectDir), + ); + + const promises = []; + + for (let i = 0; i < 10000; i++) { + const content = `\ +# Article ${i} + +${loremIpsumMd} + + + + +`; + promises.push( + fs.writeFile(new URL(`./src/content/blog/article-${i}.mdx`, projectDir), content, 'utf-8'), + ); + } + + await fs.writeFile( + new URL(`./src/pages/blog/[...slug].astro`, projectDir), + `\ +--- +import { getCollection } from 'astro:content'; +export async function getStaticPaths() { + const blogEntries = await getCollection('blog'); + return blogEntries.map(entry => ({ + params: { slug: entry.slug }, props: { entry }, + })); +} +const { entry } = Astro.props; +const { Content } = await entry.render(); +--- +<h1>{entry.data.title}</h1> +<Content /> +`, + 'utf-8', + ); + + await Promise.all(promises); + + await fs.writeFile( + new URL('./astro.config.js', projectDir), + `\ +import { defineConfig } from 'astro/config'; + +import mdx from '@astrojs/mdx'; + +export default defineConfig({ + integrations: [mdx()], +});`, + 'utf-8', + ); +} diff --git a/benchmark/make-project/mdx-cc2.js b/benchmark/make-project/mdx-cc2.js new file mode 100644 index 000000000..f50b63c9e --- /dev/null +++ b/benchmark/make-project/mdx-cc2.js @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises'; +import { loremIpsumMd } from './_util.js'; + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.mkdir(new URL('./src/pages/blog', projectDir), { recursive: true }); + await fs.mkdir(new URL('./data/blog', projectDir), { recursive: true }); + await fs.mkdir(new URL('./src/content', projectDir), { recursive: true }); + await fs.copyFile(new URL('./image.jpg', import.meta.url), new URL('./image.jpg', projectDir)); + + const promises = []; + + for (let i = 0; i < 10000; i++) { + const content = `\ +# Article ${i} + +${loremIpsumMd} + + + +`; + promises.push( + fs.writeFile(new URL(`./data/blog/article-${i}.mdx`, projectDir), content, 'utf-8'), + ); + } + + await fs.writeFile( + new URL(`./src/content/config.ts`, projectDir), + /*ts */ ` + import { defineCollection, z } from 'astro:content'; + import { glob } from 'astro/loaders'; + + const blog = defineCollection({ + loader: glob({ pattern: '*', base: './data/blog' }), + }); + + export const collections = { blog } + + `, + ); + + await fs.writeFile( + new URL(`./src/pages/blog/[...slug].astro`, projectDir), + `\ +--- +import { getCollection, render } from 'astro:content'; +export async function getStaticPaths() { + const blogEntries = await getCollection('blog'); + return blogEntries.map(entry => ({ + params: { slug: entry.id }, props: { entry }, + })); +} +const { entry } = Astro.props; +const { Content } = await render(entry); + +--- +<h1>{entry.data.title}</h1> +<Content /> +`, + 'utf-8', + ); + + await Promise.all(promises); + + await fs.writeFile( + new URL('./astro.config.js', projectDir), + `\ +import { defineConfig } from 'astro/config'; + +import mdx from '@astrojs/mdx'; + +export default defineConfig({ + integrations: [mdx()], +});`, + 'utf-8', + ); +} diff --git a/benchmark/make-project/memory-default.js b/benchmark/make-project/memory-default.js new file mode 100644 index 000000000..1087c3d4a --- /dev/null +++ b/benchmark/make-project/memory-default.js @@ -0,0 +1,82 @@ +import fs from 'node:fs/promises'; +import { loremIpsum } from './_util.js'; + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.mkdir(new URL('./src/pages/blog', projectDir), { recursive: true }); + await fs.mkdir(new URL('./src/content/blog', projectDir), { recursive: true }); + + const promises = []; + + for (let i = 0; i < 100; i++) { + const content = `\ +--- +const i = ${i}; +--- + +<span>{i}</span> +`; + promises.push( + fs.writeFile(new URL(`./src/pages/page-${i}.astro`, projectDir), content, 'utf-8'), + ); + } + + for (let i = 0; i < 100; i++) { + const content = `\ +# Article ${i} + +${loremIpsum} +`; + promises.push( + fs.writeFile(new URL(`./src/content/blog/article-${i}.md`, projectDir), content, 'utf-8'), + ); + } + + for (let i = 0; i < 100; i++) { + const content = `\ +# Post ${i} + +${loremIpsum} +`; + promises.push( + fs.writeFile(new URL(`./src/content/blog/post-${i}.mdx`, projectDir), content, 'utf-8'), + ); + } + + await fs.writeFile( + new URL(`./src/pages/blog/[...slug].astro`, projectDir), + `\ +--- +import { getCollection } from 'astro:content'; +export async function getStaticPaths() { + const blogEntries = await getCollection('blog'); + return blogEntries.map(entry => ({ + params: { slug: entry.slug }, props: { entry }, + })); +} +const { entry } = Astro.props; +const { Content } = await entry.render(); +--- +<h1>{entry.data.title}</h1> +<Content /> +`, + 'utf-8', + ); + + await Promise.all(promises); + + await fs.writeFile( + new URL('./astro.config.js', projectDir), + `\ +import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; + +export default defineConfig({ + integrations: [mdx()], +});`, + 'utf-8', + ); +} diff --git a/benchmark/make-project/render-bench.js b/benchmark/make-project/render-bench.js new file mode 100644 index 000000000..e2964fbd2 --- /dev/null +++ b/benchmark/make-project/render-bench.js @@ -0,0 +1,132 @@ +import fs from 'node:fs/promises'; +import { loremIpsumHtml, loremIpsumMd } from './_util.js'; + +// Map of files to be generated and tested for rendering. +// Ideally each content should be similar for comparison. +const renderFiles = { + 'components/ListItem.astro': `\ +--- +const { className, item, attrs } = Astro.props; +const nested = item !== 0; +--- + <li class={className}> + <a + href={item} + aria-current={item === 0} + class:list={[{ large: !nested }, className]} + {...attrs} + > + <span>{item}</span> + </a> + </li> + `, + 'components/Sublist.astro': `\ +--- +import ListItem from '../components/ListItem.astro'; +const { items } = Astro.props; +const className = "text-red-500"; +const style = { color: "red" }; +--- +<ul style={style}> +{items.map((item) => ( + <ListItem className={className} item={item} attrs={{}} /> +))} +</ul> + `, + 'pages/astro.astro': `\ +--- +const className = "text-red-500"; +const style = { color: "red" }; +const items = Array.from({ length: 10000 }, (_, i) => ({i})); +--- +<html> + <head> + <title>My Site</title> + </head> + <body> + <h1 class={className + ' text-lg'}>List</h1> + <ul style={style}> + {items.map((item) => ( + <li class={className}> + <a + href={item.i} + aria-current={item.i === 0} + class:list={[{ large: item.i === 0 }, className]} + {...({})} + > + <span>{item.i}</span> + </a> + </li> + ))} + </ul> + ${Array.from({ length: 1000 }) + .map(() => `<p>${loremIpsumHtml}</p>`) + .join('\n')} + </body> +</html>`, + 'pages/md.md': `\ +# List + +${Array.from({ length: 1000 }, (_, i) => i) + .map((v) => `- ${v}`) + .join('\n')} + +${Array.from({ length: 1000 }) + .map(() => loremIpsumMd) + .join('\n\n')} +`, + 'pages/mdx.mdx': `\ +export const className = "text-red-500"; +export const style = { color: "red" }; +export const items = Array.from({ length: 1000 }, (_, i) => i); + +# List + +<ul style={style}> + {items.map((item) => ( + <li class={className}>{item}</li> + ))} +</ul> + +${Array.from({ length: 1000 }) + .map(() => loremIpsumMd) + .join('\n\n')} +`, +}; + +export const renderPages = []; +for (const file of Object.keys(renderFiles)) { + if (file.startsWith('pages/')) { + renderPages.push(file.replace('pages/', '')); + } +} + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.mkdir(new URL('./src/pages', projectDir), { recursive: true }); + await fs.mkdir(new URL('./src/components', projectDir), { recursive: true }); + + await Promise.all( + Object.entries(renderFiles).map(([name, content]) => { + return fs.writeFile(new URL(`./src/${name}`, projectDir), content, 'utf-8'); + }), + ); + + await fs.writeFile( + new URL('./astro.config.js', projectDir), + `\ +import { defineConfig } from 'astro/config'; +import adapter from '@benchmark/adapter'; +import mdx from '@astrojs/mdx'; + +export default defineConfig({ + integrations: [mdx()], + output: 'server', + adapter: adapter(), +});`, + 'utf-8', + ); +} diff --git a/benchmark/make-project/render-default.js b/benchmark/make-project/render-default.js new file mode 100644 index 000000000..7ea54b936 --- /dev/null +++ b/benchmark/make-project/render-default.js @@ -0,0 +1,132 @@ +import fs from 'node:fs/promises'; +import { loremIpsumHtml, loremIpsumMd } from './_util.js'; + +// Map of files to be generated and tested for rendering. +// Ideally each content should be similar for comparison. +const renderFiles = { + 'components/ListItem.astro': `\ +--- +const { className, item, attrs } = Astro.props; +const nested = item !== 0; +--- + <li class={className}> + <a + href={item} + aria-current={item === 0} + class:list={[{ large: !nested }, className]} + {...attrs} + > + <span>{item}</span> + </a> + </li> + `, + 'components/Sublist.astro': `\ +--- +import ListItem from '../components/ListItem.astro'; +const { items } = Astro.props; +const className = "text-red-500"; +const style = { color: "red" }; +--- +<ul style={style}> +{items.map((item) => ( + <ListItem className={className} item={item} attrs={{}} /> +))} +</ul> + `, + 'pages/astro.astro': `\ +--- +const className = "text-red-500"; +const style = { color: "red" }; +const items = Array.from({ length: 10000 }, (_, i) => ({i})); +--- +<html> + <head> + <title>My Site</title> + </head> + <body> + <h1 class={className + ' text-lg'}>List</h1> + <ul style={style}> + {items.map((item) => ( + <li class={className}> + <a + href={item.i} + aria-current={item.i === 0} + class:list={[{ large: item.i === 0 }, className]} + {...({})} + > + <span>{item.i}</span> + </a> + </li> + ))} + </ul> + ${Array.from({ length: 1000 }) + .map(() => `<p>${loremIpsumHtml}</p>`) + .join('\n')} + </body> +</html>`, + 'pages/md.md': `\ +# List + +${Array.from({ length: 1000 }, (_, i) => i) + .map((v) => `- ${v}`) + .join('\n')} + +${Array.from({ length: 1000 }) + .map(() => loremIpsumMd) + .join('\n\n')} +`, + 'pages/mdx.mdx': `\ +export const className = "text-red-500"; +export const style = { color: "red" }; +export const items = Array.from({ length: 1000 }, (_, i) => i); + +# List + +<ul style={style}> + {items.map((item) => ( + <li class={className}>{item}</li> + ))} +</ul> + +${Array.from({ length: 1000 }) + .map(() => loremIpsumMd) + .join('\n\n')} +`, +}; + +export const renderPages = []; +for (const file of Object.keys(renderFiles)) { + if (file.startsWith('pages/')) { + renderPages.push(file.replace('pages/', '')); + } +} + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.mkdir(new URL('./src/pages', projectDir), { recursive: true }); + await fs.mkdir(new URL('./src/components', projectDir), { recursive: true }); + + await Promise.all( + Object.entries(renderFiles).map(([name, content]) => { + return fs.writeFile(new URL(`./src/${name}`, projectDir), content, 'utf-8'); + }), + ); + + await fs.writeFile( + new URL('./astro.config.js', projectDir), + `\ +import { defineConfig } from 'astro/config'; +import timer from '@benchmark/timer'; +import mdx from '@astrojs/mdx'; + +export default defineConfig({ + integrations: [mdx()], + output: 'server', + adapter: timer(), +});`, + 'utf-8', + ); +} diff --git a/benchmark/make-project/server-stress-default.js b/benchmark/make-project/server-stress-default.js new file mode 100644 index 000000000..1724f8f82 --- /dev/null +++ b/benchmark/make-project/server-stress-default.js @@ -0,0 +1,62 @@ +import fs from 'node:fs/promises'; +import { loremIpsum } from './_util.js'; + +/** + * @param {URL} projectDir + */ +export async function run(projectDir) { + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.mkdir(new URL('./src/pages', projectDir), { recursive: true }); + await fs.mkdir(new URL('./src/components', projectDir), { recursive: true }); + + await fs.writeFile( + new URL('./src/pages/index.astro', projectDir), + `\ +--- +import Paragraph from '../components/Paragraph.astro' +const content = "${loremIpsum}" +--- + +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width" /> + <meta name="generator" content={Astro.generator} /> + <title>Astro</title> + </head> + <body> + <h1>Astro</h1> + <div> + ${Array.from({ length: 100 }) + .map(() => '<p>{content}</p>') + .join('\n')} + </div> + <div> + ${Array.from({ length: 50 }) + .map((_, i) => '<Paragraph num={' + i + '} str={content} />') + .join('\n')} + </div> + </body> +</html>`, + 'utf-8', + ); + + await fs.writeFile( + new URL('./src/components/Paragraph.astro', projectDir), + `<div>{Astro.props.num} {Astro.props.str}</div>`, + 'utf-8', + ); + + await fs.writeFile( + new URL('./astro.config.js', projectDir), + `\ +import { defineConfig } from 'astro/config'; +import nodejs from '@astrojs/node'; + +export default defineConfig({ + output: 'server', + adapter: nodejs({ mode: 'standalone' }), +});`, + 'utf-8', + ); +} diff --git a/benchmark/package.json b/benchmark/package.json new file mode 100644 index 000000000..5578960ff --- /dev/null +++ b/benchmark/package.json @@ -0,0 +1,30 @@ +{ + "name": "astro-benchmark", + "private": true, + "type": "module", + "version": "0.0.0", + "bin": { + "astro-benchmark": "./index.js" + }, + "scripts": { + "bench": "pnpm vitest bench --run" + }, + "dependencies": { + "@astrojs/mdx": "workspace:*", + "@astrojs/node": "workspace:*", + "@benchmark/timer": "workspace:*", + "@benchmark/adapter": "workspace:*", + "astro": "workspace:*", + "autocannon": "^7.15.0", + "markdown-table": "^3.0.4", + "mri": "^1.2.0", + "port-authority": "^2.0.1", + "pretty-bytes": "^6.1.1", + "sharp": "^0.33.3", + "tinyexec": "^0.3.2" + }, + "devDependencies": { + "@codspeed/vitest-plugin": "4.0.1", + "vitest": "^3.1.1" + } +} diff --git a/benchmark/packages/adapter/README.md b/benchmark/packages/adapter/README.md new file mode 100644 index 000000000..5b8e33ed4 --- /dev/null +++ b/benchmark/packages/adapter/README.md @@ -0,0 +1,3 @@ +# @benchmark/timer + +Like `@astrojs/node`, but returns the rendered time in milliseconds for the page instead of the page content itself. This is used for internal benchmarks only. diff --git a/benchmark/packages/adapter/package.json b/benchmark/packages/adapter/package.json new file mode 100644 index 000000000..2bdb73ce9 --- /dev/null +++ b/benchmark/packages/adapter/package.json @@ -0,0 +1,35 @@ +{ + "name": "@benchmark/adapter", + "description": "Bench adapter", + "private": true, + "version": "0.0.0", + "type": "module", + "types": "./dist/index.d.ts", + "author": "withastro", + "license": "MIT", + "keywords": [ + "withastro", + "astro-adapter" + ], + "exports": { + ".": "./dist/index.js", + "./server.js": "./dist/server.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "astro-scripts build \"src/**/*.ts\" && tsc", + "build:ci": "astro-scripts build \"src/**/*.ts\"", + "dev": "astro-scripts dev \"src/**/*.ts\"" + }, + "dependencies": { + "server-destroy": "^1.0.1" + }, + "peerDependencies": { + "astro": "workspace:*" + }, + "devDependencies": { + "@types/server-destroy": "^1.0.4", + "astro": "workspace:*", + "astro-scripts": "workspace:*" + } +} diff --git a/benchmark/packages/adapter/src/index.ts b/benchmark/packages/adapter/src/index.ts new file mode 100644 index 000000000..0fc6d67f9 --- /dev/null +++ b/benchmark/packages/adapter/src/index.ts @@ -0,0 +1,32 @@ +import type { AstroIntegration } from 'astro'; + +export default function createIntegration(): AstroIntegration { + return { + name: '@benchmark/timer', + hooks: { + 'astro:config:setup': ({ updateConfig }) => { + updateConfig({ + vite: { + ssr: { + noExternal: ['@benchmark/timer'], + }, + }, + }); + }, + 'astro:config:done': ({ setAdapter }) => { + setAdapter({ + name: '@benchmark/adapter', + serverEntrypoint: '@benchmark/adapter/server.js', + exports: ['manifest', 'createApp'], + supportedAstroFeatures: { + serverOutput: 'stable', + envGetSecret: 'experimental', + staticOutput: 'stable', + hybridOutput: 'stable', + i18nDomains: 'stable', + }, + }); + }, + }, + }; +} diff --git a/benchmark/packages/adapter/src/server.ts b/benchmark/packages/adapter/src/server.ts new file mode 100644 index 000000000..10e212adb --- /dev/null +++ b/benchmark/packages/adapter/src/server.ts @@ -0,0 +1,32 @@ +import * as fs from 'node:fs'; +import type { SSRManifest } from 'astro'; +import { App } from 'astro/app'; +import { applyPolyfills } from 'astro/app/node'; + +applyPolyfills(); + +class MyApp extends App { + #manifest: SSRManifest | undefined; + constructor(manifest: SSRManifest, streaming = false) { + super(manifest, streaming); + this.#manifest = manifest; + } + + async render(request: Request) { + const url = new URL(request.url); + if (this.#manifest?.assets.has(url.pathname)) { + const filePath = new URL('../../client/' + this.removeBase(url.pathname), import.meta.url); + const data = await fs.promises.readFile(filePath); + return new Response(data); + } + + return super.render(request); + } +} + +export function createExports(manifest: SSRManifest) { + return { + manifest, + createApp: (streaming: boolean) => new MyApp(manifest, streaming), + }; +} diff --git a/benchmark/packages/adapter/tsconfig.json b/benchmark/packages/adapter/tsconfig.json new file mode 100644 index 000000000..1504b4b6d --- /dev/null +++ b/benchmark/packages/adapter/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/benchmark/packages/timer/README.md b/benchmark/packages/timer/README.md new file mode 100644 index 000000000..5b8e33ed4 --- /dev/null +++ b/benchmark/packages/timer/README.md @@ -0,0 +1,3 @@ +# @benchmark/timer + +Like `@astrojs/node`, but returns the rendered time in milliseconds for the page instead of the page content itself. This is used for internal benchmarks only. diff --git a/benchmark/packages/timer/package.json b/benchmark/packages/timer/package.json new file mode 100644 index 000000000..7e3e2065b --- /dev/null +++ b/benchmark/packages/timer/package.json @@ -0,0 +1,36 @@ +{ + "name": "@benchmark/timer", + "description": "Preview server for benchmark", + "private": true, + "version": "0.0.0", + "type": "module", + "types": "./dist/index.d.ts", + "author": "withastro", + "license": "MIT", + "keywords": [ + "withastro", + "astro-adapter" + ], + "exports": { + ".": "./dist/index.js", + "./server.js": "./dist/server.js", + "./preview.js": "./dist/preview.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "astro-scripts build \"src/**/*.ts\" && tsc", + "build:ci": "astro-scripts build \"src/**/*.ts\"", + "dev": "astro-scripts dev \"src/**/*.ts\"" + }, + "dependencies": { + "server-destroy": "^1.0.1" + }, + "peerDependencies": { + "astro": "workspace:*" + }, + "devDependencies": { + "@types/server-destroy": "^1.0.4", + "astro": "workspace:*", + "astro-scripts": "workspace:*" + } +} diff --git a/benchmark/packages/timer/src/index.ts b/benchmark/packages/timer/src/index.ts new file mode 100644 index 000000000..f83a61c36 --- /dev/null +++ b/benchmark/packages/timer/src/index.ts @@ -0,0 +1,37 @@ +import type { AstroAdapter, AstroIntegration } from 'astro'; + +export function getAdapter(): AstroAdapter { + return { + name: '@benchmark/timer', + serverEntrypoint: '@benchmark/timer/server.js', + previewEntrypoint: '@benchmark/timer/preview.js', + exports: ['handler'], + supportedAstroFeatures: { + serverOutput: 'stable', + }, + }; +} + +export default function createIntegration(): AstroIntegration { + return { + name: '@benchmark/timer', + hooks: { + 'astro:config:setup': ({ updateConfig }) => { + updateConfig({ + vite: { + ssr: { + noExternal: ['@benchmark/timer'], + }, + }, + }); + }, + 'astro:config:done': ({ setAdapter, config }) => { + setAdapter(getAdapter()); + + if (config.output === 'static') { + console.warn(`[@benchmark/timer] \`output: "server"\` is required to use this adapter.`); + } + }, + }, + }; +} diff --git a/benchmark/packages/timer/src/preview.ts b/benchmark/packages/timer/src/preview.ts new file mode 100644 index 000000000..9659a26be --- /dev/null +++ b/benchmark/packages/timer/src/preview.ts @@ -0,0 +1,36 @@ +import { createServer } from 'node:http'; +import type { CreatePreviewServer } from 'astro'; +import enableDestroy from 'server-destroy'; + +const preview: CreatePreviewServer = async function ({ serverEntrypoint, host, port }) { + const ssrModule = await import(serverEntrypoint.toString()); + const ssrHandler = ssrModule.handler; + const server = createServer(ssrHandler); + server.listen(port, host); + enableDestroy(server); + + // biome-ignore lint/suspicious/noConsoleLog: allowed + console.log(`Preview server listening on http://${host}:${port}`); + + // Resolves once the server is closed + const closed = new Promise<void>((resolve, reject) => { + server.addListener('close', resolve); + server.addListener('error', reject); + }); + + return { + host, + port, + closed() { + return closed; + }, + server, + stop: async () => { + await new Promise((resolve, reject) => { + server.destroy((err) => (err ? reject(err) : resolve(undefined))); + }); + }, + }; +}; + +export { preview as default }; diff --git a/benchmark/packages/timer/src/server.ts b/benchmark/packages/timer/src/server.ts new file mode 100644 index 000000000..9905a627b --- /dev/null +++ b/benchmark/packages/timer/src/server.ts @@ -0,0 +1,18 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { SSRManifest } from 'astro'; +import { NodeApp, applyPolyfills } from 'astro/app/node'; + +applyPolyfills(); + +export function createExports(manifest: SSRManifest) { + const app = new NodeApp(manifest); + return { + handler: async (req: IncomingMessage, res: ServerResponse) => { + const start = performance.now(); + await app.render(req); + const end = performance.now(); + res.write(end - start + ''); + res.end(); + }, + }; +} diff --git a/benchmark/packages/timer/tsconfig.json b/benchmark/packages/timer/tsconfig.json new file mode 100644 index 000000000..1504b4b6d --- /dev/null +++ b/benchmark/packages/timer/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/benchmark/vitest.config.js b/benchmark/vitest.config.js new file mode 100644 index 000000000..b8b6e5e52 --- /dev/null +++ b/benchmark/vitest.config.js @@ -0,0 +1,7 @@ +import codspeedPlugin from '@codspeed/vitest-plugin'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: process.env.CODSPEED ? [codspeedPlugin()] : [], + include: ['./bench/codspeed.bench.js'], +}); |