* Start of streaming

* New lockfile

* Base should be Uint8Arrays

* Remove the ability to throw from a component

* Add a warning when returning a Response from a non-page component

* Adds a changeset
This commit is contained in:
Matthew Phillips 2022-06-24 15:35:21 -04:00 committed by GitHub
parent 0d667d0e57
commit 3daaf510ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 374 additions and 308 deletions

View file

@ -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';
---
<html>
<head>
<title>App</title>
</head>
<body>
<LoadTodos />
</body>
</html>
```
In this arbtrary example Astro will streaming out the `<head>` section and everything else until it encounters `<LoadTodos />` 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
<ul>
{(async function * () {
for(const number of numbers) {
await wait(1000);
yield <li>Number: {number}</li>
yield '\n'
}
})()}
</ul>
```
Which will stream out `<li>`s one at a time, waiting a second between each.

View file

@ -105,7 +105,6 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"html-entities": "^2.3.3", "html-entities": "^2.3.3",
"html-escaper": "^3.0.3", "html-escaper": "^3.0.3",
"htmlparser2": "^7.2.0",
"kleur": "^4.1.4", "kleur": "^4.1.4",
"magic-string": "^0.25.9", "magic-string": "^0.25.9",
"micromorph": "^0.1.2", "micromorph": "^0.1.2",

View file

@ -94,7 +94,7 @@ export class App {
} }
} }
const result = await render({ const response = await render({
links, links,
logging: this.#logging, logging: this.#logging,
markdown: manifest.markdown, markdown: manifest.markdown,
@ -119,17 +119,7 @@ export class App {
request, request,
}); });
if (result.type === 'response') { return 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);
} }
async #callEndpoint( async #callEndpoint(

View file

@ -251,13 +251,14 @@ async function generatePath(
} }
body = result.body; body = result.body;
} else { } else {
const result = await render(options); const response = await render(options);
// If there's a redirect or something, just do nothing. // If there's a redirect or something, just do nothing.
if (result.type !== 'html') { if (response.status !== 200 || !response.body) {
return; return;
} }
body = result.html;
body = await response.text();
} }
const outFolder = getOutFolder(astroConfig, pathname, pageData.route.type); const outFolder = getOutFolder(astroConfig, pathname, pageData.route.type);

View file

@ -85,9 +85,7 @@ export interface RenderOptions {
export async function render( export async function render(
opts: RenderOptions opts: RenderOptions
): Promise< ): Promise<Response> {
{ type: 'html'; html: string; response: ResponseInit } | { type: 'response'; response: Response }
> {
const { const {
links, links,
styles, styles,
@ -144,32 +142,11 @@ export async function render(
ssr, ssr,
}); });
let page: Awaited<ReturnType<typeof renderPage>>;
if (!Component.isAstroComponentFactory) { if (!Component.isAstroComponentFactory) {
const props: Record<string, any> = { ...(pageProps ?? {}), 'server:root': true }; const props: Record<string, any> = { ...(pageProps ?? {}), 'server:root': true };
const html = await renderComponent(result, Component.name, Component, props, null); const html = await renderComponent(result, Component.name, Component, props, null);
page = { return new Response(html.toString(), result.response);
type: 'html',
html: html.toString(),
};
} else { } 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 <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?)
if (!/<!doctype html/i.test(html)) {
html = '<!DOCTYPE html>\n' + html;
}
return {
type: 'html',
html,
response: result.response,
};
} }

View file

@ -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 didnt generate <head> or <body>, 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<string, string>;
/** 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({
// <link> 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
// Vites `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' : ' '}`;
}

View file

@ -1,5 +1,5 @@
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import type { HtmlTagDescriptor, ViteDevServer } from 'vite'; import type { ViteDevServer } from 'vite';
import type { import type {
AstroConfig, AstroConfig,
AstroRenderer, AstroRenderer,
@ -17,7 +17,6 @@ import { RouteCache } from '../route-cache.js';
import { createModuleScriptElementWithSrcSet } from '../ssr-element.js'; import { createModuleScriptElementWithSrcSet } from '../ssr-element.js';
import { collectMdMetadata } from '../util.js'; import { collectMdMetadata } from '../util.js';
import { getStylesForURL } from './css.js'; import { getStylesForURL } from './css.js';
import { injectTags } from './html.js';
import { resolveClientDevPath } from './resolve.js'; import { resolveClientDevPath } from './resolve.js';
export interface SSROptions { export interface SSROptions {
@ -45,10 +44,6 @@ export interface SSROptions {
export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance]; export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance];
export type RenderResponse =
| { type: 'html'; html: string; response: ResponseInit }
| { type: 'response'; response: Response };
const svelteStylesRE = /svelte\?svelte&type=style/; const svelteStylesRE = /svelte\?svelte&type=style/;
async function loadRenderer( async function loadRenderer(
@ -99,7 +94,7 @@ export async function render(
renderers: SSRLoadedRenderer[], renderers: SSRLoadedRenderer[],
mod: ComponentInstance, mod: ComponentInstance,
ssrOpts: SSROptions ssrOpts: SSROptions
): Promise<RenderResponse> { ): Promise<Response> {
const { const {
astroConfig, astroConfig,
filePath, filePath,
@ -167,7 +162,7 @@ export async function render(
}); });
}); });
let content = await coreRender({ let response = await coreRender({
links, links,
styles, styles,
logging, logging,
@ -191,32 +186,13 @@ export async function render(
ssr: isBuildingToSSR(astroConfig), ssr: isBuildingToSSR(astroConfig),
}); });
if (route?.type === 'endpoint' || content.type === 'response') { return response;
return content;
}
// inject tags
const tags: HtmlTagDescriptor[] = [];
// add injected tags
let html = injectTags(content.html, tags);
// inject <!doctype html> if missing (TODO: is a more robust check needed for comments, etc.?)
if (!/<!doctype html/i.test(html)) {
html = '<!DOCTYPE html>\n' + content;
}
return {
type: 'html',
html,
response: content.response,
};
} }
export async function ssr( export async function ssr(
preloadedComponent: ComponentPreload, preloadedComponent: ComponentPreload,
ssrOpts: SSROptions ssrOpts: SSROptions
): Promise<RenderResponse> { ): Promise<Response> {
const [renderers, mod] = preloadedComponent; const [renderers, mod] = preloadedComponent;
return await render(renderers, mod, ssrOpts); // NOTE: without "await", errors wont get caught below return await render(renderers, mod, ssrOpts); // NOTE: without "await", errors wont get caught below
} }

View file

@ -113,10 +113,12 @@ export function createResult(args: CreateResultArgs): SSRResult {
const paginated = isPaginatedRoute(pageProps); const paginated = isPaginatedRoute(pageProps);
const url = new URL(request.url); const url = new URL(request.url);
const canonicalURL = createCanonicalURL('.' + pathname, site ?? url.origin, paginated); const canonicalURL = createCanonicalURL('.' + pathname, site ?? url.origin, paginated);
const headers = new Headers();
headers.set('Transfer-Encoding', 'chunked');
const response: ResponseInit = { const response: ResponseInit = {
status: 200, status: 200,
statusText: 'OK', statusText: 'OK',
headers: new Headers(), headers,
}; };
// Make headers be read-only // Make headers be read-only

View file

@ -11,6 +11,7 @@ import type {
import { escapeHTML, HTMLString, markHTMLString } from './escape.js'; import { escapeHTML, HTMLString, markHTMLString } from './escape.js';
import { extractDirectives, generateHydrateScript } from './hydration.js'; import { extractDirectives, generateHydrateScript } from './hydration.js';
import { createResponse } from './response.js';
import { import {
determineIfNeedsHydrationScript, determineIfNeedsHydrationScript,
determinesIfNeedsDirectiveScript, determinesIfNeedsDirectiveScript,
@ -40,19 +41,21 @@ const svgEnumAttributes = /^(autoReverse|externalResourcesRequired|focusable|pre
// INVESTIGATE: Can we have more specific types both for the argument and output? // 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. // If these are intentional, add comments that these are intention and why.
// Or maybe type UserValue = any; ? // Or maybe type UserValue = any; ?
async function _render(child: any): Promise<any> { async function * _render(child: any): AsyncIterable<any> {
child = await child; child = await child;
if (child instanceof HTMLString) { if (child instanceof HTMLString) {
return child; yield child;
} else if (Array.isArray(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') { } else if (typeof child === 'function') {
// Special: If a child is a function, call it automatically. // Special: If a child is a function, call it automatically.
// This lets you do {() => ...} without the extra boilerplate // This lets you do {() => ...} without the extra boilerplate
// of wrapping it in a function and calling it. // of wrapping it in a function and calling it.
return _render(child()); yield * _render(child());
} else if (typeof child === 'string') { } else if (typeof child === 'string') {
return markHTMLString(escapeHTML(child)); yield markHTMLString(escapeHTML(child));
} else if (!child && child !== 0) { } else if (!child && child !== 0) {
// do nothing, safe to ignore falsey values. // do nothing, safe to ignore falsey values.
} }
@ -62,9 +65,11 @@ async function _render(child: any): Promise<any> {
child instanceof AstroComponent || child instanceof AstroComponent ||
Object.prototype.toString.call(child) === '[object 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 { } else {
return child; yield child;
} }
} }
@ -83,7 +88,7 @@ export class AstroComponent {
return 'AstroComponent'; return 'AstroComponent';
} }
*[Symbol.iterator]() { async *[Symbol.asyncIterator]() {
const { htmlParts, expressions } = this; const { htmlParts, expressions } = this;
for (let i = 0; i < htmlParts.length; i++) { for (let i = 0; i < htmlParts.length; i++) {
@ -91,7 +96,7 @@ export class AstroComponent {
const expression = expressions[i]; const expression = expressions[i];
yield markHTMLString(html); yield markHTMLString(html);
yield _render(expression); yield * _render(expression);
} }
} }
} }
@ -120,9 +125,14 @@ export function createComponent(cb: AstroComponentFactory) {
return cb; return cb;
} }
export async function renderSlot(_result: any, slotted: string, fallback?: any) { export async function renderSlot(_result: any, slotted: string, fallback?: any): Promise<string> {
if (slotted) { 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; return fallback;
} }
@ -157,7 +167,7 @@ export async function renderComponent(
Component: unknown, Component: unknown,
_props: Record<string | number, any>, _props: Record<string | number, any>,
slots: any = {} slots: any = {}
) { ): Promise<string | AsyncIterable<string>> {
Component = await Component; Component = await Component;
if (Component === Fragment) { if (Component === Fragment) {
const children = await renderSlot(result, slots?.default); const children = await renderSlot(result, slots?.default);
@ -168,8 +178,7 @@ export async function renderComponent(
} }
if (Component && (Component as any).isAstroComponentFactory) { if (Component && (Component as any).isAstroComponentFactory) {
const output = await renderToString(result, Component as any, _props, slots); return renderToIterable(result, Component as any, _props, slots);
return markHTMLString(output);
} }
if (!Component && !_props['client:only']) { 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. // as a string and the user is responsible for adding a script tag for the component definition.
if (!html && typeof Component === 'string') { if (!html && typeof Component === 'string') {
const childSlots = Object.values(children).join(''); const childSlots = Object.values(children).join('');
html = await renderAstroComponent( const iterable = renderAstroComponent(
await render`<${Component}${internalSpreadAttributes(props)}${markHTMLString( await render`<${Component}${internalSpreadAttributes(props)}${markHTMLString(
childSlots === '' && voidElementNames.test(Component) childSlots === '' && voidElementNames.test(Component)
? `/>` ? `/>`
: `>${childSlots}</${Component}>` : `>${childSlots}</${Component}>`
)}` )}`
); );
html = '';
for await(const chunk of iterable) {
html += chunk;
}
} }
if (!hydration) { if (!hydration) {
@ -597,45 +610,72 @@ export async function renderToString(
children: any children: any
): Promise<string> { ): Promise<string> {
const Component = await componentFactory(result, props, children); const Component = await componentFactory(result, props, children);
if (!isAstroComponent(Component)) { if (!isAstroComponent(Component)) {
const response: Response = Component; const response: Response = Component;
throw response; throw response;
} }
let template = await renderAstroComponent(Component); let html = '';
return template; for await(const chunk of renderAstroComponent(Component)) {
html += chunk;
}
return html;
} }
export async function renderToIterable(
result: SSRResult,
componentFactory: AstroComponentFactory,
props: any,
children: any
): Promise<AsyncIterable<string>> {
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( export async function renderPage(
result: SSRResult, result: SSRResult,
componentFactory: AstroComponentFactory, componentFactory: AstroComponentFactory,
props: any, props: any,
children: any children: any
): Promise<{ type: 'html'; html: string } | { type: 'response'; response: Response }> { ): Promise<Response> {
try { const factoryReturnValue = await componentFactory(result, props, children);
const response = await componentFactory(result, props, children);
if (isAstroComponent(response)) { if (isAstroComponent(factoryReturnValue)) {
let html = await renderAstroComponent(response); let iterable = renderAstroComponent(factoryReturnValue);
return { let stream = new ReadableStream({
type: 'html', start(controller) {
html, async function read() {
}; let i = 0;
} else { for await(const chunk of iterable) {
return { let html = chunk.toString();
type: 'response', if(i === 0) {
response, if (!/<!doctype html/i.test(html)) {
}; controller.enqueue(encoder.encode('<!DOCTYPE html>\n'));
} }
} catch (err) { }
if (err instanceof Response) { controller.enqueue(encoder.encode(html));
return { i++;
type: 'response', }
response: err, controller.close();
}; }
} else { read();
throw err; }
} });
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<string> {
return renderHead(result); return renderHead(result);
} }
export async function renderAstroComponent(component: InstanceType<typeof AstroComponent>) { export async function * renderAstroComponent(component: InstanceType<typeof AstroComponent>): AsyncIterable<string> {
let template = [];
for await (const value of component) { for await (const value of component) {
if (value || value === 0) { 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) { function componentIsHTMLElement(Component: unknown) {

View file

@ -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<string> {
if(this.#isStream && isNodeJS) {
let decoder = new TextDecoder();
let body = this.#body as ReadableStream<Uint8Array>;
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<ArrayBuffer> {
if(this.#isStream && isNodeJS) {
let body = this.#body as ReadableStream<Uint8Array>;
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);

View file

@ -1,15 +1,16 @@
import type http from 'http'; import type http from 'http';
import { Readable } from 'stream';
import stripAnsi from 'strip-ansi';
import type * as vite from 'vite'; import type * as vite from 'vite';
import type { AstroConfig, ManifestData } from '../@types/astro'; 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 { call as callEndpoint } from '../core/endpoint/dev/index.js';
import { fixViteErrorMessage } from '../core/errors.js'; import { fixViteErrorMessage } from '../core/errors.js';
import { error, info, LogOptions, warn } from '../core/logger/core.js'; import { error, info, LogOptions, warn } from '../core/logger/core.js';
import * as msg from '../core/messages.js'; import * as msg from '../core/messages.js';
import { appendForwardSlash } from '../core/path.js'; import { appendForwardSlash } from '../core/path.js';
import { getParamsAndProps, GetParamsAndPropsError } from '../core/render/core.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 { preload, ssr } from '../core/render/dev/index.js';
import { RouteCache } from '../core/render/route-cache.js'; import { RouteCache } from '../core/render/route-cache.js';
import { createRequest } from '../core/request.js'; import { createRequest } from '../core/request.js';
@ -75,7 +76,12 @@ async function writeWebResponse(res: http.ServerResponse, webResponse: Response)
res.writeHead(status, _headers); res.writeHead(status, _headers);
if (body) { 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); body.pipe(res);
return; return;
} else { } else {
@ -93,23 +99,10 @@ async function writeWebResponse(res: http.ServerResponse, webResponse: Response)
} }
async function writeSSRResult( async function writeSSRResult(
result: RenderResponse, webResponse: Response,
res: http.ServerResponse, res: http.ServerResponse
statusCode: 200 | 404
) { ) {
if (result.type === 'response') { return writeWebResponse(res, webResponse);
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));
} }
async function handle404Response( async function handle404Response(
@ -296,7 +289,7 @@ async function handleRequest(
routeCache, routeCache,
viteServer, viteServer,
}); });
return await writeSSRResult(result, res, statusCode); return await writeSSRResult(result, res);
} else { } else {
return handle404Response(origin, config, req, res); return handle404Response(origin, config, req, res);
} }
@ -326,7 +319,7 @@ async function handleRequest(
} }
} else { } else {
const result = await ssr(preloadedComponent, options); const result = await ssr(preloadedComponent, options);
return await writeSSRResult(result, res, statusCode); return await writeSSRResult(result, res);
} }
} catch (_err) { } catch (_err) {
const err = fixViteErrorMessage(createSafeError(_err), viteServer); const err = fixViteErrorMessage(createSafeError(_err), viteServer);

View file

@ -24,9 +24,4 @@ describe('Returning responses', () => {
let response = await fixture.fetch('/not-found'); let response = await fixture.fetch('/not-found');
expect(response.status).to.equal(404); 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);
});
}); });

View file

@ -1,6 +0,0 @@
---
return new Response(null, {
status: 404,
statusText: `Not found`
});
---

View file

@ -1,4 +0,0 @@
---
import NotFound from '../components/not-found.astro';
---
<NotFound />

View file

@ -0,0 +1,11 @@
{
"name": "@test/streaming",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "astro dev"
},
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -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 <Fragment set:html={html} />;
yield '\n';
}
})()}

View file

@ -0,0 +1,7 @@
---
import { wait } from '../wait';
await wait(10);
---
<header>
<h1>My Site</h1>
</header>

View file

@ -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;
}
}
---
<html>
<head><title>Testing</title></head>
<body>
<h1>Title</h1>
<section>
<Header />
</section>
<div id="promise">{Promise.resolve(12)}</div>
<ul>
<AsyncEach iterable={list()}>
{(num: number) => (
<li>Number: {num}</li>
)}
</AsyncEach>
</ul>
</body>
</html>

View file

@ -0,0 +1,4 @@
export function wait(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

View file

@ -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);
});
});
});

50
pnpm-lock.yaml generated
View file

@ -515,7 +515,6 @@ importers:
gray-matter: ^4.0.3 gray-matter: ^4.0.3
html-entities: ^2.3.3 html-entities: ^2.3.3
html-escaper: ^3.0.3 html-escaper: ^3.0.3
htmlparser2: ^7.2.0
kleur: ^4.1.4 kleur: ^4.1.4
magic-string: ^0.25.9 magic-string: ^0.25.9
micromorph: ^0.1.2 micromorph: ^0.1.2
@ -574,7 +573,6 @@ importers:
gray-matter: 4.0.3 gray-matter: 4.0.3
html-entities: 2.3.3 html-entities: 2.3.3
html-escaper: 3.0.3 html-escaper: 3.0.3
htmlparser2: 7.2.0
kleur: 4.1.4 kleur: 4.1.4
magic-string: 0.25.9 magic-string: 0.25.9
micromorph: 0.1.2 micromorph: 0.1.2
@ -1701,6 +1699,12 @@ importers:
packages/astro/test/fixtures/static-build/pkg: packages/astro/test/fixtures/static-build/pkg:
specifiers: {} specifiers: {}
packages/astro/test/fixtures/streaming:
specifiers:
astro: workspace:*
dependencies:
astro: link:../../..
packages/astro/test/fixtures/svelte-component: packages/astro/test/fixtures/svelte-component:
specifiers: specifiers:
'@astrojs/svelte': workspace:* '@astrojs/svelte': workspace:*
@ -8696,14 +8700,6 @@ packages:
csstype: 3.1.0 csstype: 3.1.0
dev: false 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: /dom-serializer/2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dependencies: dependencies:
@ -8714,13 +8710,7 @@ packages:
/domelementtype/2.3.0: /domelementtype/2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
dev: true
/domhandler/4.3.1:
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
engines: {node: '>= 4'}
dependencies:
domelementtype: 2.3.0
dev: false
/domhandler/5.0.3: /domhandler/5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
@ -8729,14 +8719,6 @@ packages:
domelementtype: 2.3.0 domelementtype: 2.3.0
dev: true 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: /domutils/3.0.1:
resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==} resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==}
dependencies: dependencies:
@ -8811,15 +8793,6 @@ packages:
ansi-colors: 4.1.1 ansi-colors: 4.1.1
dev: true 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: /entities/4.3.0:
resolution: {integrity: sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==} resolution: {integrity: sha512-/iP1rZrSEJ0DTlPiX+jbzlA3eVkY/e8L8SozroF395fIqE3TYF/Nz7YOMAawta+vLmyJ/hkGNNPcSbMADCCXbg==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
@ -9970,15 +9943,6 @@ packages:
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==} resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
dev: false 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: /htmlparser2/8.0.1:
resolution: {integrity: sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==} resolution: {integrity: sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==}
dependencies: dependencies: