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,