diff --git a/.changeset/proud-mayflies-drum.md b/.changeset/proud-mayflies-drum.md new file mode 100644 index 000000000000..b68605f67e48 --- /dev/null +++ b/.changeset/proud-mayflies-drum.md @@ -0,0 +1,5 @@ +--- +'@astrojs/netlify': minor +--- + +Adds support for Netlify Edge Functions diff --git a/packages/integrations/netlify/src/README.md b/packages/integrations/netlify/README.md similarity index 67% rename from packages/integrations/netlify/src/README.md rename to packages/integrations/netlify/README.md index 1a77ed598bc4..bd98cc957849 100644 --- a/packages/integrations/netlify/src/README.md +++ b/packages/integrations/netlify/README.md @@ -21,6 +21,20 @@ Now you can deploy! netlify deploy ``` +## Edge Functions + +Netlify has two serverless platforms, Netlify Functions and Netlify Edge Functions. With Edge Functions your code is distributed closer to your users, lowering latency. You can use Edge Functions by changing the import in your astro configuration file: + +```diff +import { defineConfig } from 'astro/config'; +- import netlify from '@astrojs/netlify/functions'; ++ import netlify from '@astrojs/netlify/edge-functions'; + +export default defineConfig({ + adapter: netlify(), +}); +``` + ## Configuration ### dist diff --git a/packages/integrations/netlify/package.json b/packages/integrations/netlify/package.json index abf394f5ec9a..bcb979cf5ca3 100644 --- a/packages/integrations/netlify/package.json +++ b/packages/integrations/netlify/package.json @@ -17,13 +17,17 @@ ".": "./dist/index.js", "./functions": "./dist/integration-functions.js", "./netlify-functions.js": "./dist/netlify-functions.js", + "./edge-functions": "./dist/integration-edge-functions.js", + "./netlify-edge-functions.js": "./dist/netlify-edge-functions.js", "./package.json": "./package.json" }, "scripts": { "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", "dev": "astro-scripts dev \"src/**/*.ts\"", - "test": "mocha --exit --timeout 20000" + "test-fn": "mocha --exit --timeout 20000 test/functions/", + "test-edge": "deno test --allow-run --allow-read --allow-net ./test/edge-functions/", + "test": "npm run test-fn" }, "dependencies": { "@astrojs/webapi": "^0.11.1" diff --git a/packages/integrations/netlify/src/edge-shim.ts b/packages/integrations/netlify/src/edge-shim.ts new file mode 100644 index 000000000000..1a4a6ee9be4c --- /dev/null +++ b/packages/integrations/netlify/src/edge-shim.ts @@ -0,0 +1,4 @@ +(globalThis as any).process = { + argv: [], + env: {}, +}; diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index c473de67ae63..121495652859 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -1,65 +1,8 @@ -import type { AstroAdapter, AstroIntegration, AstroConfig } from 'astro'; -import fs from 'fs'; - -export function getAdapter(): AstroAdapter { - return { - name: '@astrojs/netlify', - serverEntrypoint: '@astrojs/netlify/netlify-functions.js', - exports: ['handler'], - args: {}, - }; -} - -interface NetlifyFunctionsOptions { - dist?: URL; -} - -function netlifyFunctions({ dist }: NetlifyFunctionsOptions = {}): AstroIntegration { - let _config: AstroConfig; - let entryFile: string; - return { - name: '@astrojs/netlify', - hooks: { - 'astro:config:setup': ({ config }) => { - if (dist) { - config.outDir = dist; - } else { - config.outDir = new URL('./netlify/', config.root); - } - }, - 'astro:config:done': ({ config, setAdapter }) => { - setAdapter(getAdapter()); - _config = config; - }, - 'astro:build:start': async ({ buildConfig }) => { - entryFile = buildConfig.serverEntry.replace(/\.m?js/, ''); - buildConfig.client = _config.outDir; - buildConfig.server = new URL('./functions/', _config.outDir); - }, - 'astro:build:done': async ({ routes, dir }) => { - const _redirectsURL = new URL('./_redirects', dir); - - // Create the redirects file that is used for routing. - let _redirects = ''; - for (const route of routes) { - if (route.pathname) { - _redirects += ` -${route.pathname} /.netlify/functions/${entryFile} 200`; - } else { - const pattern = - '/' + route.segments.map(([part]) => (part.dynamic ? '*' : part.content)).join('/'); - _redirects += ` -${pattern} /.netlify/functions/${entryFile} 200`; - } - } - - // Always use appendFile() because the redirects file could already exist, - // e.g. due to a `/public/_redirects` file that got copied to the output dir. - // If the file does not exist yet, appendFile() automatically creates it. - await fs.promises.appendFile(_redirectsURL, _redirects, 'utf-8'); - }, - }, - }; -} - -export { netlifyFunctions, netlifyFunctions as default }; +export { + netlifyFunctions, + netlifyFunctions as default +} from './integration-functions.js'; + +export { + netlifyEdgeFunctions +} from './integration-edge-functions.js'; diff --git a/packages/integrations/netlify/src/integration-edge-functions.ts b/packages/integrations/netlify/src/integration-edge-functions.ts new file mode 100644 index 000000000000..fcce820f4ef2 --- /dev/null +++ b/packages/integrations/netlify/src/integration-edge-functions.ts @@ -0,0 +1,98 @@ +import type { AstroAdapter, AstroIntegration, AstroConfig, RouteData } from 'astro'; +import * as fs from 'fs'; + +export function getAdapter(): AstroAdapter { + return { + name: '@astrojs/netlify/edge-functions', + serverEntrypoint: '@astrojs/netlify/netlify-edge-functions.js', + exports: ['default'], + }; +} + +interface NetlifyEdgeFunctionsOptions { + dist?: URL; +} + +interface NetlifyEdgeFunctionManifestFunctionPath { + function: string; + path: string; +} + +interface NetlifyEdgeFunctionManifestFunctionPattern { + function: string; + pattern: string; +} + +type NetlifyEdgeFunctionManifestFunction = NetlifyEdgeFunctionManifestFunctionPath | NetlifyEdgeFunctionManifestFunctionPattern; + +interface NetlifyEdgeFunctionManifest { + functions: NetlifyEdgeFunctionManifestFunction[]; + version: 1; +} + +async function createEdgeManifest(routes: RouteData[], entryFile: string, dir: URL) { + const functions: NetlifyEdgeFunctionManifestFunction[] = []; + for(const route of routes) { + if(route.pathname) { + functions.push({ + function: entryFile, + path: route.pathname + }); + } else { + functions.push({ + function: entryFile, + pattern: route.pattern.source + }); + } + } + + const manifest: NetlifyEdgeFunctionManifest = { + functions, + version: 1 + }; + + const manifestURL = new URL('./manifest.json', dir); + const _manifest = JSON.stringify(manifest, null, ' '); + await fs.promises.writeFile(manifestURL, _manifest, 'utf-8'); +} + +export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}): AstroIntegration { + let _config: AstroConfig; + let entryFile: string; + return { + name: '@astrojs/netlify/edge-functions', + hooks: { + 'astro:config:setup': ({ config }) => { + if (dist) { + config.outDir = dist; + } else { + config.outDir = new URL('./netlify/', config.root); + } + }, + 'astro:config:done': ({ config, setAdapter }) => { + setAdapter(getAdapter()); + _config = config; + }, + 'astro:build:start': async ({ buildConfig }) => { + entryFile = buildConfig.serverEntry.replace(/\.m?js/, ''); + buildConfig.client = _config.outDir; + buildConfig.server = new URL('./edge-functions/', _config.outDir); + }, + 'astro:build:setup': ({ vite, target }) => { + if (target === 'server') { + vite.ssr = { + noExternal: true, + }; + } + }, + 'astro:build:done': async ({ routes, dir }) => { + + await createEdgeManifest(routes, entryFile, new URL('./edge-functions/', dir)); + }, + }, + }; +} + +export { + netlifyEdgeFunctions as default +} diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts index 5b687cda40c4..2720eb591167 100644 --- a/packages/integrations/netlify/src/integration-functions.ts +++ b/packages/integrations/netlify/src/integration-functions.ts @@ -1 +1,65 @@ -export { netlifyFunctions as default } from './index.js'; +import type { AstroAdapter, AstroIntegration, AstroConfig } from 'astro'; +import fs from 'fs'; + +export function getAdapter(): AstroAdapter { + return { + name: '@astrojs/netlify/functions', + serverEntrypoint: '@astrojs/netlify/netlify-functions.js', + exports: ['handler'], + args: {}, + }; +} + +interface NetlifyFunctionsOptions { + dist?: URL; +} + +function netlifyFunctions({ dist }: NetlifyFunctionsOptions = {}): AstroIntegration { + let _config: AstroConfig; + let entryFile: string; + return { + name: '@astrojs/netlify', + hooks: { + 'astro:config:setup': ({ config }) => { + if (dist) { + config.outDir = dist; + } else { + config.outDir = new URL('./netlify/', config.root); + } + }, + 'astro:config:done': ({ config, setAdapter }) => { + setAdapter(getAdapter()); + _config = config; + }, + 'astro:build:start': async ({ buildConfig }) => { + entryFile = buildConfig.serverEntry.replace(/\.m?js/, ''); + buildConfig.client = _config.outDir; + buildConfig.server = new URL('./functions/', _config.outDir); + }, + 'astro:build:done': async ({ routes, dir }) => { + const _redirectsURL = new URL('./_redirects', dir); + + // Create the redirects file that is used for routing. + let _redirects = ''; + for (const route of routes) { + if (route.pathname) { + _redirects += ` +${route.pathname} /.netlify/functions/${entryFile} 200`; + } else { + const pattern = + '/' + route.segments.map(([part]) => (part.dynamic ? '*' : part.content)).join('/'); + _redirects += ` +${pattern} /.netlify/functions/${entryFile} 200`; + } + } + + // Always use appendFile() because the redirects file could already exist, + // e.g. due to a `/public/_redirects` file that got copied to the output dir. + // If the file does not exist yet, appendFile() automatically creates it. + await fs.promises.appendFile(_redirectsURL, _redirects, 'utf-8'); + }, + }, + }; +} + +export { netlifyFunctions, netlifyFunctions as default }; diff --git a/packages/integrations/netlify/src/netlify-edge-functions.ts b/packages/integrations/netlify/src/netlify-edge-functions.ts new file mode 100644 index 000000000000..f7000442c2c1 --- /dev/null +++ b/packages/integrations/netlify/src/netlify-edge-functions.ts @@ -0,0 +1,20 @@ +import './edge-shim.js'; +import { SSRManifest } from 'astro'; +import { App } from 'astro/app'; + +export function createExports(manifest: SSRManifest) { + const app = new App(manifest); + + const handler = async (request: Request): Promise => { + if(app.match(request)) { + return app.render(request); + } + + return new Response(null, { + status: 404, + statusText: 'Not found' + }); + }; + + return { 'default': handler }; +} diff --git a/packages/integrations/netlify/test/edge-functions/deps.ts b/packages/integrations/netlify/test/edge-functions/deps.ts new file mode 100644 index 000000000000..f3e46181a67c --- /dev/null +++ b/packages/integrations/netlify/test/edge-functions/deps.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +export { fromFileUrl } from 'https://deno.land/std@0.110.0/path/mod.ts'; +export { assertEquals, assert } from 'https://deno.land/std@0.132.0/testing/asserts.ts'; diff --git a/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts b/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts new file mode 100644 index 000000000000..a9912598028e --- /dev/null +++ b/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts @@ -0,0 +1,18 @@ +// @ts-ignore +import { runBuild } from './test-utils.ts'; +// @ts-ignore +import { assertEquals, assert } from './deps.ts'; + +// @ts-ignore +Deno.test({ + name: 'Edge Basics', + async fn() { + let close = await runBuild('./fixtures/edge-basic/'); + const { default: handler } = await import('./fixtures/edge-basic/dist/edge-functions/entry.mjs'); + const response = await handler(new Request('http://example.com/')); + assertEquals(response.status, 200); + const html = await response.text(); + assert(html, 'got some html'); + await close(); + }, +}); diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs new file mode 100644 index 000000000000..c55135e43b7a --- /dev/null +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'astro/config'; +import { netlifyEdgeFunctions } from '@astrojs/netlify'; + +export default defineConfig({ + adapter: netlifyEdgeFunctions({ + dist: new URL('./dist/', import.meta.url), + }), + experimental: { + ssr: true + } +}) diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json new file mode 100644 index 000000000000..bbda2476b440 --- /dev/null +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/netlify-edge-astro-basic", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/netlify": "workspace:*" + } +} diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro new file mode 100644 index 000000000000..a87de65dbbd4 --- /dev/null +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro @@ -0,0 +1,10 @@ + +Testing + +

Test page

+

Links

+ + + diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/two.astro b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/two.astro new file mode 100644 index 000000000000..b5a031be3b20 --- /dev/null +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/two.astro @@ -0,0 +1,6 @@ + +Page Two + +

Page two

+ + diff --git a/packages/integrations/netlify/test/edge-functions/test-utils.ts b/packages/integrations/netlify/test/edge-functions/test-utils.ts new file mode 100644 index 000000000000..826f64d37cd5 --- /dev/null +++ b/packages/integrations/netlify/test/edge-functions/test-utils.ts @@ -0,0 +1,13 @@ +// @ts-ignore +import { fromFileUrl } from './deps.ts'; +const dir = new URL('./', import.meta.url); + +export async function runBuild(fixturePath: string) { + // @ts-ignore + let proc = Deno.run({ + cmd: ['node', '../../../../../../astro/astro.js', 'build', '--silent'], + cwd: fromFileUrl(new URL(fixturePath, dir)), + }); + await proc.status(); + return async () => await proc.close(); +} diff --git a/packages/integrations/netlify/test/cookies.test.js b/packages/integrations/netlify/test/functions/cookies.test.js similarity index 78% rename from packages/integrations/netlify/test/cookies.test.js rename to packages/integrations/netlify/test/functions/cookies.test.js index 0fdc126e8e21..93cc05229185 100644 --- a/packages/integrations/netlify/test/cookies.test.js +++ b/packages/integrations/netlify/test/functions/cookies.test.js @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from '../../../astro/test/test-utils.js'; -import netlifyAdapter from '../dist/index.js'; +import { loadFixture, testIntegration } from './test-utils.js'; +import netlifyAdapter from '../../dist/index.js'; import { fileURLToPath } from 'url'; describe('Cookies', () => { @@ -18,15 +18,7 @@ describe('Cookies', () => { dist: new URL('./fixtures/cookies/dist/', import.meta.url), }), site: `http://example.com`, - vite: { - resolve: { - alias: { - '@astrojs/netlify/netlify-functions.js': fileURLToPath( - new URL('../dist/netlify-functions.js', import.meta.url) - ), - }, - }, - }, + integrations: [ testIntegration() ] }); await fixture.build(); }); diff --git a/packages/integrations/netlify/test/dynamic-route.test.js b/packages/integrations/netlify/test/functions/dynamic-route.test.js similarity index 55% rename from packages/integrations/netlify/test/dynamic-route.test.js rename to packages/integrations/netlify/test/functions/dynamic-route.test.js index 18d8b8ec284f..279982767a93 100644 --- a/packages/integrations/netlify/test/dynamic-route.test.js +++ b/packages/integrations/netlify/test/functions/dynamic-route.test.js @@ -1,12 +1,9 @@ import { expect } from 'chai'; -import { load as cheerioLoad } from 'cheerio'; -import { loadFixture } from '../../../astro/test/test-utils.js'; -import netlifyAdapter from '../dist/index.js'; -import { fileURLToPath } from 'url'; +import netlifyAdapter from '../../dist/index.js'; +import { loadFixture, testIntegration } from './test-utils.js'; -// Asset bundling describe('Dynamic pages', () => { - /** @type {import('../../../astro/test/test-utils').Fixture} */ + /** @type {import('./test-utils').Fixture} */ let fixture; before(async () => { @@ -19,15 +16,7 @@ describe('Dynamic pages', () => { dist: new URL('./fixtures/dynamic-route/dist/', import.meta.url), }), site: `http://example.com`, - vite: { - resolve: { - alias: { - '@astrojs/netlify/netlify-functions.js': fileURLToPath( - new URL('../dist/netlify-functions.js', import.meta.url) - ), - }, - }, - }, + integrations: [ testIntegration() ] }); await fixture.build(); }); diff --git a/packages/integrations/netlify/test/fixtures/.gitignore b/packages/integrations/netlify/test/functions/fixtures/.gitignore similarity index 100% rename from packages/integrations/netlify/test/fixtures/.gitignore rename to packages/integrations/netlify/test/functions/fixtures/.gitignore diff --git a/packages/integrations/netlify/test/fixtures/cookies/src/pages/index.astro b/packages/integrations/netlify/test/functions/fixtures/cookies/src/pages/index.astro similarity index 100% rename from packages/integrations/netlify/test/fixtures/cookies/src/pages/index.astro rename to packages/integrations/netlify/test/functions/fixtures/cookies/src/pages/index.astro diff --git a/packages/integrations/netlify/test/fixtures/cookies/src/pages/login.js b/packages/integrations/netlify/test/functions/fixtures/cookies/src/pages/login.js similarity index 100% rename from packages/integrations/netlify/test/fixtures/cookies/src/pages/login.js rename to packages/integrations/netlify/test/functions/fixtures/cookies/src/pages/login.js diff --git a/packages/integrations/netlify/test/fixtures/dynamic-route/src/pages/products/[id].astro b/packages/integrations/netlify/test/functions/fixtures/dynamic-route/src/pages/products/[id].astro similarity index 100% rename from packages/integrations/netlify/test/fixtures/dynamic-route/src/pages/products/[id].astro rename to packages/integrations/netlify/test/functions/fixtures/dynamic-route/src/pages/products/[id].astro diff --git a/packages/integrations/netlify/test/functions/test-utils.js b/packages/integrations/netlify/test/functions/test-utils.js new file mode 100644 index 000000000000..19cd7ef663e0 --- /dev/null +++ b/packages/integrations/netlify/test/functions/test-utils.js @@ -0,0 +1,29 @@ +// @ts-check +import { fileURLToPath } from 'url'; + +export * from '../../../../astro/test/test-utils.js'; + +/** + * + * @returns {import('../../../../astro/dist/types/@types/astro').AstroIntegration} + */ +export function testIntegration() { + return { + name: '@astrojs/netlify/test-integration', + hooks: { + 'astro:config:setup':({ updateConfig }) => { + updateConfig({ + vite: { + resolve: { + alias: { + '@astrojs/netlify/netlify-functions.js': fileURLToPath( + new URL('../../dist/netlify-functions.js', import.meta.url) + ), + }, + }, + }, + }); + } + } + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f497bf8c260f..a398ba47997b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1271,6 +1271,14 @@ importers: astro: link:../../astro astro-scripts: link:../../../scripts + packages/integrations/netlify/test/edge-functions/fixtures/edge-basic: + specifiers: + '@astrojs/netlify': workspace:* + astro: workspace:* + dependencies: + '@astrojs/netlify': link:../../../.. + astro: link:../../../../../../astro + packages/integrations/node: specifiers: '@astrojs/webapi': ^0.11.1