diff --git a/.changeset/hot-pumas-bathe.md b/.changeset/hot-pumas-bathe.md new file mode 100644 index 000000000..ad559b934 --- /dev/null +++ b/.changeset/hot-pumas-bathe.md @@ -0,0 +1,5 @@ +--- +'@astrojs/netlify': patch +--- + +Adds support for base64 encoded responses in Netlify Functions diff --git a/packages/integrations/netlify/README.md b/packages/integrations/netlify/README.md index b0daeadef..dba81568e 100644 --- a/packages/integrations/netlify/README.md +++ b/packages/integrations/netlify/README.md @@ -58,3 +58,27 @@ And then point to the dist in your `netlify.toml`: [functions] directory = "dist/functions" ``` + +### binaryMediaTypes + +> This option is only needed for the Functions adapter and is not needed for Edge Functions. + +Netlify Functions sending binary data in the `body` need to be base64 encoded. The `@astrojs/netlify/functions` adapter handles this automatically based on the `Content-Type` header. + +We check for common mime types for audio, image, and video files. To include specific mime types that should be treated as binary data, include the `binaryMediaTypes` option with a list of binary mime types. + +```js +import fs from 'node:fs'; + +export function get() { + const buffer = fs.readFileSync('../image.jpg'); + + // Return the buffer directly, @astrojs/netlify will base64 encode the body + return new Response(buffer, { + status: 200, + headers: { + 'content-type': 'image/jpeg' + } + }); +} +``` diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts index 8f9b291fa..4242a7b00 100644 --- a/packages/integrations/netlify/src/integration-functions.ts +++ b/packages/integrations/netlify/src/integration-functions.ts @@ -1,20 +1,22 @@ import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro'; import { createRedirects } from './shared.js'; +import type { Args } from './netlify-functions.js'; -export function getAdapter(): AstroAdapter { +export function getAdapter(args: Args = {}): AstroAdapter { return { name: '@astrojs/netlify/functions', serverEntrypoint: '@astrojs/netlify/netlify-functions.js', exports: ['handler'], - args: {}, + args, }; } interface NetlifyFunctionsOptions { dist?: URL; + binaryMediaTypes?: string[]; } -function netlifyFunctions({ dist }: NetlifyFunctionsOptions = {}): AstroIntegration { +function netlifyFunctions({ dist, binaryMediaTypes }: NetlifyFunctionsOptions = {}): AstroIntegration { let _config: AstroConfig; let entryFile: string; return { @@ -28,7 +30,7 @@ function netlifyFunctions({ dist }: NetlifyFunctionsOptions = {}): AstroIntegrat } }, 'astro:config:done': ({ config, setAdapter }) => { - setAdapter(getAdapter()); + setAdapter(getAdapter({ binaryMediaTypes })); _config = config; }, 'astro:build:start': async ({ buildConfig }) => { diff --git a/packages/integrations/netlify/src/netlify-functions.ts b/packages/integrations/netlify/src/netlify-functions.ts index 66e5271f5..f8b3ab21b 100644 --- a/packages/integrations/netlify/src/netlify-functions.ts +++ b/packages/integrations/netlify/src/netlify-functions.ts @@ -7,11 +7,49 @@ polyfill(globalThis, { exclude: 'window document', }); -interface Args {} +export interface Args { + binaryMediaTypes?: string[]; +} + +function parseContentType(header?: string) { + return header?.split(';')[0] ?? ''; +} export const createExports = (manifest: SSRManifest, args: Args) => { const app = new App(manifest); + const binaryMediaTypes = args.binaryMediaTypes ?? []; + const knownBinaryMediaTypes = new Set([ + 'audio/3gpp', + 'audio/3gpp2', + 'audio/aac', + 'audio/midi', + 'audio/mpeg', + 'audio/ogg', + 'audio/opus', + 'audio/wav', + 'audio/webm', + 'audio/x-midi', + 'image/avif', + 'image/bmp', + 'image/gif', + 'image/vnd.microsoft.icon', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/tiff', + 'image/webp', + 'video/3gpp', + 'video/3gpp2', + 'video/mp2t', + 'video/mp4', + 'video/mpeg', + 'video/ogg', + 'video/x-msvideo', + 'video/webm', + ...binaryMediaTypes, + ]); + const handler: Handler = async (event) => { const { httpMethod, headers, rawUrl, body: requestBody, isBase64Encoded } = event; const init: RequestInit = { @@ -34,13 +72,20 @@ export const createExports = (manifest: SSRManifest, args: Args) => { } const response: Response = await app.render(request); - const responseBody = await response.text(); - const responseHeaders = Object.fromEntries(response.headers.entries()); + + const responseContentType = parseContentType(responseHeaders['content-type']); + const responseIsBase64Encoded = knownBinaryMediaTypes.has(responseContentType); + + const responseBody = responseIsBase64Encoded + ? Buffer.from(await response.text(), 'binary').toString('base64') + : await response.text(); + const fnResponse: any = { statusCode: response.status, headers: responseHeaders, body: responseBody, + isBase64Encoded: responseIsBase64Encoded, }; // Special-case set-cookie which has to be set an different way :/ diff --git a/packages/integrations/netlify/test/functions/base64-response.test.js b/packages/integrations/netlify/test/functions/base64-response.test.js new file mode 100644 index 000000000..10d43b046 --- /dev/null +++ b/packages/integrations/netlify/test/functions/base64-response.test.js @@ -0,0 +1,64 @@ +import { expect } from 'chai'; +import { loadFixture, testIntegration } from './test-utils.js'; +import netlifyAdapter from '../../dist/index.js'; + +describe('Base64 Responses', () => { + /** @type {import('../../../astro/test/test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/base64-response/', import.meta.url).toString(), + experimental: { + ssr: true, + }, + adapter: netlifyAdapter({ + dist: new URL('./fixtures/base64-response/dist/', import.meta.url), + binaryMediaTypes: ['font/otf'] + }), + site: `http://example.com`, + integrations: [testIntegration()], + }); + await fixture.build(); + }); + + it('Can return base64 encoded strings', async () => { + const entryURL = new URL( + './fixtures/base64-response/.netlify/functions-internal/entry.mjs', + import.meta.url + ); + const { handler } = await import(entryURL); + const resp = await handler({ + httpMethod: 'GET', + headers: {}, + rawUrl: 'http://example.com/image', + body: '{}', + isBase64Encoded: false, + }); + expect(resp.statusCode, 'successful response').to.equal(200); + expect(resp.isBase64Encoded, 'includes isBase64Encoded flag').to.be.true; + + const buffer = Buffer.from(resp.body, 'base64'); + expect(buffer.toString(), 'decoded base64 string matches').to.equal('base64 test string'); + }); + + it('Can define custom binaryMediaTypes', async () => { + const entryURL = new URL( + './fixtures/base64-response/.netlify/functions-internal/entry.mjs', + import.meta.url + ); + const { handler } = await import(entryURL); + const resp = await handler({ + httpMethod: 'GET', + headers: {}, + rawUrl: 'http://example.com/font', + body: '{}', + isBase64Encoded: false, + }); + expect(resp.statusCode, 'successful response').to.equal(200); + expect(resp.isBase64Encoded, 'includes isBase64Encoded flag').to.be.true; + + const buffer = Buffer.from(resp.body, 'base64'); + expect(buffer.toString(), 'decoded base64 string matches').to.equal('base64 test font'); + }); +}); diff --git a/packages/integrations/netlify/test/functions/fixtures/base64-response/src/pages/font.js b/packages/integrations/netlify/test/functions/fixtures/base64-response/src/pages/font.js new file mode 100644 index 000000000..3ec4c8364 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/base64-response/src/pages/font.js @@ -0,0 +1,11 @@ + +export function get() { + const buffer = Buffer.from('base64 test font', 'utf-8') + + return new Response(buffer, { + status: 200, + headers: { + 'Content-Type': 'font/otf' + } + }); +} diff --git a/packages/integrations/netlify/test/functions/fixtures/base64-response/src/pages/image.js b/packages/integrations/netlify/test/functions/fixtures/base64-response/src/pages/image.js new file mode 100644 index 000000000..ca3b4d9d3 --- /dev/null +++ b/packages/integrations/netlify/test/functions/fixtures/base64-response/src/pages/image.js @@ -0,0 +1,11 @@ + +export function get() { + const buffer = Buffer.from('base64 test string', 'utf-8') + + return new Response(buffer, { + status: 200, + headers: { + 'content-type': 'image/jpeg;foo=foo' + } + }); +}