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
+
+ {(async function * () {
+ for(const number of numbers) {
+ await wait(1000);
+
+ yield Number: {number}
+ yield '\n'
+ }
+ })()}
+
+```
+
+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)
- )}${tag}>`;
- }
-}
-
-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}${Component}>`
)}`
);
+ 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);
+---
+
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: