Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding an option to disable HTTP streaming #3777

Merged
merged 18 commits into from
Jul 1, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export interface CLIFlags {
experimentalSsr?: boolean;
experimentalIntegrations?: boolean;
drafts?: boolean;
streaming?: boolean;
tony-sull marked this conversation as resolved.
Show resolved Hide resolved
}

export interface BuildConfig {
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class App {
site: this.#manifest.site,
ssr: true,
request,
streaming: import.meta.env.STREAMING,
});

return response;
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ async function generatePath(
? new URL(astroConfig.base, astroConfig.site).toString()
: astroConfig.site,
ssr,
streaming: astroConfig.server.streaming,
};

let body: string;
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
server: {
host: false,
port: 3000,
streaming: true,
tony-sull marked this conversation as resolved.
Show resolved Hide resolved
},
style: { postcss: { options: {}, plugins: [] } },
integrations: [],
Expand Down Expand Up @@ -154,6 +155,7 @@ export const AstroConfigSchema = z.object({
.optional()
.default(ASTRO_CONFIG_DEFAULTS.server.host),
port: z.number().optional().default(ASTRO_CONFIG_DEFAULTS.server.port),
streaming: z.boolean().optional().default(true),
})
.optional()
.default({})
Expand Down Expand Up @@ -315,6 +317,7 @@ export async function validateConfig(
.optional()
.default(ASTRO_CONFIG_DEFAULTS.server.host),
port: z.number().optional().default(ASTRO_CONFIG_DEFAULTS.server.port),
streaming: z.boolean().optional().default(true),
})
.optional()
.default({})
Expand Down Expand Up @@ -404,6 +407,11 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags, cmd: strin
if (typeof flags.experimentalIntegrations === 'boolean')
astroConfig.experimental.integrations = flags.experimentalIntegrations;
if (typeof flags.drafts === 'boolean') astroConfig.markdown.drafts = flags.drafts;
if (typeof flags.streaming === 'boolean') {
tony-sull marked this conversation as resolved.
Show resolved Hide resolved
// @ts-expect-error astroConfig.server may be a function, but TS doesn't like attaching properties to a function.
// TODO: Come back here and refactor to remove this expected error.
astroConfig.server.streaming = flags.streaming;
}
if (typeof flags.port === 'number') {
// @ts-expect-error astroConfig.server may be a function, but TS doesn't like attaching properties to a function.
// TODO: Come back here and refactor to remove this expected error.
Expand Down
5 changes: 4 additions & 1 deletion packages/astro/src/core/render/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface RenderOptions {
routeCache: RouteCache;
site?: string;
ssr: boolean;
streaming: boolean;
request: Request;
}

Expand All @@ -100,6 +101,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
routeCache,
site,
ssr,
streaming,
} = opts;

const paramsAndPropsRes = await getParamsAndProps({
Expand Down Expand Up @@ -138,13 +140,14 @@ export async function render(opts: RenderOptions): Promise<Response> {
site,
scripts,
ssr,
streaming,
});

if (!Component.isAstroComponentFactory) {
const props: Record<string, any> = { ...(pageProps ?? {}), 'server:root': true };
const html = await renderComponent(result, Component.name, Component, props, null);
return new Response(html.toString(), result.response);
} else {
return await renderPage(result, Component, pageProps, null);
return await renderPage(result, Component, pageProps, null, streaming);
}
}
1 change: 1 addition & 0 deletions packages/astro/src/core/render/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export async function render(
routeCache,
site: astroConfig.site ? new URL(astroConfig.base, astroConfig.site).toString() : undefined,
ssr: isBuildingToSSR(astroConfig),
streaming: astroConfig.server.streaming,
});

