mirror of
https://github.com/withastro/astro.git
synced 2024-12-16 21:46:22 -05:00
Improve Node.js performance using an AsyncIterable (#9614)
* Improve Node.js performance using an AsyncIterable * Oops * Get rid of extra abstraction * Update .changeset/hip-cherries-behave.md Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev> * Check if already resolved * Resolve on done * Get rid of unneeded "done" * Done when length is zero * Let errors resolve * Update packages/astro/src/runtime/server/render/astro/render.ts Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> * Move doctype to top-level * Document the new function * Update .changeset/hip-cherries-behave.md Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> * Update .changeset/hip-cherries-behave.md --------- Co-authored-by: Florian Lefebvre <contact@florian-lefebvre.dev> Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
This commit is contained in:
parent
8c14143d06
commit
d469bebd7b
6 changed files with 221 additions and 18 deletions
7
.changeset/hip-cherries-behave.md
Normal file
7
.changeset/hip-cherries-behave.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
"astro": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Improves Node.js streaming performance.
|
||||||
|
|
||||||
|
This uses an `AsyncIterable` instead of a `ReadableStream` to do streaming in Node.js. This is a non-standard enhancement by Node, which is done only in that environment.
|
|
@ -6,7 +6,7 @@ import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { waitUntilBusy } from 'port-authority';
|
import { waitUntilBusy } from 'port-authority';
|
||||||
import { calculateStat, astroBin } from './_util.js';
|
import { calculateStat, astroBin } from './_util.js';
|
||||||
import { renderFiles } from '../make-project/render-default.js';
|
import { renderPages } from '../make-project/render-default.js';
|
||||||
|
|
||||||
const port = 4322;
|
const port = 4322;
|
||||||
|
|
||||||
|
@ -57,7 +57,7 @@ export async function run(projectDir, outputFile) {
|
||||||
async function benchmarkRenderTime() {
|
async function benchmarkRenderTime() {
|
||||||
/** @type {Record<string, number[]>} */
|
/** @type {Record<string, number[]>} */
|
||||||
const result = {};
|
const result = {};
|
||||||
for (const fileName of Object.keys(renderFiles)) {
|
for (const fileName of renderPages) {
|
||||||
// Render each file 100 times and push to an array
|
// Render each file 100 times and push to an array
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
const pathname = '/' + fileName.slice(0, -path.extname(fileName).length);
|
const pathname = '/' + fileName.slice(0, -path.extname(fileName).length);
|
||||||
|
|
|
@ -3,31 +3,68 @@ import { loremIpsumHtml, loremIpsumMd } from './_util.js';
|
||||||
|
|
||||||
// Map of files to be generated and tested for rendering.
|
// Map of files to be generated and tested for rendering.
|
||||||
// Ideally each content should be similar for comparison.
|
// Ideally each content should be similar for comparison.
|
||||||
export const renderFiles = {
|
const renderFiles = {
|
||||||
'astro.astro': `\
|
'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 className = "text-red-500";
|
||||||
const style = { color: "red" };
|
const style = { color: "red" };
|
||||||
const items = Array.from({ length: 1000 }, (_, i) => i);
|
const items = Array.from({ length: 10000 }, (_, i) => ({i}));
|
||||||
---
|
---
|
||||||
|
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>My Site</title>
|
<title>My Site</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 class={className + ' text-lg'}>List</h1>
|
<h1 class={className + ' text-lg'}>List</h1>
|
||||||
<ul style={style}>
|
<ul style={style}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li class={className}>{item}</li>
|
<li class={className}>
|
||||||
))}
|
<a
|
||||||
</ul>
|
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 })
|
${Array.from({ length: 1000 })
|
||||||
.map(() => `<p>${loremIpsumHtml}</p>`)
|
.map(() => `<p>${loremIpsumHtml}</p>`)
|
||||||
.join('\n')}
|
.join('\n')}
|
||||||
</body>
|
</body>
|
||||||
</html>`,
|
</html>`,
|
||||||
'md.md': `\
|
'pages/md.md': `\
|
||||||
# List
|
# List
|
||||||
|
|
||||||
${Array.from({ length: 1000 }, (_, i) => i)
|
${Array.from({ length: 1000 }, (_, i) => i)
|
||||||
|
@ -38,7 +75,7 @@ ${Array.from({ length: 1000 })
|
||||||
.map(() => loremIpsumMd)
|
.map(() => loremIpsumMd)
|
||||||
.join('\n\n')}
|
.join('\n\n')}
|
||||||
`,
|
`,
|
||||||
'mdx.mdx': `\
|
'pages/mdx.mdx': `\
|
||||||
export const className = "text-red-500";
|
export const className = "text-red-500";
|
||||||
export const style = { color: "red" };
|
export const style = { color: "red" };
|
||||||
export const items = Array.from({ length: 1000 }, (_, i) => i);
|
export const items = Array.from({ length: 1000 }, (_, i) => i);
|
||||||
|
@ -57,16 +94,24 @@ ${Array.from({ length: 1000 })
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const renderPages = [];
|
||||||
|
for(const file of Object.keys(renderFiles)) {
|
||||||
|
if(file.startsWith('pages/')) {
|
||||||
|
renderPages.push(file.replace('pages/', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {URL} projectDir
|
* @param {URL} projectDir
|
||||||
*/
|
*/
|
||||||
export async function run(projectDir) {
|
export async function run(projectDir) {
|
||||||
await fs.rm(projectDir, { recursive: true, force: true });
|
await fs.rm(projectDir, { recursive: true, force: true });
|
||||||
await fs.mkdir(new URL('./src/pages', projectDir), { recursive: true });
|
await fs.mkdir(new URL('./src/pages', projectDir), { recursive: true });
|
||||||
|
await fs.mkdir(new URL('./src/components', projectDir), { recursive: true });
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.entries(renderFiles).map(([name, content]) => {
|
Object.entries(renderFiles).map(([name, content]) => {
|
||||||
return fs.writeFile(new URL(`./src/pages/${name}`, projectDir), content, 'utf-8');
|
return fs.writeFile(new URL(`./src/${name}`, projectDir), content, 'utf-8');
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,9 @@ import { chunkToByteArray, chunkToString, encoder, type RenderDestination } from
|
||||||
import type { AstroComponentFactory } from './factory.js';
|
import type { AstroComponentFactory } from './factory.js';
|
||||||
import { isHeadAndContent } from './head-and-content.js';
|
import { isHeadAndContent } from './head-and-content.js';
|
||||||
import { isRenderTemplateResult } from './render-template.js';
|
import { isRenderTemplateResult } from './render-template.js';
|
||||||
|
import { promiseWithResolvers } from '../util.js';
|
||||||
|
|
||||||
|
const DOCTYPE_EXP = /<!doctype html/i;
|
||||||
|
|
||||||
// Calls a component and renders it into a string of HTML
|
// Calls a component and renders it into a string of HTML
|
||||||
export async function renderToString(
|
export async function renderToString(
|
||||||
|
@ -33,7 +36,7 @@ export async function renderToString(
|
||||||
// Automatic doctype insertion for pages
|
// Automatic doctype insertion for pages
|
||||||
if (isPage && !renderedFirstPageChunk) {
|
if (isPage && !renderedFirstPageChunk) {
|
||||||
renderedFirstPageChunk = true;
|
renderedFirstPageChunk = true;
|
||||||
if (!result.partial && !/<!doctype html/i.test(String(chunk))) {
|
if (!result.partial && !DOCTYPE_EXP.test(String(chunk))) {
|
||||||
const doctype = result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n';
|
const doctype = result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n';
|
||||||
str += doctype;
|
str += doctype;
|
||||||
}
|
}
|
||||||
|
@ -84,7 +87,7 @@ export async function renderToReadableStream(
|
||||||
// Automatic doctype insertion for pages
|
// Automatic doctype insertion for pages
|
||||||
if (isPage && !renderedFirstPageChunk) {
|
if (isPage && !renderedFirstPageChunk) {
|
||||||
renderedFirstPageChunk = true;
|
renderedFirstPageChunk = true;
|
||||||
if (!result.partial && !/<!doctype html/i.test(String(chunk))) {
|
if (!result.partial && !DOCTYPE_EXP.test(String(chunk))) {
|
||||||
const doctype = result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n';
|
const doctype = result.compressHTML ? '<!DOCTYPE html>' : '<!DOCTYPE html>\n';
|
||||||
controller.enqueue(encoder.encode(doctype));
|
controller.enqueue(encoder.encode(doctype));
|
||||||
}
|
}
|
||||||
|
@ -165,3 +168,119 @@ async function bufferHeadContent(result: SSRResult) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renderToAsyncIterable(
|
||||||
|
result: SSRResult,
|
||||||
|
componentFactory: AstroComponentFactory,
|
||||||
|
props: any,
|
||||||
|
children: any,
|
||||||
|
isPage = false,
|
||||||
|
route?: RouteData
|
||||||
|
): Promise<AsyncIterable<Uint8Array> | Response> {
|
||||||
|
const templateResult = await callComponentAsTemplateResultOrResponse(
|
||||||
|
result,
|
||||||
|
componentFactory,
|
||||||
|
props,
|
||||||
|
children,
|
||||||
|
route
|
||||||
|
);
|
||||||
|
if (templateResult instanceof Response)
|
||||||
|
return templateResult;
|
||||||
|
let renderedFirstPageChunk = false;
|
||||||
|
if (isPage) {
|
||||||
|
await bufferHeadContent(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This implements the iterator protocol:
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols
|
||||||
|
// The `iterator` is passed to the Response as a stream-like thing.
|
||||||
|
// The `buffer` array acts like a buffer. During render the `destination` pushes
|
||||||
|
// chunks of Uint8Arrays into the buffer. The response calls `next()` and we combine
|
||||||
|
// all of the chunks into one Uint8Array and then empty it.
|
||||||
|
|
||||||
|
let error: Error | null = null;
|
||||||
|
// The `next` is an object `{ promise, resolve, reject }` that we use to wait
|
||||||
|
// for chunks to be pushed into the buffer.
|
||||||
|
let next = promiseWithResolvers<void>();
|
||||||
|
const buffer: Uint8Array[] = []; // []Uint8Array
|
||||||
|
|
||||||
|
const iterator = {
|
||||||
|
async next() {
|
||||||
|
await next.promise;
|
||||||
|
|
||||||
|
// If an error occurs during rendering, throw the error as we cannot proceed.
|
||||||
|
if(error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the total length of all arrays.
|
||||||
|
let length = 0;
|
||||||
|
for(let i = 0, len = buffer.length; i < len; i++) {
|
||||||
|
length += buffer[i].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new array with total length and merge all source arrays.
|
||||||
|
let mergedArray = new Uint8Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
for(let i = 0, len = buffer.length; i < len; i++) {
|
||||||
|
const item = buffer[i];
|
||||||
|
mergedArray.set(item, offset);
|
||||||
|
offset += item.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty the array. We do this so that we can reuse the same array.
|
||||||
|
buffer.length = 0;
|
||||||
|
|
||||||
|
const returnValue = {
|
||||||
|
// The iterator is done if there are no chunks to return.
|
||||||
|
done: length === 0,
|
||||||
|
value: mergedArray
|
||||||
|
};
|
||||||
|
|
||||||
|
return returnValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const destination: RenderDestination = {
|
||||||
|
write(chunk) {
|
||||||
|
if (isPage && !renderedFirstPageChunk) {
|
||||||
|
renderedFirstPageChunk = true;
|
||||||
|
if (!result.partial && !DOCTYPE_EXP.test(String(chunk))) {
|
||||||
|
const doctype = result.compressHTML ? "<!DOCTYPE html>" : "<!DOCTYPE html>\n";
|
||||||
|
buffer.push(encoder.encode(doctype));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chunk instanceof Response) {
|
||||||
|
throw new AstroError(AstroErrorData.ResponseSentError);
|
||||||
|
}
|
||||||
|
const bytes = chunkToByteArray(result, chunk);
|
||||||
|
// It might be possible that we rendered a chunk with no content, in which
|
||||||
|
// case we don't want to resolve the promise.
|
||||||
|
if(bytes.length > 0) {
|
||||||
|
// Push the chunks into the buffer and resolve the promise so that next()
|
||||||
|
// will run.
|
||||||
|
buffer.push(bytes);
|
||||||
|
next.resolve();
|
||||||
|
next = promiseWithResolvers<void>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPromise = templateResult.render(destination);
|
||||||
|
renderPromise.then(() => {
|
||||||
|
// Once rendering is complete, calling resolve() allows the iterator to finish running.
|
||||||
|
next.resolve();
|
||||||
|
}).catch(err => {
|
||||||
|
// If an error occurs, save it in the scope so that we throw it when next() is called.
|
||||||
|
error = err;
|
||||||
|
next.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// This is the Iterator protocol, an object with a `Symbol.asyncIterator`
|
||||||
|
// function that returns an object like `{ next(): Promise<{ done: boolean; value: any }> }`
|
||||||
|
return {
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return iterator;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -3,8 +3,9 @@ import { renderComponentToString, type NonAstroPageComponent } from './component
|
||||||
import type { AstroComponentFactory } from './index.js';
|
import type { AstroComponentFactory } from './index.js';
|
||||||
|
|
||||||
import { isAstroComponentFactory } from './astro/index.js';
|
import { isAstroComponentFactory } from './astro/index.js';
|
||||||
import { renderToReadableStream, renderToString } from './astro/render.js';
|
import { renderToReadableStream, renderToString, renderToAsyncIterable } from './astro/render.js';
|
||||||
import { encoder } from './common.js';
|
import { encoder } from './common.js';
|
||||||
|
import { isNode } from './util.js';
|
||||||
|
|
||||||
export async function renderPage(
|
export async function renderPage(
|
||||||
result: SSRResult,
|
result: SSRResult,
|
||||||
|
@ -47,7 +48,14 @@ export async function renderPage(
|
||||||
|
|
||||||
let body: BodyInit | Response;
|
let body: BodyInit | Response;
|
||||||
if (streaming) {
|
if (streaming) {
|
||||||
body = await renderToReadableStream(result, componentFactory, props, children, true, route);
|
if(isNode) {
|
||||||
|
const nodeBody = await renderToAsyncIterable(result, componentFactory, props, children, true, route);
|
||||||
|
// Node.js allows passing in an AsyncIterable to the Response constructor.
|
||||||
|
// This is non-standard so using `any` here to preserve types everywhere else.
|
||||||
|
body = nodeBody as any;
|
||||||
|
} else {
|
||||||
|
body = await renderToReadableStream(result, componentFactory, props, children, true, route);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
body = await renderToString(result, componentFactory, props, children, true, route);
|
body = await renderToString(result, componentFactory, props, children, true, route);
|
||||||
}
|
}
|
||||||
|
|
|
@ -196,3 +196,27 @@ export function renderToBufferDestination(bufferRenderFunction: RenderFunction):
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isNode = typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]';
|
||||||
|
|
||||||
|
// We can get rid of this when Promise.withResolvers() is ready
|
||||||
|
export type PromiseWithResolvers<T> = {
|
||||||
|
promise: Promise<T>
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (reason?: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is an implementation of Promise.withResolvers(), which we can't yet rely on.
|
||||||
|
// We can remove this once the native function is available in Node.js
|
||||||
|
export function promiseWithResolvers<T = any>(): PromiseWithResolvers<T> {
|
||||||
|
let resolve: any, reject: any;
|
||||||
|
const promise = new Promise<T>((_resolve, _reject) => {
|
||||||
|
resolve = _resolve;
|
||||||
|
reject = _reject;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
resolve,
|
||||||
|
reject
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue