From d469bebd7b45b060dc41d82ab1cf18ee6de7e051 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Wed, 14 Feb 2024 10:14:05 -0500 Subject: [PATCH] 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 * 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 * Move doctype to top-level * Document the new function * Update .changeset/hip-cherries-behave.md Co-authored-by: Emanuele Stoppa * Update .changeset/hip-cherries-behave.md --------- Co-authored-by: Florian Lefebvre Co-authored-by: Emanuele Stoppa --- .changeset/hip-cherries-behave.md | 7 + benchmark/bench/render.js | 4 +- benchmark/make-project/render-default.js | 69 ++++++++-- .../src/runtime/server/render/astro/render.ts | 123 +++++++++++++++++- .../astro/src/runtime/server/render/page.ts | 12 +- .../astro/src/runtime/server/render/util.ts | 24 ++++ 6 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 .changeset/hip-cherries-behave.md diff --git a/.changeset/hip-cherries-behave.md b/.changeset/hip-cherries-behave.md new file mode 100644 index 0000000000..097e5b2f5f --- /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 ac733bdeaa..20c9abb0f3 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} */ 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 3a01dcc479..36936513c3 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; +--- +
  • + + {item} + +
  • + `, + 'components/Sublist.astro': `\ +--- +import ListItem from '../components/ListItem.astro'; +const { items } = Astro.props; +const className = "text-red-500"; +const style = { color: "red" }; +--- +
      +{items.map((item) => ( + +))} +
    + `, + 'pages/astro.astro': `\ --- const className = "text-red-500"; const style = { color: "red" }; -const items = Array.from({ length: 1000 }, (_, i) => i); +const items = Array.from({ length: 10000 }, (_, i) => ({i})); --- - My Site

    List

    -
      - {items.map((item) => ( -
    • {item}
    • - ))} -
    + ${Array.from({ length: 1000 }) .map(() => `

    ${loremIpsumHtml}

    `) .join('\n')} `, - '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 3b7cdc0526..3a4c03172e 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 = /' : '\n'; str += doctype; } @@ -84,7 +87,7 @@ export async function renderToReadableStream( // Automatic doctype insertion for pages if (isPage && !renderedFirstPageChunk) { renderedFirstPageChunk = true; - if (!result.partial && !/' : '\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 | 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(); + 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 ? "" : "\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(); + } + } + }; + + 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 fbfe567a83..dd609f0638 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 91883024e9..749a38685e 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 = { + promise: Promise + 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(): PromiseWithResolvers { + let resolve: any, reject: any; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + return { + promise, + resolve, + reject + }; +}