Skip to content

Commit

Permalink
Improve Node.js performance using an AsyncIterable (#9614)
Browse files Browse the repository at this point in the history
* Improve Node.js performance using an AsyncIterable

* Oops

* Get rid of extra abstraction

* Update .changeset/hip-cherries-behave.md

Co-authored-by: Florian Lefebvre <[email protected]>

* 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 <[email protected]>

* Move doctype to top-level

* Document the new function

* Update .changeset/hip-cherries-behave.md

Co-authored-by: Emanuele Stoppa <[email protected]>

* Update .changeset/hip-cherries-behave.md

---------

Co-authored-by: Florian Lefebvre <[email protected]>
Co-authored-by: Emanuele Stoppa <[email protected]>
  • Loading branch information
3 people authored Feb 14, 2024
1 parent 8c14143 commit d469beb
Show file tree
Hide file tree
Showing 6 changed files with 221 additions and 18 deletions.
7 changes: 7 additions & 0 deletions .changeset/hip-cherries-behave.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions benchmark/bench/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
69 changes: 57 additions & 12 deletions benchmark/make-project/render-default.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand All @@ -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');
})
);

Expand Down
123 changes: 121 additions & 2 deletions packages/astro/src/runtime/server/render/astro/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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;
}
};
}
12 changes: 10 additions & 2 deletions packages/astro/src/runtime/server/render/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
24 changes: 24 additions & 0 deletions packages/astro/src/runtime/server/render/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

0 comments on commit d469beb

Please sign in to comment.