return response;
Expand Down
7 changes: 6 additions & 1 deletion packages/astro/src/core/render/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function onlyAvailableInSSR(name: string) {

export interface CreateResultArgs {
ssr: boolean;
streaming: boolean;
logging: LogOptions;
origin: string;
markdown: MarkdownRenderingOptions;
Expand Down Expand Up @@ -114,7 +115,11 @@ export function createResult(args: CreateResultArgs): SSRResult {
const url = new URL(request.url);
const canonicalURL = createCanonicalURL('.' + pathname, site ?? url.origin, paginated);
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 = {
status: 200,
statusText: 'OK',
Expand Down
51 changes: 34 additions & 17 deletions packages/astro/src/runtime/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,33 +692,50 @@ export async function renderPage(
result: SSRResult,
componentFactory: AstroComponentFactory,
props: any,
children: any
children: any,
streaming: boolean,
): Promise<Response> {
const factoryReturnValue = await componentFactory(result, props, children);

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 (!/<!doctype html/i.test(html)) {
controller.enqueue(encoder.encode('<!DOCTYPE html>\n'));
let body: BodyInit;
if (streaming) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bah, I wish CF supported ReadableStream. I'm still on a mission to reduce forking code paths, so in that spirit I'd love to use ReadableStream in both cases and then just pipe it to a string as a smaller, additional step in the non-streaming code path.

I can't think of any good way to remove the copy-paste code between the two if-else code paths, so I guess this might not be actionable. Just venting I guess :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was actually my first solution! We can't use ReadableStream in CF at all unfortunately, the constructor throws an error immediately. Even worse, the error is basically silent if you don't add an extra try/catch around the entire SSR handler and logs are accessible yet for Cloudflare Pages Functions 🙃

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.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';
}
controller.close();
}
read();
},
});
body += chunk;
i++;
}
}
let init = result.response;
let response = createResponse(stream, init);
let response = createResponse(body, init);
return response;
} else {
return factoryReturnValue;
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/runtime/server/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ type CreateResponseFn = (body?: BodyInit | null, init?: ResponseInit) => Respons

export const createResponse: CreateResponseFn = isNodeJS
? (body, init) => {
if (typeof body === 'string') {
return new Response(body, init);
}
if (typeof StreamingCompatibleResponse === 'undefined') {
return new (createResponseClass())(body, init);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/vite-plugin-astro-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ async function writeWebResponse(res: http.ServerResponse, webResponse: Response)
} else if (body instanceof Readable) {
body.pipe(res);
return;
} else if (typeof body === 'string') {
res.write(body);
} else {
const reader = body.getReader();
while (true) {
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/vite-plugin-env/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default function envVitePlugin({
if (privateEnv) {
privateEnv.SITE = astroConfig.site ? `'${astroConfig.site}'` : 'undefined';
privateEnv.SSR = JSON.stringify(true);
privateEnv.STREAMING = JSON.stringify(astroConfig.server.streaming);
tony-sull marked this conversation as resolved.
Show resolved Hide resolved
const entries = Object.entries(privateEnv).map(([key, value]) => [
`import.meta.env.${key}`,
value,
Expand All @@ -88,6 +89,7 @@ export default function envVitePlugin({
replacements = Object.assign(replacements, {
'import.meta.env.SITE': astroConfig.site ? `'${astroConfig.site}'` : 'undefined',
'import.meta.env.SSR': JSON.stringify(true),
'import.meta.env.STREAMING': JSON.stringify(astroConfig.server.streaming),
// This catches destructed `import.meta.env` calls,
// BUT we only want to inject private keys referenced in the file.
// We overwrite this value on a per-file basis.
Expand Down
64 changes: 64 additions & 0 deletions packages/astro/test/streaming.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,67 @@ 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 not chunked', async () => {
let res = await fixture.fetch('/');

expect(res.status).to.equal(200);
expect(res.headers.get('content-type')).to.equal('text/html');

let body = await res.text();
expect(body.startsWith('<!DOCTYPE html>')).to.equal(true);
});
});

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:https://example.com/');
const response = await app.render(request);

expect(response.status).to.equal(200);
expect(response.headers.get('content-type')).to.equal('text/html');

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);
});
});
});
7 changes: 7 additions & 0 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export default function createIntegration(): AstroIntegration {
return {
name: '@astrojs/cloudflare',
hooks: {
'astro:config:setup': ({ updateConfig }) => {
updateConfig({
server: {
streaming: false
}
});
},
'astro:config:done': ({ setAdapter, config }) => {
setAdapter(getAdapter());
_config = config;
Expand Down