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:
parent
f9290b328a
commit
976e1f175a
16 changed files with 158 additions and 29 deletions
5
.changeset/curly-worms-agree.md
Normal file
5
.changeset/curly-worms-agree.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@astrojs/cloudflare': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Disables HTTP streaming in Cloudflare Pages deployments
|
5
.changeset/two-dolls-marry.md
Normal file
5
.changeset/two-dolls-marry.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Adds an option to disable HTTP streaming in Astro's production `App` server
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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({})
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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) }; }`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
9
pnpm-lock.yaml
generated
|
@ -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:
|
||||||
|
|
Loading…
Add table
Reference in a new issue