aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Matthew Phillips <matthew@skypack.dev> 2024-02-14 10:14:05 -0500
committerGravatar GitHub <noreply@github.com> 2024-02-14 10:14:05 -0500
commitd469bebd7b45b060dc41d82ab1cf18ee6de7e051 (patch)
tree93335d8fbc46e1aa78c1f390d26bf4952e14ff15
parent8c14143d0635b2571686d1c9bdc4fb3cc859b659 (diff)
downloadastro-d469bebd7b45b060dc41d82ab1cf18ee6de7e051.tar.gz
astro-d469bebd7b45b060dc41d82ab1cf18ee6de7e051.tar.zst
astro-d469bebd7b45b060dc41d82ab1cf18ee6de7e051.zip
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>
-rw-r--r--.changeset/hip-cherries-behave.md7
-rw-r--r--benchmark/bench/render.js4
-rw-r--r--benchmark/make-project/render-default.js69
-rw-r--r--packages/astro/src/runtime/server/render/astro/render.ts123
-rw-r--r--packages/astro/src/runtime/server/render/page.ts12
-rw-r--r--packages/astro/src/runtime/server/render/util.ts24
6 files changed, 221 insertions, 18 deletions
diff --git a/.changeset/hip-cherries-behave.md b/.changeset/hip-cherries-behave.md
new file mode 100644
index 000000000..097e5b2f5
--- /dev/null
+++ b/.changeset/hip-cherries-behave.md
@@ -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.
diff --git a/benchmark/bench/render.js b/benchmark/bench/render.js
index ac733bdea..20c9abb0f 100644
--- a/benchmark/bench/render.js
+++ b/benchmark/bench/render.js
@@ -6,7 +6,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { waitUntilBusy } from 'port-authority';
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;
@@ -57,7 +57,7 @@ export async function run(projectDir, outputFile) {
async function benchmarkRenderTime() {
/** @type {Record<string, number[]>} */
const result = {};
- for (const fileName of Object.keys(renderFiles)) {
+ 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);
diff --git a/benchmark/make-project/render-default.js b/benchmark/make-project/render-default.js
index 3a01dcc47..36936513c 100644
--- a/benchmark/make-project/render-default.js
+++ b/benchmark/make-project/render-default.js
@@ -3,31 +3,68 @@ import { loremIpsumHtml, loremIpsumMd } from './_util.js';
// Map of files to be generated and tested for rendering.
// Ideally each content should be similar for comparison.
-export const renderFiles = {
- 'astro.astro': `\
+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" };
-const items = Array.from({ length: 1000 }, (_, i) => i);
---
-
+<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}>{item}</li>
- ))}
- </ul>
+ <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>`,
- 'md.md': `\
+ 'pages/md.md': `\
# List
${Array.from({ length: 1000 }, (_, i) => i)
@@ -38,7 +75,7 @@ ${Array.from({ length: 1000 })
.map(() => loremIpsumMd)
.join('\n\n')}
`,
- 'mdx.mdx': `\
+ 'pages/mdx.mdx': `\
export const className = "text-red-500";
export const style = { color: "red" };
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
*/
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/pages/${name}`, projectDir), content, 'utf-8');
+ return fs.writeFile(new URL(`./src/${name}`, projectDir), content, 'utf-8');
})
);
diff --git a/packages/astro/src/runtime/server/render/astro/render.ts b/packages/astro/src/runtime/server/render/astro/render.ts
index 3b7cdc052..3a4c03172 100644
--- a/packages/astro/src/runtime/server/render/astro/render.ts
+++ b/packages/astro/src/runtime/server/render/astro/render.ts
@@ -4,6 +4,9 @@ import { chunkToByteArray, chunkToString, encoder, type RenderDestination } from
import type { AstroComponentFactory } from './factory.js';
import { isHeadAndContent } from './head-and-content.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
export async function renderToString(
@@ -33,7 +36,7 @@ export async function renderToString(
// Automatic doctype insertion for pages
if (isPage && !renderedFirstPageChunk) {
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';
str += doctype;
}
@@ -84,7 +87,7 @@ export async function renderToReadableStream(
// Automatic doctype insertion for pages
if (isPage && !renderedFirstPageChunk) {
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';
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;
+ }
+ };
+}
diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts
index fbfe567a8..dd609f063 100644
--- a/packages/astro/src/runtime/server/render/page.ts
+++ b/packages/astro/src/runtime/server/render/page.ts
@@ -3,8 +3,9 @@ import { renderComponentToString, type NonAstroPageComponent } from './component
import type { AstroComponentFactory } from './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 { isNode } from './util.js';
export async function renderPage(
result: SSRResult,
@@ -47,7 +48,14 @@ export async function renderPage(
let body: BodyInit | Response;
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 {
body = await renderToString(result, componentFactory, props, children, true, route);
}
diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts
index 91883024e..749a38685 100644
--- a/packages/astro/src/runtime/server/render/util.ts
+++ b/packages/astro/src/runtime/server/render/util.ts
@@ -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
+ };
+}