diff --git a/.changeset/angry-rats-turn.md b/.changeset/angry-rats-turn.md new file mode 100644 index 000000000..342e2dff2 --- /dev/null +++ b/.changeset/angry-rats-turn.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Implements the Astro.response RFC diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 805eeb407..f396699d1 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -131,6 +131,18 @@ export interface AstroGlobal extends AstroGlobalPartial { * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astrorequest) */ request: Request; + /** Information about the outgoing response. This is a standard [ResponseInit](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#init) object + * + * For example, to change the status code you can set a different status on this object: + * ```typescript + * Astro.response.status = 404; + * ``` + * + * [Astro reference](https://docs.astro.build/en/reference/api-reference/#astroresponse) + */ + response: ResponseInit & { + readonly headers: Headers + }; /** Redirect to another page (**SSR Only**) * * Example usage: @@ -1031,5 +1043,6 @@ export interface SSRResult { slots: Record | null ): AstroGlobal; resolve: (s: string) => Promise; + response: ResponseInit; _metadata: SSRMetadata; } diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 307508fc7..2d6554380 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -108,14 +108,12 @@ export class App { } let html = result.html; + let init = result.response; + let headers = init.headers as Headers; let bytes = this.#encoder.encode(html); - return new Response(bytes, { - status: 200, - headers: { - 'Content-Type': 'text/html', - 'Content-Length': bytes.byteLength.toString(), - }, - }); + headers.set('Content-Type', 'text/html'); + headers.set('Content-Length', bytes.byteLength.toString()) + return new Response(bytes, init); } async #callEndpoint( diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index 0adac347e..333e9eec8 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -84,7 +84,7 @@ export interface RenderOptions { export async function render( opts: RenderOptions -): Promise<{ type: 'html'; html: string } | { type: 'response'; response: Response }> { +): Promise<{ type: 'html'; html: string, response: ResponseInit; } | { type: 'response'; response: Response }> { const { links, logging, @@ -162,5 +162,6 @@ export async function render( return { type: 'html', html, + response: result.response }; } diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index 410cb965b..07af9134f 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -45,7 +45,7 @@ export interface SSROptions { export type ComponentPreload = [SSRLoadedRenderer[], ComponentInstance]; export type RenderResponse = - | { type: 'html'; html: string } + | { type: 'html'; html: string, response: ResponseInit } | { type: 'response'; response: Response }; const svelteStylesRE = /svelte\?svelte&type=style/; @@ -199,6 +199,7 @@ export async function render( return { type: 'html', html, + response: content.response, }; } diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 358bef7e0..c72d1ae59 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -104,6 +104,18 @@ export function createResult(args: CreateResultArgs): SSRResult { const url = new URL(request.url); const canonicalURL = createCanonicalURL('.' + pathname, site ?? url.origin); + const response: ResponseInit = { + status: 200, + statusText: 'OK', + headers: new Headers(), + }; + + // Make headers be read-only + Object.defineProperty(response, 'headers', { + value: response.headers, + enumerable: true, + writable: false, + }); // Create the result object that will be passed into the render function. // This object starts here as an empty shell (not yet the result) but then @@ -168,6 +180,7 @@ ${extra}` // Intentionally return an empty string so that it is not relied upon. return ''; }, + response, slots: astroSlots, } as unknown as AstroGlobal; @@ -202,6 +215,7 @@ ${extra}` renderers, pathname, }, + response }; return result; diff --git a/packages/astro/src/vite-plugin-astro-server/index.ts b/packages/astro/src/vite-plugin-astro-server/index.ts index 0fdb8705b..5c8cafd77 100644 --- a/packages/astro/src/vite-plugin-astro-server/index.ts +++ b/packages/astro/src/vite-plugin-astro-server/index.ts @@ -94,8 +94,13 @@ async function writeSSRResult( return; } - const { html } = result; - writeHtmlResponse(res, statusCode, html); + const { html, response: init } = result; + const headers = init.headers as Headers; + + headers.set('Content-Type', 'text/html; charset=utf-8'); + headers.set('Content-Length', Buffer.byteLength(html, 'utf-8').toString()); + + return writeWebResponse(res, new Response(html, init)); } async function handle404Response( diff --git a/packages/astro/test/fixtures/ssr-response/src/components/OverwriteHeader.astro b/packages/astro/test/fixtures/ssr-response/src/components/OverwriteHeader.astro new file mode 100644 index 000000000..e3a99c6c3 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-response/src/components/OverwriteHeader.astro @@ -0,0 +1,9 @@ +--- +let gotError = false; +try { + Astro.response.headers = new Headers(); +} catch(err) { + gotError = true; +} +--- +
{gotError}
diff --git a/packages/astro/test/fixtures/ssr-response/src/components/SetAHeader.astro b/packages/astro/test/fixtures/ssr-response/src/components/SetAHeader.astro new file mode 100644 index 000000000..4186561f4 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-response/src/components/SetAHeader.astro @@ -0,0 +1,3 @@ +--- +Astro.response.headers.set('Seven-Eight', 'nine'); +--- diff --git a/packages/astro/test/fixtures/ssr-response/src/components/SetStatus.astro b/packages/astro/test/fixtures/ssr-response/src/components/SetStatus.astro new file mode 100644 index 000000000..d98f3a8e1 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-response/src/components/SetStatus.astro @@ -0,0 +1,3 @@ +--- +Astro.response.status = 403; +--- diff --git a/packages/astro/test/fixtures/ssr-response/src/pages/child-set-status.astro b/packages/astro/test/fixtures/ssr-response/src/pages/child-set-status.astro new file mode 100644 index 000000000..8b66882f1 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-response/src/pages/child-set-status.astro @@ -0,0 +1,12 @@ +--- +import SetStatus from '../components/SetStatus.astro'; +--- + + + Testing + + +

Testing

+ + + diff --git a/packages/astro/test/fixtures/ssr-response/src/pages/child-tries-to-overwrite.astro b/packages/astro/test/fixtures/ssr-response/src/pages/child-tries-to-overwrite.astro new file mode 100644 index 000000000..35a207de7 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-response/src/pages/child-tries-to-overwrite.astro @@ -0,0 +1,14 @@ +--- +import SetAHeader from '../components/SetAHeader.astro'; +import OverwriteHeader from '../components/OverwriteHeader.astro'; +--- + + + Testing + + +

Testing

+ + + + diff --git a/packages/astro/test/fixtures/ssr-response/src/pages/some-header.astro b/packages/astro/test/fixtures/ssr-response/src/pages/some-header.astro new file mode 100644 index 000000000..f2840ff94 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-response/src/pages/some-header.astro @@ -0,0 +1,14 @@ +--- +import SetAHeader from '../components/SetAHeader.astro'; +Astro.response.headers.set('One-Two', 'three'); +Astro.response.headers.set('Four-Five', 'six'); +--- + + + Testing + + +

Testing

+ + + diff --git a/packages/astro/test/fixtures/ssr-response/src/pages/status-code.astro b/packages/astro/test/fixtures/ssr-response/src/pages/status-code.astro new file mode 100644 index 000000000..9d183beb0 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-response/src/pages/status-code.astro @@ -0,0 +1,12 @@ +--- +Astro.response.status = 404; +Astro.response.statusText = 'Oops'; +--- + + + Testing + + +

Testing

+ + diff --git a/packages/astro/test/ssr-adapter-build-config.test.js b/packages/astro/test/ssr-adapter-build-config.test.js index 427049532..bb1c4514a 100644 --- a/packages/astro/test/ssr-adapter-build-config.test.js +++ b/packages/astro/test/ssr-adapter-build-config.test.js @@ -3,7 +3,6 @@ import { load as cheerioLoad } from 'cheerio'; import { loadFixture } from './test-utils.js'; import { viteID } from '../dist/core/util.js'; -// Asset bundling describe('Integration buildConfig hook', () => { /** @type {import('./test-utils').Fixture} */ let fixture; diff --git a/packages/astro/test/ssr-api-route.test.js b/packages/astro/test/ssr-api-route.test.js index 496b3a412..e4758f10e 100644 --- a/packages/astro/test/ssr-api-route.test.js +++ b/packages/astro/test/ssr-api-route.test.js @@ -2,7 +2,6 @@ import { expect } from 'chai'; import { loadFixture } from './test-utils.js'; import testAdapter from './test-adapter.js'; -// Asset bundling describe('API routes in SSR', () => { /** @type {import('./test-utils').Fixture} */ let fixture; diff --git a/packages/astro/test/ssr-dynamic.test.js b/packages/astro/test/ssr-dynamic.test.js index 47157e10b..8c200f1c5 100644 --- a/packages/astro/test/ssr-dynamic.test.js +++ b/packages/astro/test/ssr-dynamic.test.js @@ -3,7 +3,6 @@ import { load as cheerioLoad } from 'cheerio'; import { loadFixture } from './test-utils.js'; import testAdapter from './test-adapter.js'; -// Asset bundling describe('Dynamic pages in SSR', () => { /** @type {import('./test-utils').Fixture} */ let fixture; diff --git a/packages/astro/test/ssr-request.test.js b/packages/astro/test/ssr-request.test.js index 0cba2a49e..a9c1dd6b6 100644 --- a/packages/astro/test/ssr-request.test.js +++ b/packages/astro/test/ssr-request.test.js @@ -3,7 +3,6 @@ import { load as cheerioLoad } from 'cheerio'; import { loadFixture } from './test-utils.js'; import testAdapter from './test-adapter.js'; -// Asset bundling describe('Using Astro.request in SSR', () => { /** @type {import('./test-utils').Fixture} */ let fixture; diff --git a/packages/astro/test/ssr-response.test.js b/packages/astro/test/ssr-response.test.js new file mode 100644 index 000000000..1b517d9ee --- /dev/null +++ b/packages/astro/test/ssr-response.test.js @@ -0,0 +1,62 @@ +import { expect } from 'chai'; +import { load as cheerioLoad } from 'cheerio'; +import { loadFixture } from './test-utils.js'; +import testAdapter from './test-adapter.js'; + +describe('Using Astro.response in SSR', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-response/', + adapter: testAdapter(), + experimental: { + ssr: true, + }, + }); + await fixture.build(); + }); + + it('Can set the status', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/status-code'); + const response = await app.render(request); + expect(response.status).to.equal(404); + }); + + it('Can set the statusText', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/status-code'); + const response = await app.render(request); + expect(response.statusText).to.equal('Oops'); + }); + + it('Child component can set status', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/child-set-status'); + const response = await app.render(request); + expect(response.status).to.equal(403); + }); + + it('Can add headers', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/some-header'); + const response = await app.render(request); + const headers = response.headers; + expect(headers.get('one-two')).to.equal('three'); + expect(headers.get('four-five')).to.equal('six'); + expect(headers.get('seven-eight')).to.equal('nine'); + }); + + it('Child component cannot override headers object', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/child-tries-to-overwrite'); + const response = await app.render(request); + const headers = response.headers; + expect(headers.get('seven-eight')).to.equal('nine'); + const html = await response.text(); + const $ = cheerioLoad(html); + expect($('#overwrite-error').html()).to.equal('true'); + }); +});