diff options
author | 2023-07-17 15:53:10 +0100 | |
---|---|---|
committer | 2023-07-17 15:53:10 +0100 | |
commit | 4c93bd8154c210ebce6ad2889bd8bfdf4c349a78 (patch) | |
tree | e0b9fb9474845411b35f177260408f444d0631ff /packages/integrations/netlify/src | |
parent | cc8e9de88179d2ed4b70980c60b41448db393429 (diff) | |
download | astro-4c93bd8154c210ebce6ad2889bd8bfdf4c349a78.tar.gz astro-4c93bd8154c210ebce6ad2889bd8bfdf4c349a78.tar.zst astro-4c93bd8154c210ebce6ad2889bd8bfdf4c349a78.zip |
feat(@astrojs/netlify): edge middleware support (#7632)
Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
Co-authored-by: Yan Thomas <61414485+Yan-Thomas@users.noreply.github.com>
Diffstat (limited to 'packages/integrations/netlify/src')
5 files changed, 206 insertions, 105 deletions
diff --git a/packages/integrations/netlify/src/integration-edge-functions.ts b/packages/integrations/netlify/src/integration-edge-functions.ts index 72721bd59..ac7c124fb 100644 --- a/packages/integrations/netlify/src/integration-edge-functions.ts +++ b/packages/integrations/netlify/src/integration-edge-functions.ts @@ -1,21 +1,10 @@ import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro'; -import esbuild from 'esbuild'; -import * as fs from 'fs'; -import * as npath from 'path'; -import { fileURLToPath } from 'url'; -import { createRedirects } from './shared.js'; - -interface BuildConfig { - server: URL; - client: URL; - serverEntry: string; - assets: string; -} - -const SHIM = `globalThis.process = { - argv: [], - env: Deno.env.toObject(), -};`; +import { + bundleServerEntry, + createEdgeManifest, + createRedirects, + type NetlifyEdgeFunctionsOptions, +} from './shared.js'; export function getAdapter(): AstroAdapter { return { @@ -25,92 +14,10 @@ export function getAdapter(): AstroAdapter { }; } -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, - // Make route pattern serializable to match expected - // Netlify Edge validation format. Mirrors Netlify's own edge bundler: - // https://github.com/netlify/edge-bundler/blob/main/src/manifest.ts#L34 - pattern: route.pattern.source.replace(/\\\//g, '/').toString(), - }); - } - } - - const manifest: NetlifyEdgeFunctionManifest = { - functions, - version: 1, - }; - - const baseDir = new URL('./.netlify/edge-functions/', dir); - await fs.promises.mkdir(baseDir, { recursive: true }); - - const manifestURL = new URL('./manifest.json', baseDir); - const _manifest = JSON.stringify(manifest, null, ' '); - await fs.promises.writeFile(manifestURL, _manifest, 'utf-8'); -} - -async function bundleServerEntry({ serverEntry, server }: BuildConfig, vite: any) { - const entryUrl = new URL(serverEntry, server); - const pth = fileURLToPath(entryUrl); - await esbuild.build({ - target: 'es2020', - platform: 'browser', - entryPoints: [pth], - outfile: pth, - allowOverwrite: true, - format: 'esm', - bundle: true, - external: ['@astrojs/markdown-remark'], - banner: { - js: SHIM, - }, - }); - - // Remove chunks, if they exist. Since we have bundled via esbuild these chunks are trash. - try { - const chunkFileNames = - vite?.build?.rollupOptions?.output?.chunkFileNames ?? `chunks/chunk.[hash].mjs`; - const chunkPath = npath.dirname(chunkFileNames); - const chunksDirUrl = new URL(chunkPath + '/', server); - await fs.promises.rm(chunksDirUrl, { recursive: true, force: true }); - } catch {} -} - export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}): AstroIntegration { let _config: AstroConfig; let entryFile: string; - let _buildConfig: BuildConfig; + let _buildConfig: AstroConfig['build']; let _vite: any; return { name: '@astrojs/netlify/edge-functions', @@ -164,7 +71,8 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}) } }, 'astro:build:done': async ({ routes, dir }) => { - await bundleServerEntry(_buildConfig, _vite); + const entryUrl = new URL(_buildConfig.serverEntry, _buildConfig.server); + await bundleServerEntry(entryUrl, _buildConfig.server, _vite); await createEdgeManifest(routes, entryFile, _config.root); const dynamicTarget = `/.netlify/edge-functions/${entryFile}`; const map: [RouteData, string][] = routes.map((route) => { diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts index 3e8c476e5..64bdbb203 100644 --- a/packages/integrations/netlify/src/integration-functions.ts +++ b/packages/integrations/netlify/src/integration-functions.ts @@ -1,8 +1,12 @@ import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro'; import { extname } from 'node:path'; -import { fileURLToPath } from 'node:url'; import type { Args } from './netlify-functions.js'; import { createRedirects } from './shared.js'; +import { fileURLToPath } from 'node:url'; +import { generateEdgeMiddleware } from './middleware.js'; + +export const NETLIFY_EDGE_MIDDLEWARE_FILE = 'netlify-edge-middleware'; +export const ASTRO_LOCALS_HEADER = 'x-astro-locals'; export function getAdapter(args: Args = {}): AstroAdapter { return { @@ -27,6 +31,7 @@ function netlifyFunctions({ let _config: AstroConfig; let _entryPoints: Map<RouteData, URL>; let ssrEntryFile: string; + let _middlewareEntryPoint: URL; return { name: '@astrojs/netlify', hooks: { @@ -40,7 +45,10 @@ function netlifyFunctions({ }, }); }, - 'astro:build:ssr': ({ entryPoints }) => { + 'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => { + if (middlewareEntryPoint) { + _middlewareEntryPoint = middlewareEntryPoint; + } _entryPoints = entryPoints; }, 'astro:config:done': ({ config, setAdapter }) => { @@ -85,6 +93,18 @@ function netlifyFunctions({ await createRedirects(_config, routeToDynamicTargetMap, dir); } + if (_middlewareEntryPoint) { + const outPath = fileURLToPath(new URL('./.netlify/edge-functions/', _config.root)); + const netlifyEdgeMiddlewareHandlerPath = new URL( + NETLIFY_EDGE_MIDDLEWARE_FILE, + _config.srcDir + ); + await generateEdgeMiddleware( + _middlewareEntryPoint, + outPath, + netlifyEdgeMiddlewareHandlerPath + ); + } }, }, }; diff --git a/packages/integrations/netlify/src/middleware.ts b/packages/integrations/netlify/src/middleware.ts new file mode 100644 index 000000000..a53d4fbde --- /dev/null +++ b/packages/integrations/netlify/src/middleware.ts @@ -0,0 +1,75 @@ +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { ASTRO_LOCALS_HEADER } from './integration-functions.js'; +import { DENO_SHIM } from './shared.js'; + +/** + * It generates a Netlify edge function. + * + */ +export async function generateEdgeMiddleware( + astroMiddlewareEntryPointPath: URL, + outPath: string, + netlifyEdgeMiddlewareHandlerPath: URL +): Promise<URL> { + const entryPointPathURLAsString = JSON.stringify( + fileURLToPath(astroMiddlewareEntryPointPath).replace(/\\/g, '/') + ); + + const code = edgeMiddlewareTemplate(entryPointPathURLAsString, netlifyEdgeMiddlewareHandlerPath); + const bundledFilePath = join(outPath, 'edgeMiddleware.js'); + const esbuild = await import('esbuild'); + await esbuild.build({ + stdin: { + contents: code, + resolveDir: process.cwd(), + }, + target: 'es2020', + platform: 'browser', + outfile: bundledFilePath, + allowOverwrite: true, + format: 'esm', + bundle: true, + minify: false, + banner: { + js: DENO_SHIM, + }, + }); + return pathToFileURL(bundledFilePath); +} + +function edgeMiddlewareTemplate(middlewarePath: string, netlifyEdgeMiddlewareHandlerPath: URL) { + const filePathEdgeMiddleware = fileURLToPath(netlifyEdgeMiddlewareHandlerPath); + let handlerTemplateImport = ''; + let handlerTemplateCall = '{}'; + if (existsSync(filePathEdgeMiddleware + '.js') || existsSync(filePathEdgeMiddleware + '.ts')) { + const stringified = JSON.stringify(filePathEdgeMiddleware.replace(/\\/g, '/')); + handlerTemplateImport = `import handler from ${stringified}`; + handlerTemplateCall = `handler({ request, context })`; + } else { + } + return ` + ${handlerTemplateImport} +import { onRequest } from ${middlewarePath}; +import { createContext, trySerializeLocals } from 'astro/middleware'; +export default async function middleware(request, context) { + const url = new URL(request.url); + const ctx = createContext({ + request, + params: {} + }); + ctx.locals = ${handlerTemplateCall}; + const next = async () => { + request.headers.set(${JSON.stringify(ASTRO_LOCALS_HEADER)}, trySerializeLocals(ctx.locals)); + return await context.next(); + }; + + return onRequest(ctx, next); +} + +export const config = { + path: "/*" +} +`; +} diff --git a/packages/integrations/netlify/src/netlify-functions.ts b/packages/integrations/netlify/src/netlify-functions.ts index 915f72955..8d0196d5e 100644 --- a/packages/integrations/netlify/src/netlify-functions.ts +++ b/packages/integrations/netlify/src/netlify-functions.ts @@ -2,6 +2,7 @@ import { polyfill } from '@astrojs/webapi'; import { builder, type Handler } from '@netlify/functions'; import type { SSRManifest } from 'astro'; import { App } from 'astro/app'; +import { ASTRO_LOCALS_HEADER } from './integration-functions.js'; polyfill(globalThis, { exclude: 'window document', @@ -80,8 +81,14 @@ export const createExports = (manifest: SSRManifest, args: Args) => { const ip = headers['x-nf-client-connection-ip']; Reflect.set(request, clientAddressSymbol, ip); - - const response: Response = await app.render(request, routeData); + let locals = {}; + if (request.headers.has(ASTRO_LOCALS_HEADER)) { + let localsAsString = request.headers.get(ASTRO_LOCALS_HEADER); + if (localsAsString) { + locals = JSON.parse(localsAsString); + } + } + const response: Response = await app.render(request, routeData, locals); const responseHeaders = Object.fromEntries(response.headers.entries()); const responseContentType = parseContentType(responseHeaders['content-type']); diff --git a/packages/integrations/netlify/src/shared.ts b/packages/integrations/netlify/src/shared.ts index ca45dc752..c0ed9ec17 100644 --- a/packages/integrations/netlify/src/shared.ts +++ b/packages/integrations/netlify/src/shared.ts @@ -1,6 +1,37 @@ import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; import type { AstroConfig, RouteData } from 'astro'; import fs from 'node:fs'; +import { fileURLToPath } from 'url'; +import esbuild from 'esbuild'; +import npath from 'path'; + +export const DENO_SHIM = `globalThis.process = { + argv: [], + env: Deno.env.toObject(), +};`; + +export interface NetlifyEdgeFunctionsOptions { + dist?: URL; +} + +export interface NetlifyEdgeFunctionManifestFunctionPath { + function: string; + path: string; +} + +export interface NetlifyEdgeFunctionManifestFunctionPattern { + function: string; + pattern: string; +} + +export type NetlifyEdgeFunctionManifestFunction = + | NetlifyEdgeFunctionManifestFunctionPath + | NetlifyEdgeFunctionManifestFunctionPattern; + +export interface NetlifyEdgeFunctionManifest { + functions: NetlifyEdgeFunctionManifestFunction[]; + version: 1; +} export async function createRedirects( config: AstroConfig, @@ -21,3 +52,63 @@ export async function createRedirects( // If the file does not exist yet, appendFile() automatically creates it. await fs.promises.appendFile(_redirectsURL, content, 'utf-8'); } + +export 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, + // Make route pattern serializable to match expected + // Netlify Edge validation format. Mirrors Netlify's own edge bundler: + // https://github.com/netlify/edge-bundler/blob/main/src/manifest.ts#L34 + pattern: route.pattern.source.replace(/\\\//g, '/').toString(), + }); + } + } + + const manifest: NetlifyEdgeFunctionManifest = { + functions, + version: 1, + }; + + const baseDir = new URL('./.netlify/edge-functions/', dir); + await fs.promises.mkdir(baseDir, { recursive: true }); + + const manifestURL = new URL('./manifest.json', baseDir); + const _manifest = JSON.stringify(manifest, null, ' '); + await fs.promises.writeFile(manifestURL, _manifest, 'utf-8'); +} + +export async function bundleServerEntry(entryUrl: URL, serverUrl?: URL, vite?: any | undefined) { + const pth = fileURLToPath(entryUrl); + await esbuild.build({ + target: 'es2020', + platform: 'browser', + entryPoints: [pth], + outfile: pth, + allowOverwrite: true, + format: 'esm', + bundle: true, + external: ['@astrojs/markdown-remark', 'astro/middleware'], + banner: { + js: DENO_SHIM, + }, + }); + + // Remove chunks, if they exist. Since we have bundled via esbuild these chunks are trash. + if (vite && serverUrl) { + try { + const chunkFileNames = + vite?.build?.rollupOptions?.output?.chunkFileNames ?? `chunks/chunk.[hash].mjs`; + const chunkPath = npath.dirname(chunkFileNames); + const chunksDirUrl = new URL(chunkPath + '/', serverUrl); + await fs.promises.rm(chunksDirUrl, { recursive: true, force: true }); + } catch {} + } +} |