diff --git a/.changeset/cold-cooks-battle.md b/.changeset/cold-cooks-battle.md
new file mode 100644
index 000000000000..451aa2bab09a
--- /dev/null
+++ b/.changeset/cold-cooks-battle.md
@@ -0,0 +1,21 @@
+---
+'@astrojs/node': minor
+---
+
+Adds a new experimental configuration option `experimentalDisableStreaming` to allow you to opt out of Astro's default [HTML streaming](https://docs.astro.build/en/guides/on-demand-rendering/#html-streaming) for pages rendered on demand.
+
+HTML streaming helps with performance and generally provides a better visitor experience. In most cases, disabling streaming is not recommended.
+
+However, when you need to disable HTML streaming (e.g. your host only supports non-streamed HTML caching at the CDN level), you can now opt out of the default behavior:
+
+```diff
+import { defineConfig } from 'astro/config';
+import node from '@astrojs/node';
+
+export default defineConfig({
+ adapter: node({
+ mode: 'standalone',
++ experimentalDisableStreaming: true,
+ }),
+});
+```
diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts
index 0ed42cb3bb01..84506d70aac5 100644
--- a/packages/integrations/node/src/server.ts
+++ b/packages/integrations/node/src/server.ts
@@ -13,7 +13,7 @@ import type { Options } from './types.js';
setGetEnv((key) => process.env[key]);
export function createExports(manifest: SSRManifest, options: Options) {
- const app = new NodeApp(manifest);
+ const app = new NodeApp(manifest, !options.experimentalDisableStreaming);
let headersMap: NodeAppHeadersJson | undefined = undefined;
if (options.experimentalStaticHeaders) {
headersMap = readHeadersJson(manifest.outDir);
@@ -41,7 +41,7 @@ export function start(manifest: SSRManifest, options: Options) {
headersMap = readHeadersJson(manifest.outDir);
}
- const app = new NodeApp(manifest);
+ const app = new NodeApp(manifest, !options.experimentalDisableStreaming);
if (headersMap) {
app.setHeadersMap(headersMap);
}
diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts
index 5cf225270347..4dae922436f0 100644
--- a/packages/integrations/node/src/types.ts
+++ b/packages/integrations/node/src/types.ts
@@ -9,6 +9,10 @@ export interface UserOptions {
* - 'standalone' - Build to a standalone server. The server starts up just by running the built script.
*/
mode: 'middleware' | 'standalone';
+ /**
+ * Disables HTML streaming. This is useful for example if there are constraints from your host.
+ */
+ experimentalDisableStreaming?: boolean;
/**
* If enabled, the adapter will save [static headers in the framework API file](https://docs.netlify.com/frameworks-api/#headers).
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-simple.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-simple.astro
new file mode 100644
index 000000000000..930e370cdb29
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-simple.astro
@@ -0,0 +1,8 @@
+---
+import Wrapper from "../components/Wrapper.astro";
+import Wait from "../components/Wait.astro";
+---
+
+
+
+
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/components/Wait.astro b/packages/integrations/node/test/fixtures/headers/src/pages/components/Wait.astro
new file mode 100644
index 000000000000..8052f00a6b21
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/components/Wait.astro
@@ -0,0 +1,5 @@
+---
+await new Promise(r => setTimeout(r, 500))
+---
+
+
hello world
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/components/Wrapper.astro b/packages/integrations/node/test/fixtures/headers/src/pages/components/Wrapper.astro
new file mode 100644
index 000000000000..1e0b7c541b19
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/components/Wrapper.astro
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/packages/integrations/node/test/headers.test.js b/packages/integrations/node/test/headers.test.js
index f2753517e975..15f52a434d25 100644
--- a/packages/integrations/node/test/headers.test.js
+++ b/packages/integrations/node/test/headers.test.js
@@ -7,124 +7,171 @@ describe('Node Adapter Headers', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
- before(async () => {
- fixture = await loadFixture({
- root: './fixtures/headers/',
- output: 'server',
- adapter: nodejs({ mode: 'middleware' }),
+ describe('streaming', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/headers/',
+ output: 'server',
+ adapter: nodejs({ mode: 'middleware' }),
+ });
+ await fixture.build();
});
- await fixture.build();
- });
- it('Endpoint Simple Headers', async () => {
- await runTest('/endpoints/simple', {
- 'content-type': 'text/plain;charset=utf-8',
- 'x-hello': 'world',
+ it('Endpoint Simple Headers', async () => {
+ await runTest('/endpoints/simple', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'x-hello': 'world',
+ });
});
- });
- it('Endpoint Astro Single Cookie Header', async () => {
- await runTest('/endpoints/astro-cookies-single', {
- 'content-type': 'text/plain;charset=utf-8',
- 'set-cookie': 'from1=astro1',
+ it('Endpoint Astro Single Cookie Header', async () => {
+ await runTest('/endpoints/astro-cookies-single', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': 'from1=astro1',
+ });
});
- });
- it('Endpoint Astro Multi Cookie Header', async () => {
- await runTest('/endpoints/astro-cookies-multi', {
- 'content-type': 'text/plain;charset=utf-8',
- 'set-cookie': ['from1=astro1', 'from2=astro2'],
+ it('Endpoint Astro Multi Cookie Header', async () => {
+ await runTest('/endpoints/astro-cookies-multi', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': ['from1=astro1', 'from2=astro2'],
+ });
});
- });
- it('Endpoint Response Single Cookie Header', async () => {
- await runTest('/endpoints/response-cookies-single', {
- 'content-type': 'text/plain;charset=utf-8',
- 'set-cookie': 'hello1=world1',
+ it('Endpoint Response Single Cookie Header', async () => {
+ await runTest('/endpoints/response-cookies-single', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': 'hello1=world1',
+ });
});
- });
- it('Endpoint Response Multi Cookie Header', async () => {
- await runTest('/endpoints/response-cookies-multi', {
- 'content-type': 'text/plain;charset=utf-8',
- 'set-cookie': ['hello1=world1', 'hello2=world2'],
+ it('Endpoint Response Multi Cookie Header', async () => {
+ await runTest('/endpoints/response-cookies-multi', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': ['hello1=world1', 'hello2=world2'],
+ });
});
- });
- it('Endpoint Complex Headers Kitchen Sink', async () => {
- await runTest('/endpoints/kitchen-sink', {
- 'content-type': 'text/plain;charset=utf-8',
- 'x-single': 'single',
- 'x-triple': 'one, two, three',
- 'set-cookie': ['hello1=world1', 'hello2=world2'],
+ it('Endpoint Complex Headers Kitchen Sink', async () => {
+ await runTest('/endpoints/kitchen-sink', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'x-single': 'single',
+ 'x-triple': 'one, two, three',
+ 'set-cookie': ['hello1=world1', 'hello2=world2'],
+ });
});
- });
- it('Endpoint Astro and Response Single Cookie Header', async () => {
- await runTest('/endpoints/astro-response-cookie-single', {
- 'content-type': 'text/plain;charset=utf-8',
- 'set-cookie': ['from1=response1', 'from1=astro1'],
+ it('Endpoint Astro and Response Single Cookie Header', async () => {
+ await runTest('/endpoints/astro-response-cookie-single', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': ['from1=response1', 'from1=astro1'],
+ });
});
- });
- it('Endpoint Astro and Response Multi Cookie Header', async () => {
- await runTest('/endpoints/astro-response-cookie-multi', {
- 'content-type': 'text/plain;charset=utf-8',
- 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'],
+ it('Endpoint Astro and Response Multi Cookie Header', async () => {
+ await runTest('/endpoints/astro-response-cookie-multi', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'],
+ });
});
- });
- it('Endpoint Response Empty Headers Object', async () => {
- await runTest('/endpoints/response-empty-headers-object', {
- 'content-type': 'text/plain;charset=UTF-8',
+ it('Endpoint Response Empty Headers Object', async () => {
+ await runTest('/endpoints/response-empty-headers-object', {
+ 'content-type': 'text/plain;charset=UTF-8',
+ });
});
- });
- it('Endpoint Response undefined Headers Object', async () => {
- await runTest('/endpoints/response-undefined-headers-object', {
- 'content-type': 'text/plain;charset=UTF-8',
+ it('Endpoint Response undefined Headers Object', async () => {
+ await runTest('/endpoints/response-undefined-headers-object', {
+ 'content-type': 'text/plain;charset=UTF-8',
+ });
});
- });
- it('Component Astro Single Cookie Header', async () => {
- await runTest('/astro/component-astro-cookies-single', {
- 'content-type': 'text/html',
- 'set-cookie': 'from1=astro1',
+ it('Component Astro Single Cookie Header', async () => {
+ await runTest('/astro/component-astro-cookies-single', {
+ 'content-type': 'text/html',
+ 'set-cookie': 'from1=astro1',
+ });
});
- });
- it('Component Astro Multi Cookie Header', async () => {
- await runTest('/astro/component-astro-cookies-multi', {
- 'content-type': 'text/html',
- 'set-cookie': ['from1=astro1', 'from2=astro2'],
+ it('Component Astro Multi Cookie Header', async () => {
+ await runTest('/astro/component-astro-cookies-multi', {
+ 'content-type': 'text/html',
+ 'set-cookie': ['from1=astro1', 'from2=astro2'],
+ });
});
- });
- it('Component Response Single Cookie Header', async () => {
- await runTest('/astro/component-response-cookies-single', {
- 'content-type': 'text/html',
- 'set-cookie': 'from1=value1',
+ it('Component Response Single Cookie Header', async () => {
+ await runTest('/astro/component-response-cookies-single', {
+ 'content-type': 'text/html',
+ 'set-cookie': 'from1=value1',
+ });
});
- });
- it('Component Response Multi Cookie Header', async () => {
- await runTest('/astro/component-response-cookies-multi', {
- 'content-type': 'text/html',
- 'set-cookie': ['from1=value1', 'from2=value2'],
+ it('Component Response Multi Cookie Header', async () => {
+ await runTest('/astro/component-response-cookies-multi', {
+ 'content-type': 'text/html',
+ 'set-cookie': ['from1=value1', 'from2=value2'],
+ });
});
- });
- it('Component Astro and Response Single Cookie Header', async () => {
- await runTest('/astro/component-astro-response-cookie-single', {
- 'content-type': 'text/html',
- 'set-cookie': ['from1=response1', 'from1=astro1'],
+ it('Component Astro and Response Single Cookie Header', async () => {
+ await runTest('/astro/component-astro-response-cookie-single', {
+ 'content-type': 'text/html',
+ 'set-cookie': ['from1=response1', 'from1=astro1'],
+ });
+ });
+
+ it('Component Astro and Response Multi Cookie Header', async () => {
+ await runTest('/astro/component-astro-response-cookie-multi', {
+ 'content-type': 'text/html',
+ 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'],
+ });
+ });
+
+ // TODO: needs e2e tests to check real headers
+ it('sends several chunks', async () => {
+ const { handler } = await import('./fixtures/headers/dist/server/entry.mjs');
+
+ const { req, res, done } = createRequestAndResponse({
+ method: 'GET',
+ url: '/astro/component-simple',
+ });
+
+ handler(req, res);
+
+ req.send();
+
+ const chunks = await done;
+ assert.equal(chunks.length, 3);
});
});
- it('Component Astro and Response Multi Cookie Header', async () => {
- await runTest('/astro/component-astro-response-cookie-multi', {
- 'content-type': 'text/html',
- 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'],
+ describe('without streaming', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/headers/',
+ output: 'server',
+ adapter: nodejs({ mode: 'middleware', experimentalDisableStreaming: true }),
+ });
+ await fixture.build();
+ });
+
+ // TODO: needs e2e tests to check real headers
+ it('sends a single chunk', async () => {
+ const { handler } = await import('./fixtures/headers/dist/server/entry.mjs?cachebust=0');
+
+ const { req, res, done } = createRequestAndResponse({
+ method: 'GET',
+ url: '/astro/component-simple',
+ });
+
+ handler(req, res);
+
+ req.send();
+
+ const chunks = await done;
+ assert.equal(chunks.length, 1);
});
});
});
diff --git a/packages/integrations/node/test/test-utils.js b/packages/integrations/node/test/test-utils.js
index b7720d690c3d..8553a159e09c 100644
--- a/packages/integrations/node/test/test-utils.js
+++ b/packages/integrations/node/test/test-utils.js
@@ -38,6 +38,7 @@ export function createRequestAndResponse(reqOptions) {
return { req, res, done, text };
}
+/** @returns {Promise>} */
function toPromise(res) {
return new Promise((resolve) => {
// node-mocks-http doesn't correctly handle non-Buffer typed arrays,