diff --git a/.changeset/funny-pianos-mix.md b/.changeset/funny-pianos-mix.md new file mode 100644 index 000000000..249ba4dea --- /dev/null +++ b/.changeset/funny-pianos-mix.md @@ -0,0 +1,40 @@ +--- +'astro': patch +--- + +Support for streaming responses + +Astro supports streaming in its templates. Any time Astro encounters an async boundary it will stream out HTML that occurs before it. For example: + +```astro +--- +import LoadTodos from '../components/LoadTodos.astro'; +--- + + +App + + + + + +``` + +In this arbtrary example Astro will streaming out the `` section and everything else until it encounters `` and then stop. LoadTodos, which is also an Astro component will stream its contents as well; stopping and waiting at any other asynchronous components. + +As part of this Astro also now supports async iterables within its templates. This means you can do this: + +```astro + +``` + +Which will stream out `
  • `s one at a time, waiting a second between each. diff --git a/packages/astro/package.json b/packages/astro/package.json index 4b9e34174..b1a02bcc6 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -105,7 +105,6 @@ "gray-matter": "^4.0.3", "html-entities": "^2.3.3", "html-escaper": "^3.0.3", - "htmlparser2": "^7.2.0", "kleur": "^4.1.4", "magic-string": "^0.25.9", "micromorph": "^0.1.2", diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 36fb01ff2..40842a7f9 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -94,7 +94,7 @@ export class App { } } - const result = await render({ + const response = await render({ links, logging: this.#logging, markdown: manifest.markdown, @@ -119,17 +119,7 @@ export class App { request, }); - if (result.type === 'response') { - return result.response; - } - - let html = result.html; - let init = result.response; - let headers = init.headers as Headers; - let bytes = this.#encoder.encode(html); - headers.set('Content-Type', 'text/html'); - headers.set('Content-Length', bytes.byteLength.toString()); - return new Response(bytes, init); + return response; } async #callEndpoint( diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 05d1bcc55..bfc95cb8e 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -251,13 +251,14 @@ async function generatePath( } body = result.body; } else { - const result = await render(options); + const response = await render(options); // If there's a redirect or something, just do nothing. - if (result.type !== 'html') { + if (response.status !== 200 || !response.body) { return; } - body = result.html; + + body = await response.text(); } const outFolder = getOutFolder(astroConfig, pathname, pageData.route.type); diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index b718b4d28..c9c46fda1 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -85,9 +85,7 @@ export interface RenderOptions { export async function render( opts: RenderOptions -): Promise< - { type: 'html'; html: string; response: ResponseInit } | { type: 'response'; response: Response } -> { +): Promise { const { links, styles, @@ -144,32 +142,11 @@ export async function render( ssr, }); - let page: Awaited>; if (!Component.isAstroComponentFactory) { const props: Record = { ...(pageProps ?? {}), 'server:root': true }; const html = await renderComponent(result, Component.name, Component, props, null); - page = { - type: 'html', - html: html.toString(), - }; + return new Response(html.toString(), result.response); } else { - page = await renderPage(result, Component, pageProps, null); + return await renderPage(result, Component, pageProps, null); } - - if (page.type === 'response') { - return page; - } - - let html = page.html; - - // inject if missing (TODO: is a more robust check needed for comments, etc.?) - if (!/\n' + html; - } - - return { - type: 'html', - html, - response: result.response, - }; } diff --git a/packages/astro/src/core/render/dev/html.ts b/packages/astro/src/core/render/dev/html.ts deleted file mode 100644 index 065b07cf0..000000000 --- a/packages/astro/src/core/render/dev/html.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type * as vite from 'vite'; - -import htmlparser2 from 'htmlparser2'; - -/** Inject tags into HTML (note: for best performance, group as many tags as possible into as few calls as you can) */ -export function injectTags(html: string, tags: vite.HtmlTagDescriptor[]): string { - let output = html; - if (!tags.length) return output; - - const pos = { 'head-prepend': -1, head: -1, 'body-prepend': -1, body: -1 }; - - // parse html - const parser = new htmlparser2.Parser({ - onopentag(tagname) { - if (tagname === 'head') pos['head-prepend'] = parser.endIndex + 1; - if (tagname === 'body') pos['body-prepend'] = parser.endIndex + 1; - }, - onclosetag(tagname) { - if (tagname === 'head') pos['head'] = parser.startIndex; - if (tagname === 'body') pos['body'] = parser.startIndex; - }, - }); - parser.write(html); - parser.end(); - - // inject - const lastToFirst = Object.entries(pos).sort((a, b) => b[1] - a[1]); - lastToFirst.forEach(([name, i]) => { - if (i === -1) { - // if page didn’t generate or , guess - if (name === 'head-prepend' || name === 'head') i = 0; - if (name === 'body-prepend' || name === 'body') i = html.length; - } - let selected = tags.filter(({ injectTo }) => { - if (name === 'head-prepend' && !injectTo) { - return true; // "head-prepend" is the default - } else { - return injectTo === name; - } - }); - if (!selected.length) return; - output = output.substring(0, i) + serializeTags(selected) + html.substring(i); - }); - - return output; -} - -type Resource = Record; - -/** Collect resources (scans final, rendered HTML so expressions have been applied) */ -export function collectResources(html: string): Resource[] { - let resources: Resource[] = []; - const parser = new htmlparser2.Parser({ - // tags are self-closing, so only use onopentag (avoid onattribute or onclosetag) - onopentag(tagname, attrs) { - if (tagname === 'link') resources.push(attrs); - }, - }); - parser.write(html); - parser.end(); - return resources; -} - -// ------------------------------------------------------------------------------- -// Everything below © Vite. Rather than invent our own tag creating API, we borrow -// Vite’s `transformIndexHtml()` API for ease-of-use and consistency. But we need -// to borrow a few private methods in Vite to make that available here. -// https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/html.ts -// -// See LICENSE for more info. -// ------------------------------------------------------------------------------- - -const unaryTags = new Set(['link', 'meta', 'base']); - -function serializeTag({ tag, attrs, children }: vite.HtmlTagDescriptor, indent = ''): string { - if (unaryTags.has(tag)) { - return `<${tag}${serializeAttrs(attrs)}>`; - } else { - return `<${tag}${serializeAttrs(attrs)}>${serializeTags( - children, - incrementIndent(indent) - )}`; - } -} - -function serializeTags(tags: vite.HtmlTagDescriptor['children'], indent = ''): string { - if (typeof tags === 'string') { - return tags; - } else if (tags && tags.length) { - return tags.map((tag) => `${indent}${serializeTag(tag, indent)}\n`).join(''); - } - return ''; -} - -function serializeAttrs(attrs: vite.HtmlTagDescriptor['attrs']): string { - let res = ''; - for (const key in attrs) { - if (typeof attrs[key] === 'boolean') { - res += attrs[key] ? ` ${key}` : ``; - } else { - res += ` ${key}=${JSON.stringify(attrs[key])}`; - } - } - return res; -} - -function incrementIndent(indent = '') { - return `${indent}${indent[0] === '\t' ? '\t' : ' '}`; -} diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index 86e4840f8..be1a7b20b 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from 'url'; -import type { HtmlTagDescriptor, ViteDevServer } from 'vite'; +import type { ViteDevServer } from 'vite'; import type { AstroConfig, AstroRenderer, @@ -17,7 +17,6 @@ import { RouteCache } from '../route-cache.js'; import { createModuleScriptElementWithSrcSet } from '../ssr-element.js'; import { collectMdMetadata } from '../util.js'; import { getStylesForURL } from './css.js'; -import { injectTags } from './html.js'; import { resolveClientDevPath } from './resolve.js'; export interface SSROptions { @@ -45,10 +44,6 @@ export interface SSROptions { export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance]; -export type RenderResponse = - | { type: 'html'; html: string; response: ResponseInit } - | { type: 'response'; response: Response }; - const svelteStylesRE = /svelte\?svelte&type=style/; async function loadRenderer( @@ -99,7 +94,7 @@ export async function render( renderers: SSRLoadedRenderer[], mod: ComponentInstance, ssrOpts: SSROptions -): Promise { +): Promise { const { astroConfig, filePath, @@ -167,7 +162,7 @@ export async function render( }); }); - let content = await coreRender({ + let response = await coreRender({ links, styles, logging, @@ -191,32 +186,13 @@ export async function render( ssr: isBuildingToSSR(astroConfig), }); - if (route?.type === 'endpoint' || content.type === 'response') { - return content; - } - - // inject tags - const tags: HtmlTagDescriptor[] = []; - - // add injected tags - let html = injectTags(content.html, tags); - - // inject if missing (TODO: is a more robust check needed for comments, etc.?) - if (!/\n' + content; - } - - return { - type: 'html', - html, - response: content.response, - }; + return response; } export async function ssr( preloadedComponent: ComponentPreload, ssrOpts: SSROptions -): Promise { +): Promise { const [renderers, mod] = preloadedComponent; return await render(renderers, mod, ssrOpts); // NOTE: without "await", errors won’t get caught below } diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 457efe44a..154fb797b 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -113,10 +113,12 @@ export function createResult(args: CreateResultArgs): SSRResult { const paginated = isPaginatedRoute(pageProps); const url = new URL(request.url); const canonicalURL = createCanonicalURL('.' + pathname, site ?? url.origin, paginated); + const headers = new Headers(); + headers.set('Transfer-Encoding', 'chunked'); const response: ResponseInit = { status: 200, statusText: 'OK', - headers: new Headers(), + headers, }; // Make headers be read-only diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 8c58711c5..92cb5b4c8 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -11,6 +11,7 @@ import type { import { escapeHTML, HTMLString, markHTMLString } from './escape.js'; import { extractDirectives, generateHydrateScript } from './hydration.js'; +import { createResponse } from './response.js'; import { determineIfNeedsHydrationScript, determinesIfNeedsDirectiveScript, @@ -40,19 +41,21 @@ const svgEnumAttributes = /^(autoReverse|externalResourcesRequired|focusable|pre // INVESTIGATE: Can we have more specific types both for the argument and output? // If these are intentional, add comments that these are intention and why. // Or maybe type UserValue = any; ? -async function _render(child: any): Promise { +async function * _render(child: any): AsyncIterable { child = await child; if (child instanceof HTMLString) { - return child; + yield child; } else if (Array.isArray(child)) { - return markHTMLString((await Promise.all(child.map((value) => _render(value)))).join('')); + for(const value of child) { + yield markHTMLString(await _render(value)); + } } else if (typeof child === 'function') { // Special: If a child is a function, call it automatically. // This lets you do {() => ...} without the extra boilerplate // of wrapping it in a function and calling it. - return _render(child()); + yield * _render(child()); } else if (typeof child === 'string') { - return markHTMLString(escapeHTML(child)); + yield markHTMLString(escapeHTML(child)); } else if (!child && child !== 0) { // do nothing, safe to ignore falsey values. } @@ -62,9 +65,11 @@ async function _render(child: any): Promise { child instanceof AstroComponent || Object.prototype.toString.call(child) === '[object AstroComponent]' ) { - return markHTMLString(await renderAstroComponent(child)); + yield * renderAstroComponent(child); + } else if(typeof child === 'object' && Symbol.asyncIterator in child) { + yield * child; } else { - return child; + yield child; } } @@ -83,7 +88,7 @@ export class AstroComponent { return 'AstroComponent'; } - *[Symbol.iterator]() { + async *[Symbol.asyncIterator]() { const { htmlParts, expressions } = this; for (let i = 0; i < htmlParts.length; i++) { @@ -91,7 +96,7 @@ export class AstroComponent { const expression = expressions[i]; yield markHTMLString(html); - yield _render(expression); + yield * _render(expression); } } } @@ -120,9 +125,14 @@ export function createComponent(cb: AstroComponentFactory) { return cb; } -export async function renderSlot(_result: any, slotted: string, fallback?: any) { +export async function renderSlot(_result: any, slotted: string, fallback?: any): Promise { if (slotted) { - return await _render(slotted); + let iterator = _render(slotted); + let content = ''; + for await(const chunk of iterator) { + content += chunk; + } + return markHTMLString(content); } return fallback; } @@ -157,7 +167,7 @@ export async function renderComponent( Component: unknown, _props: Record, slots: any = {} -) { +): Promise> { Component = await Component; if (Component === Fragment) { const children = await renderSlot(result, slots?.default); @@ -168,8 +178,7 @@ export async function renderComponent( } if (Component && (Component as any).isAstroComponentFactory) { - const output = await renderToString(result, Component as any, _props, slots); - return markHTMLString(output); + return renderToIterable(result, Component as any, _props, slots); } if (!Component && !_props['client:only']) { @@ -317,13 +326,17 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr // as a string and the user is responsible for adding a script tag for the component definition. if (!html && typeof Component === 'string') { const childSlots = Object.values(children).join(''); - html = await renderAstroComponent( + const iterable = renderAstroComponent( await render`<${Component}${internalSpreadAttributes(props)}${markHTMLString( childSlots === '' && voidElementNames.test(Component) ? `/>` : `>${childSlots}` )}` ); + html = ''; + for await(const chunk of iterable) { + html += chunk; + } } if (!hydration) { @@ -597,45 +610,72 @@ export async function renderToString( children: any ): Promise { const Component = await componentFactory(result, props, children); + if (!isAstroComponent(Component)) { const response: Response = Component; throw response; } - let template = await renderAstroComponent(Component); - return template; + let html = ''; + for await(const chunk of renderAstroComponent(Component)) { + html += chunk; + } + return html; } +export async function renderToIterable( + result: SSRResult, + componentFactory: AstroComponentFactory, + props: any, + children: any +): Promise> { + const Component = await componentFactory(result, props, children); + + if (!isAstroComponent(Component)) { + console.warn(`Returning a Response is only supported inside of page components. Consider refactoring this logic into something like a function that can be used in the page.`); + const response: Response = Component; + throw response; + } + + return renderAstroComponent(Component); +} + +const encoder = new TextEncoder(); + export async function renderPage( result: SSRResult, componentFactory: AstroComponentFactory, props: any, children: any -): Promise<{ type: 'html'; html: string } | { type: 'response'; response: Response }> { - try { - const response = await componentFactory(result, props, children); +): Promise { + const factoryReturnValue = await componentFactory(result, props, children); - if (isAstroComponent(response)) { - let html = await renderAstroComponent(response); - return { - type: 'html', - html, - }; - } else { - return { - type: 'response', - response, - }; - } - } catch (err) { - if (err instanceof Response) { - return { - type: 'response', - response: err, - }; - } else { - throw err; - } + if (isAstroComponent(factoryReturnValue)) { + let iterable = renderAstroComponent(factoryReturnValue); + let stream = new ReadableStream({ + start(controller) { + async function read() { + let i = 0; + for await(const chunk of iterable) { + let html = chunk.toString(); + if(i === 0) { + if (!/\n')); + } + } + controller.enqueue(encoder.encode(html)); + i++; + } + controller.close(); + } + read(); + } + }); + let init = result.response; + let response = createResponse(stream, init); + return response; + } else { + return factoryReturnValue; } } @@ -676,16 +716,14 @@ export function maybeRenderHead(result: SSRResult): string | Promise { return renderHead(result); } -export async function renderAstroComponent(component: InstanceType) { - let template = []; - +export async function * renderAstroComponent(component: InstanceType): AsyncIterable { for await (const value of component) { if (value || value === 0) { - template.push(value); + for await(const chunk of _render(value)) { + yield markHTMLString(chunk); + } } } - - return markHTMLString(await _render(template)); } function componentIsHTMLElement(Component: unknown) { diff --git a/packages/astro/src/runtime/server/response.ts b/packages/astro/src/runtime/server/response.ts new file mode 100644 index 000000000..d0bf8fefd --- /dev/null +++ b/packages/astro/src/runtime/server/response.ts @@ -0,0 +1,71 @@ + +const isNodeJS = typeof process === 'object' && Object.prototype.toString.call(process) === '[object process]'; + +let StreamingCompatibleResponse: typeof Response | undefined; + +function createResponseClass() { + StreamingCompatibleResponse = class extends Response { + #isStream: boolean; + #body: any; + constructor(body?: BodyInit | null, init?: ResponseInit) { + let isStream = body instanceof ReadableStream; + super(isStream ? null : body, init); + this.#isStream = isStream; + this.#body = body; + } + + get body() { + return this.#body; + } + + async text(): Promise { + if(this.#isStream && isNodeJS) { + let decoder = new TextDecoder(); + let body = this.#body as ReadableStream; + let reader = body.getReader(); + let buffer: number[] = []; + while(true) { + let r = await reader.read(); + if(r.value) { + buffer.push(...r.value); + } + if(r.done) { + break; + } + } + return decoder.decode(Uint8Array.from(buffer)); + } + return super.text(); + } + + async arrayBuffer(): Promise { + if(this.#isStream && isNodeJS) { + let body = this.#body as ReadableStream; + let reader = body.getReader(); + let chunks: number[] = []; + while(true) { + let r = await reader.read(); + if(r.value) { + chunks.push(...r.value); + } + if(r.done) { + break; + } + } + return Uint8Array.from(chunks); + } + return super.arrayBuffer(); + } + } + + return StreamingCompatibleResponse; +} + +type CreateResponseFn = (body?: BodyInit | null, init?: ResponseInit) => Response; + +export const createResponse: CreateResponseFn = isNodeJS ? (body, init) => { + if(typeof StreamingCompatibleResponse === 'undefined') { + return new (createResponseClass())(body, init); + } + return new StreamingCompatibleResponse(body, init); +} : (body, init) => new Response(body, init); diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts index d3ad53eff..a26e66a98 100644 --- a/packages/astro/src/vite-plugin-astro-server/index.ts +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -1,15 +1,16 @@ import type http from 'http'; -import { Readable } from 'stream'; -import stripAnsi from 'strip-ansi'; import type * as vite from 'vite'; import type { AstroConfig, ManifestData } from '../@types/astro'; +import type { SSROptions } from '../core/render/dev/index'; + +import { Readable } from 'stream'; +import stripAnsi from 'strip-ansi'; import { call as callEndpoint } from '../core/endpoint/dev/index.js'; import { fixViteErrorMessage } from '../core/errors.js'; import { error, info, LogOptions, warn } from '../core/logger/core.js'; import * as msg from '../core/messages.js'; import { appendForwardSlash } from '../core/path.js'; import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/core.js'; -import type { RenderResponse, SSROptions } from '../core/render/dev/index'; import { preload, ssr } from '../core/render/dev/index.js'; import { RouteCache } from '../core/render/route-cache.js'; import { createRequest } from '../core/request.js'; @@ -75,7 +76,12 @@ async function writeWebResponse(res: http.ServerResponse, webResponse: Response) res.writeHead(status, _headers); if (body) { - if (body instanceof Readable) { + if(Symbol.for('astro.responseBody') in webResponse) { + let stream = (webResponse as any)[Symbol.for('astro.responseBody')]; + for await(const chunk of stream) { + res.write(chunk.toString()); + } + } else if (body instanceof Readable) { body.pipe(res); return; } else { @@ -93,23 +99,10 @@ async function writeWebResponse(res: http.ServerResponse, webResponse: Response) } async function writeSSRResult( - result: RenderResponse, - res: http.ServerResponse, - statusCode: 200 | 404 + webResponse: Response, + res: http.ServerResponse ) { - if (result.type === 'response') { - const { response } = result; - await writeWebResponse(res, response); - return; - } - - const { html, response: init } = result; - const headers = init.headers as Headers; - - headers.set('Content-Type', 'text/html; charset=utf-8'); - headers.set('Content-Length', Buffer.byteLength(html, 'utf-8').toString()); - - return writeWebResponse(res, new Response(html, init)); + return writeWebResponse(res, webResponse); } async function handle404Response( @@ -296,7 +289,7 @@ async function handleRequest( routeCache, viteServer, }); - return await writeSSRResult(result, res, statusCode); + return await writeSSRResult(result, res); } else { return handle404Response(origin, config, req, res); } @@ -326,7 +319,7 @@ async function handleRequest( } } else { const result = await ssr(preloadedComponent, options); - return await writeSSRResult(result, res, statusCode); + return await writeSSRResult(result, res); } } catch (_err) { const err = fixViteErrorMessage(createSafeError(_err), viteServer); diff --git a/packages/astro/test/astro-response.test.js b/packages/astro/test/astro-response.test.js index 195247eba..050feaabb 100644 --- a/packages/astro/test/astro-response.test.js +++ b/packages/astro/test/astro-response.test.js @@ -24,9 +24,4 @@ describe('Returning responses', () => { let response = await fixture.fetch('/not-found'); expect(response.status).to.equal(404); }); - - it('Works from a component', async () => { - let response = await fixture.fetch('/not-found-component'); - expect(response.status).to.equal(404); - }); }); diff --git a/packages/astro/test/fixtures/astro-response/src/components/not-found.astro b/packages/astro/test/fixtures/astro-response/src/components/not-found.astro deleted file mode 100644 index dd339e72b..000000000 --- a/packages/astro/test/fixtures/astro-response/src/components/not-found.astro +++ /dev/null @@ -1,6 +0,0 @@ ---- -return new Response(null, { - status: 404, - statusText: `Not found` -}); ---- diff --git a/packages/astro/test/fixtures/astro-response/src/pages/not-found-component.astro b/packages/astro/test/fixtures/astro-response/src/pages/not-found-component.astro deleted file mode 100644 index e1077e9c9..000000000 --- a/packages/astro/test/fixtures/astro-response/src/pages/not-found-component.astro +++ /dev/null @@ -1,4 +0,0 @@ ---- -import NotFound from '../components/not-found.astro'; ---- - diff --git a/packages/astro/test/fixtures/streaming/astro.config.mjs b/packages/astro/test/fixtures/streaming/astro.config.mjs new file mode 100644 index 000000000..e69de29bb diff --git a/packages/astro/test/fixtures/streaming/package.json b/packages/astro/test/fixtures/streaming/package.json new file mode 100644 index 000000000..a27a51b6d --- /dev/null +++ b/packages/astro/test/fixtures/streaming/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/streaming", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev" + }, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/streaming/src/components/AsyncEach.astro b/packages/astro/test/fixtures/streaming/src/components/AsyncEach.astro new file mode 100644 index 000000000..02db971cc --- /dev/null +++ b/packages/astro/test/fixtures/streaming/src/components/AsyncEach.astro @@ -0,0 +1,11 @@ +--- +const { iterable } = Astro.props; +--- + +{(async function * () { + for await(const value of iterable) { + let html = await Astro.slots.render('default', [value]); + yield ; + yield '\n'; + } +})()} diff --git a/packages/astro/test/fixtures/streaming/src/components/Header.astro b/packages/astro/test/fixtures/streaming/src/components/Header.astro new file mode 100644 index 000000000..92d650253 --- /dev/null +++ b/packages/astro/test/fixtures/streaming/src/components/Header.astro @@ -0,0 +1,7 @@ +--- +import { wait } from '../wait'; +await wait(10); +--- +
    +

    My Site

    +
    diff --git a/packages/astro/test/fixtures/streaming/src/pages/index.astro b/packages/astro/test/fixtures/streaming/src/pages/index.astro new file mode 100644 index 000000000..ef0e8eb49 --- /dev/null +++ b/packages/astro/test/fixtures/streaming/src/pages/index.astro @@ -0,0 +1,32 @@ +--- +import Header from '../components/Header.astro'; +import AsyncEach from '../components/AsyncEach.astro'; +import { wait } from '../wait'; + +async function * list() { + const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + for(const num of nums) { + await wait(15); + yield num; + } +} +--- + +Testing + +

    Title

    +
    +
    +
    + +
    {Promise.resolve(12)}
    + +
      + + {(num: number) => ( +
    • Number: {num}
    • + )} +
      +
    + + diff --git a/packages/astro/test/fixtures/streaming/src/wait.ts b/packages/astro/test/fixtures/streaming/src/wait.ts new file mode 100644 index 000000000..304559799 --- /dev/null +++ b/packages/astro/test/fixtures/streaming/src/wait.ts @@ -0,0 +1,4 @@ + +export function wait(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/packages/astro/test/streaming.test.js b/packages/astro/test/streaming.test.js new file mode 100644 index 000000000..489e2ab19 --- /dev/null +++ b/packages/astro/test/streaming.test.js @@ -0,0 +1,74 @@ +import { isWindows, loadFixture } from './test-utils.js'; +import { expect } from 'chai'; +import testAdapter from './test-adapter.js'; +import * as cheerio from 'cheerio'; + + +describe('Streaming', () => { + if (isWindows) return; + + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/streaming/', + adapter: testAdapter(), + experimental: { + ssr: true, + }, + }); + }); + + describe('Development', () => { + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('Body is chunked', async () => { + let res = await fixture.fetch('/'); + let chunks = []; + for await(const bytes of res.body) { + let chunk = bytes.toString('utf-8'); + chunks.push(chunk); + } + expect(chunks.length).to.be.greaterThan(1); + }); + }); + + describe('Production', () => { + before(async () => { + await fixture.build(); + }); + + it('Can get the full html body', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('header h1')).to.have.a.lengthOf(1); + expect($('ul li')).to.have.a.lengthOf(10); + }); + + it('Body is chunked', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request); + let chunks = []; + let decoder = new TextDecoder(); + for await(const bytes of response.body) { + let chunk = decoder.decode(bytes); + chunks.push(chunk); + } + expect(chunks.length).to.be.greaterThan(1); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7b468ed1..b42e76d32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -515,7 +515,6 @@ importers: gray-matter: ^4.0.3 html-entities: ^2.3.3 html-escaper: ^3.0.3 - htmlparser2: ^7.2.0 kleur: ^4.1.4 magic-string: ^0.25.9 micromorph: ^0.1.2 @@ -574,7 +573,6 @@ importers: gray-matter: 4.0.3 html-entities: 2.3.3 html-escaper: 3.0.3 - htmlparser2: 7.2.0 kleur: 4.1.4 magic-string: 0.25.9 micromorph: 0.1.2 @@ -1701,6 +1699,12 @@ importers: packages/astro/test/fixtures/static-build/pkg: specifiers: {} + packages/astro/test/fixtures/streaming: + specifiers: + astro: workspace:* + dependencies: + astro: link:../../.. + packages/astro/test/fixtures/svelte-component: specifiers: '@astrojs/svelte': workspace:* @@ -8696,14 +8700,6 @@ packages: csstype: 3.1.0 dev: false - /dom-serializer/1.4.1: - resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} - dependencies: - domelementtype: 2.3.0 - domhandler: 4.3.1 - entities: 2.2.0 - dev: false - /dom-serializer/2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} dependencies: @@ -8714,13 +8710,7 @@ packages: /domelementtype/2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - /domhandler/4.3.1: - resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} - engines: {node: '>= 4'} - dependencies: - domelementtype: 2.3.0 - dev: false + dev: true /domhandler/5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} @@ -8729,14 +8719,6 @@ packages: domelementtype: 2.3.0 dev: true - /domutils/2.8.0: - resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} - dependencies: - dom-serializer: 1.4.1 - domelementtype: 2.3.0 - domhandler: 4.3.1 - dev: false - /domutils/3.0.1: resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} dependencies: @@ -8811,15 +8793,6 @@ packages: ansi-colors: 4.1.1 dev: true - /entities/2.2.0: - resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} - dev: false - - /entities/3.0.1: - resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==} - engines: {node: '>=0.12'} - dev: false - /entities/4.3.0: resolution: {integrity: sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==} engines: {node: '>=0.12'} @@ -9970,15 +9943,6 @@ packages: resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==} dev: false - /htmlparser2/7.2.0: - resolution: {integrity: sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==} - dependencies: - domelementtype: 2.3.0 - domhandler: 4.3.1 - domutils: 2.8.0 - entities: 3.0.1 - dev: false - /htmlparser2/8.0.1: resolution: {integrity: sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==} dependencies: