Adding an option to disable HTTP streaming (#3777)

* Adding a flag to disable HTTP streaming

* refactor: adding support for SSG builds

* handling string responses in the server runtime, adding tests

* removing streaming CLI flag

* removing import.meta.env.STREAMING

* include Content-Length header when streaming is disabled

* Verifying content-length header in dev

* fix: default streaming to enabled in the base App server

* TEMP: disabling the production test to investigate the test-adapter

* re-enabling the test with an adapter option to disable streaming for the test

* fix: use the existing TextEncoder to get the body's byte length

* moving config to build.streaming, ignoring it in `dev`

* fixing dev test to expect response streaming

* chore: add changsets

* removing the new config option all together 🎉

* remove temp debug log

* Updating astro changeset now that streaming isn't a config option
This commit is contained in:
Tony Sullivan 2022-07-01 02:29:59 +00:00 committed by GitHub
parent f9290b328a
commit 976e1f175a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 158 additions and 29 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---
Disables HTTP streaming in Cloudflare Pages deployments

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Adds an option to disable HTTP streaming in Astro's production `App` server

View file

@ -34,14 +34,16 @@ export class App {
dest: consoleLogDestination, dest: consoleLogDestination,
level: 'info', level: 'info',
}; };
#streaming: boolean;
constructor(manifest: Manifest) { constructor(manifest: Manifest, streaming = true) {
this.#manifest = manifest; this.#manifest = manifest;
this.#manifestData = { this.#manifestData = {
routes: manifest.routes.map((route) => route.routeData), routes: manifest.routes.map((route) => route.routeData),
}; };
this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route])); this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route]));
this.#routeCache = new RouteCache(this.#logging); this.#routeCache = new RouteCache(this.#logging);
this.#streaming = streaming;
} }
match(request: Request): RouteData | undefined { match(request: Request): RouteData | undefined {
const url = new URL(request.url); const url = new URL(request.url);
@ -117,6 +119,7 @@ export class App {
site: this.#manifest.site, site: this.#manifest.site,
ssr: true, ssr: true,
request, request,
streaming: this.#streaming,
}); });
return response; return response;

View file

@ -240,6 +240,7 @@ async function generatePath(
? new URL(astroConfig.base, astroConfig.site).toString() ? new URL(astroConfig.base, astroConfig.site).toString()
: astroConfig.site, : astroConfig.site,
ssr, ssr,
streaming: true,
}; };
let body: string; let body: string;

View file

@ -34,6 +34,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
server: { server: {
host: false, host: false,
port: 3000, port: 3000,
streaming: true,
}, },
style: { postcss: { options: {}, plugins: [] } }, style: { postcss: { options: {}, plugins: [] } },
integrations: [], integrations: [],
@ -315,6 +316,7 @@ export async function validateConfig(
.optional() .optional()
.default(ASTRO_CONFIG_DEFAULTS.server.host), .default(ASTRO_CONFIG_DEFAULTS.server.host),
port: z.number().optional().default(ASTRO_CONFIG_DEFAULTS.server.port), port: z.number().optional().default(ASTRO_CONFIG_DEFAULTS.server.port),
streaming: z.boolean().optional().default(true),
}) })
.optional() .optional()
.default({}) .default({})

View file

@ -80,6 +80,7 @@ export interface RenderOptions {
routeCache: RouteCache; routeCache: RouteCache;
site?: string; site?: string;
ssr: boolean; ssr: boolean;
streaming: boolean;
request: Request; request: Request;
} }
@ -100,6 +101,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
routeCache, routeCache,
site, site,
ssr, ssr,
streaming,
} = opts; } = opts;
const paramsAndPropsRes = await getParamsAndProps({ const paramsAndPropsRes = await getParamsAndProps({
@ -138,6 +140,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
site, site,
scripts, scripts,
ssr, ssr,
streaming,
}); });
// Support `export const components` for `MDX` pages // Support `export const components` for `MDX` pages
@ -145,5 +148,5 @@ export async function render(opts: RenderOptions): Promise<Response> {
Object.assign(pageProps, { components: (mod as any).components }); Object.assign(pageProps, { components: (mod as any).components });
} }
return await renderPage(result, Component, pageProps, null); return await renderPage(result, Component, pageProps, null, streaming);
} }

View file

@ -184,6 +184,7 @@ export async function render(
routeCache, routeCache,
site: astroConfig.site ? new URL(astroConfig.base, astroConfig.site).toString() : undefined, site: astroConfig.site ? new URL(astroConfig.base, astroConfig.site).toString() : undefined,
ssr: isBuildingToSSR(astroConfig), ssr: isBuildingToSSR(astroConfig),
streaming: true,
}); });
return response; return response;

View file

@ -24,6 +24,7 @@ function onlyAvailableInSSR(name: string) {
export interface CreateResultArgs { export interface CreateResultArgs {
ssr: boolean; ssr: boolean;
streaming: boolean;
logging: LogOptions; logging: LogOptions;
origin: string; origin: string;
markdown: MarkdownRenderingOptions; markdown: MarkdownRenderingOptions;
@ -114,7 +115,11 @@ export function createResult(args: CreateResultArgs): SSRResult {
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(); const headers = new Headers();
headers.set('Transfer-Encoding', 'chunked'); if (args.streaming) {
headers.set('Transfer-Encoding', 'chunked');
} else {
headers.set('Content-Type', 'text/html');
}
const response: ResponseInit = { const response: ResponseInit = {
status: 200, status: 200,
statusText: 'OK', statusText: 'OK',

View file

@ -704,7 +704,8 @@ export async function renderPage(
result: SSRResult, result: SSRResult,
componentFactory: AstroComponentFactory, componentFactory: AstroComponentFactory,
props: any, props: any,
children: any children: any,
streaming: boolean,
): Promise<Response> { ): Promise<Response> {
let iterable: AsyncIterable<any>; let iterable: AsyncIterable<any>;
if (!componentFactory.isAstroComponentFactory) { if (!componentFactory.isAstroComponentFactory) {
@ -730,28 +731,48 @@ export async function renderPage(
const factoryReturnValue = await componentFactory(result, props, children); const factoryReturnValue = await componentFactory(result, props, children);
if (isAstroComponent(factoryReturnValue)) { if (isAstroComponent(factoryReturnValue)) {
iterable = renderAstroComponent(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 (!/<!doctype html/i.test(html)) {
controller.enqueue(encoder.encode('<!DOCTYPE html>\n'));
}
}
controller.enqueue(encoder.encode(html));
i++;
}
controller.close();
}
read();
},
});
let init = result.response; let init = result.response;
let response = createResponse(stream, init); let headers = new Headers(init.headers);
let body: BodyInit;
if (streaming) {
body = new ReadableStream({
start(controller) {
async function read() {
let i = 0;
for await (const chunk of iterable) {
let html = chunk.toString();
if (i === 0) {
if (!/<!doctype html/i.test(html)) {
controller.enqueue(encoder.encode('<!DOCTYPE html>\n'));
}
}
controller.enqueue(encoder.encode(html));
i++;
}
controller.close();
}
read();
},
});
} else {
body = '';
let i = 0;
for await (const chunk of iterable) {
let html = chunk.toString();
if (i === 0) {
if (!/<!doctype html/i.test(html)) {
body += '<!DOCTYPE html>\n';
}
}
body += chunk;
i++;
}
const bytes = encoder.encode(body);
headers.set('Content-Length', `${bytes.byteLength}`);
}
let response = createResponse(body, { ...init, headers });
return response; return response;
} else { } else {
return factoryReturnValue; return factoryReturnValue;

View file

@ -51,6 +51,9 @@ type CreateResponseFn = (body?: BodyInit | null, init?: ResponseInit) => Respons
export const createResponse: CreateResponseFn = isNodeJS export const createResponse: CreateResponseFn = isNodeJS
? (body, init) => { ? (body, init) => {
if (typeof body === 'string') {
return new Response(body, init);
}
if (typeof StreamingCompatibleResponse === 'undefined') { if (typeof StreamingCompatibleResponse === 'undefined') {
return new (createResponseClass())(body, init); return new (createResponseClass())(body, init);
} }

View file

@ -84,6 +84,8 @@ async function writeWebResponse(res: http.ServerResponse, webResponse: Response)
} else if (body instanceof Readable) { } else if (body instanceof Readable) {
body.pipe(res); body.pipe(res);
return; return;
} else if (typeof body === 'string') {
res.write(body);
} else { } else {
const reader = body.getReader(); const reader = body.getReader();
while (true) { while (true) {

View file

@ -71,3 +71,72 @@ describe('Streaming', () => {
}); });
}); });
}); });
describe('Streaming disabled', () => {
if (isWindows) return;
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/streaming/',
adapter: testAdapter(),
experimental: {
ssr: true,
},
server: {
streaming: false,
}
});
});
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);
});
});
// TODO: find a different solution for the test-adapter,
// currently there's no way to resolve two different versions with one
// having streaming disabled
describe('Production', () => {
before(async () => {
await fixture.build();
});
it('Can get the full html body', async () => {
const app = await fixture.loadTestAdapterApp(false);
const request = new Request('http://example.com/');
const response = await app.render(request);
expect(response.status).to.equal(200);
expect(response.headers.get('content-type')).to.equal('text/html');
expect(response.headers.has('content-length')).to.equal(true);
expect(parseInt(response.headers.get('content-length'))).to.be.greaterThan(0);
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);
});
});
});

View file

@ -23,7 +23,7 @@ export default function () {
}, },
load(id) { load(id) {
if (id === '@my-ssr') { if (id === '@my-ssr') {
return `import { App } from 'astro/app';export function createExports(manifest) { return { manifest, createApp: () => new App(manifest) }; }`; return `import { App } from 'astro/app';export function createExports(manifest) { return { manifest, createApp: (streaming) => new App(manifest, streaming) }; }`;
} }
}, },
}, },

View file

@ -149,10 +149,10 @@ export async function loadFixture(inlineConfig) {
clean: async () => { clean: async () => {
await fs.promises.rm(config.outDir, { maxRetries: 10, recursive: true, force: true }); await fs.promises.rm(config.outDir, { maxRetries: 10, recursive: true, force: true });
}, },
loadTestAdapterApp: async () => { loadTestAdapterApp: async (streaming) => {
const url = new URL('./server/entry.mjs', config.outDir); const url = new URL('./server/entry.mjs', config.outDir);
const { createApp, manifest } = await import(url); const { createApp, manifest } = await import(url);
const app = createApp(); const app = createApp(streaming);
app.manifest = manifest; app.manifest = manifest;
return app; return app;
}, },

View file

@ -8,7 +8,7 @@ type Env = {
}; };
export function createExports(manifest: SSRManifest) { export function createExports(manifest: SSRManifest) {
const app = new App(manifest); const app = new App(manifest, false);
const fetch = async (request: Request, env: Env) => { const fetch = async (request: Request, env: Env) => {
const { origin, pathname } = new URL(request.url); const { origin, pathname } = new URL(request.url);

9
pnpm-lock.yaml generated
View file

@ -8905,6 +8905,11 @@ packages:
/debug/3.2.7: /debug/3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
dev: false dev: false
@ -11957,6 +11962,8 @@ packages:
debug: 3.2.7 debug: 3.2.7
iconv-lite: 0.4.24 iconv-lite: 0.4.24
sax: 1.2.4 sax: 1.2.4
transitivePeerDependencies:
- supports-color
dev: false dev: false
/netmask/2.0.2: /netmask/2.0.2:
@ -12040,6 +12047,8 @@ packages:
rimraf: 2.7.1 rimraf: 2.7.1
semver: 5.7.1 semver: 5.7.1
tar: 4.4.19 tar: 4.4.19
transitivePeerDependencies:
- supports-color
dev: false dev: false
/node-releases/2.0.5: /node-releases/2.0.5: