diff options
author | 2023-12-17 16:44:09 +0100 | |
---|---|---|
committer | 2023-12-17 16:44:09 +0100 | |
commit | 94dcbfed0607d037c591001b5484de74661c90a2 (patch) | |
tree | 7ecfac03e71d61da8cf9fcf935291fc57e005432 /packages/integrations/netlify/src | |
parent | acb92412634176ae32dfbe186bf430055408e8fd (diff) | |
download | astro-94dcbfed0607d037c591001b5484de74661c90a2.tar.gz astro-94dcbfed0607d037c591001b5484de74661c90a2.tar.zst astro-94dcbfed0607d037c591001b5484de74661c90a2.zip |
feat(netlify): Netlify Adapter v4 (#84)
Co-authored-by: Matt Kane <m@mk.gg>
Co-authored-by: Jacklyn <70537879+jacklyn-net@users.noreply.github.com>
Co-authored-by: Arsh <69170106+lilnasy@users.noreply.github.com>
Co-authored-by: Emanuele Stoppa <602478+ematipico@users.noreply.github.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Diffstat (limited to 'packages/integrations/netlify/src')
-rw-r--r-- | packages/integrations/netlify/src/env.d.ts | 1 | ||||
-rw-r--r-- | packages/integrations/netlify/src/functions.ts | 6 | ||||
-rw-r--r-- | packages/integrations/netlify/src/image-service.ts | 57 | ||||
-rw-r--r-- | packages/integrations/netlify/src/index.ts | 313 | ||||
-rw-r--r-- | packages/integrations/netlify/src/integration-functions.ts | 151 | ||||
-rw-r--r-- | packages/integrations/netlify/src/integration-static.ts | 30 | ||||
-rw-r--r-- | packages/integrations/netlify/src/middleware.ts | 75 | ||||
-rw-r--r-- | packages/integrations/netlify/src/netlify-functions.ts | 225 | ||||
-rw-r--r-- | packages/integrations/netlify/src/shared.ts | 114 | ||||
-rw-r--r-- | packages/integrations/netlify/src/ssr-function.ts | 56 | ||||
-rw-r--r-- | packages/integrations/netlify/src/static.ts | 6 | ||||
-rw-r--r-- | packages/integrations/netlify/src/types.d.ts | 1 |
12 files changed, 437 insertions, 598 deletions
diff --git a/packages/integrations/netlify/src/env.d.ts b/packages/integrations/netlify/src/env.d.ts deleted file mode 100644 index f964fe0cf..000000000 --- a/packages/integrations/netlify/src/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// <reference types="astro/client" /> diff --git a/packages/integrations/netlify/src/functions.ts b/packages/integrations/netlify/src/functions.ts new file mode 100644 index 000000000..7a84087af --- /dev/null +++ b/packages/integrations/netlify/src/functions.ts @@ -0,0 +1,6 @@ +import netlifyIntegration, { type NetlifyIntegrationConfig } from "./index.js" + +export default function functionsIntegration(config: NetlifyIntegrationConfig) { + console.warn("The @astrojs/netlify/functions import is deprecated and will be removed in a future release. Please use @astrojs/netlify instead.") + return netlifyIntegration(config) +}
\ No newline at end of file diff --git a/packages/integrations/netlify/src/image-service.ts b/packages/integrations/netlify/src/image-service.ts new file mode 100644 index 000000000..385f6996f --- /dev/null +++ b/packages/integrations/netlify/src/image-service.ts @@ -0,0 +1,57 @@ +import type { ExternalImageService, ImageMetadata } from 'astro'; +import { AstroError } from 'astro/errors'; +import { baseService } from 'astro/assets' + +const SUPPORTED_FORMATS = ['avif', 'jpg', 'png', 'webp']; +const QUALITY_NAMES: Record<string, number> = { low: 25, mid: 50, high: 90, max: 100 }; + +export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata { + return typeof src === 'object'; +} + +function removeLeadingForwardSlash(path: string) { + return path.startsWith('/') ? path.substring(1) : path; +} + +const service: ExternalImageService = { + getURL(options) { + const query = new URLSearchParams(); + + const fileSrc = isESMImportedImage(options.src) + ? removeLeadingForwardSlash(options.src.src) + : options.src; + + query.set('url', fileSrc); + + if (options.format) query.set('fm', options.format); + if (options.width) query.set('w', '' + options.width); + if (options.height) query.set('h', '' + options.height); + if (options.quality) query.set('q', '' + options.quality); + + return '/.netlify/images?' + query; + }, + getHTMLAttributes: baseService.getHTMLAttributes, + getSrcSet: baseService.getSrcSet, + validateOptions(options) { + if (options.format && !SUPPORTED_FORMATS.includes(options.format)) { + throw new AstroError( + `Unsupported image format "${options.format}"`, + `Use one of ${SUPPORTED_FORMATS.join(', ')} instead.` + ); + } + + if (options.quality) { + options.quality = + typeof options.quality === 'string' ? QUALITY_NAMES[options.quality] : options.quality; + if (options.quality < 1 || options.quality > 100) { + throw new AstroError( + `Invalid quality for picture "${options.src}"`, + `Quality needs to be between 1 and 100.` + ); + } + } + return options; + }, +}; + +export default service; diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index a374020f9..09451a2da 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -1,2 +1,311 @@ -export { netlifyFunctions as default, netlifyFunctions } from './integration-functions.js'; -export { netlifyStatic } from './integration-static.js'; +import type { AstroConfig, AstroIntegration, RouteData } from 'astro'; +import { writeFile, mkdir, appendFile, rm } from 'fs/promises'; +import { fileURLToPath } from 'url'; +import { build } from 'esbuild'; +import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; +import { version as packageVersion } from '../package.json'; +import type { Context } from '@netlify/functions'; +import { AstroError } from 'astro/errors'; +import type { IncomingMessage } from 'http'; + +export interface NetlifyLocals { + netlify: { + context: Context; + }; +} + +const isStaticRedirect = (route: RouteData) => + route.type === 'redirect' && (route.redirect || route.redirectRoute); + +const clearDirectory = (dir: URL) => rm(dir, { recursive: true }).catch(() => {}); + +export interface NetlifyIntegrationConfig { + /** + * If enabled, On-Demand-Rendered pages are cached for up to a year. + * This is useful for pages that are not updated often, like a blog post, + * but that you have too many of to pre-render at build time. + * + * You can override this behavior on a per-page basis + * by setting the `Cache-Control`, `CDN-Cache-Control` or `Netlify-CDN-Cache-Control` header + * from within the Page: + * + * ```astro + * // src/pages/cached-clock.astro + * Astro.response.headers.set('CDN-Cache-Control', "public, max-age=45, must-revalidate"); + * --- + * <p>{Date.now()}</p> + * ``` + */ + cacheOnDemandPages?: boolean; + + /** + * If disabled, Middleware is applied to prerendered pages at build-time, and to on-demand-rendered pages at runtime. + * Only disable when your Middleware does not need to run on prerendered pages. + * If you use Middleware to implement authentication, redirects or similar things, you should should likely enabled it. + * + * If enabled, Astro Middleware is deployed as an Edge Function and applies to all routes. + * Caveat: Locals set in Middleware are not applied to prerendered pages, because they've been rendered at build-time and are served from the CDN. + * + * @default disabled + */ + edgeMiddleware?: boolean; +} + +export default function netlifyIntegration( + integrationConfig?: NetlifyIntegrationConfig +): AstroIntegration { + const isRunningInNetlify = Boolean( + process.env.NETLIFY || process.env.NETLIFY_LOCAL || process.env.NETLIFY_DEV + ); + + let _config: AstroConfig; + let outDir: URL; + let rootDir: URL; + let astroMiddlewareEntryPoint: URL | undefined = undefined; + + const ssrOutputDir = () => new URL('./.netlify/functions-internal/ssr/', rootDir); + const middlewareOutputDir = () => new URL('.netlify/edge-functions/middleware/', rootDir); + + const cleanFunctions = async () => + await Promise.all([clearDirectory(middlewareOutputDir()), clearDirectory(ssrOutputDir())]); + + async function writeRedirects(routes: RouteData[], dir: URL) { + const fallback = _config.output === 'static' ? '/.netlify/static' : '/.netlify/functions/ssr'; + const redirects = createRedirectsFromAstroRoutes({ + config: _config, + dir, + routeToDynamicTargetMap: new Map( + routes + .filter(isStaticRedirect) // all other routes are handled by SSR + .map((route) => { + // this is needed to support redirects to dynamic routes + // on static. not sure why this is needed, but it works. + route.distURL ??= route.redirectRoute?.distURL; + + return [route, fallback]; + }) + ), + }); + + if (!redirects.empty()) { + await appendFile(new URL('_redirects', outDir), '\n' + redirects.print() + '\n'); + } + } + + async function writeSSRFunction() { + await writeFile( + new URL('./ssr.mjs', ssrOutputDir()), + ` + import createSSRHandler from './entry.mjs'; + export default createSSRHandler(${JSON.stringify({ + cacheOnDemandPages: Boolean(integrationConfig?.cacheOnDemandPages), + })}); + export const config = { name: "Astro SSR", generator: "@astrojs/netlify@${packageVersion}", path: "/*", preferStatic: true }; + ` + ); + } + + async function writeMiddleware(entrypoint: URL) { + await mkdir(middlewareOutputDir(), { recursive: true }); + await writeFile( + new URL('./entry.mjs', middlewareOutputDir()), + ` + import { onRequest } from "${fileURLToPath(entrypoint).replaceAll('\\', '/')}"; + import { createContext, trySerializeLocals } from 'astro/middleware'; + + export default async (request, context) => { + const ctx = createContext({ + request, + params: {} + }); + ctx.locals = { netlify: { context } } + const next = () => { + const { netlify, ...otherLocals } = ctx.locals; + request.headers.set("x-astro-locals", trySerializeLocals(otherLocals)); + return context.next(); + }; + + return onRequest(ctx, next); + } + + export const config = { + name: "Astro Middleware", + generator: "@astrojs/netlify@${packageVersion}", + path: "/*", excludedPath: ["/_astro/*", "/.netlify/images/*"] + }; + ` + ); + + // taking over bundling, because Netlify bundling trips over NPM modules + await build({ + entryPoints: [fileURLToPath(new URL('./entry.mjs', middlewareOutputDir()))], + target: 'es2022', + platform: 'neutral', + outfile: fileURLToPath(new URL('./middleware.mjs', middlewareOutputDir())), + allowOverwrite: true, + format: 'esm', + bundle: true, + minify: false, + }); + } + + function getLocalDevNetlifyContext(req: IncomingMessage): Context { + const isHttps = req.headers['x-forwarded-proto'] === 'https'; + const parseBase64JSON = <T = unknown>(header: string): T | undefined => { + if (typeof req.headers[header] === 'string') { + try { + return JSON.parse(Buffer.from(req.headers[header] as string, 'base64').toString('utf8')); + } catch {} + } + }; + + const context: Context = { + account: parseBase64JSON('x-nf-account-info') ?? { + id: 'mock-netlify-account-id', + }, + deploy: { + id: + typeof req.headers['x-nf-deploy-id'] === 'string' + ? req.headers['x-nf-deploy-id'] + : 'mock-netlify-deploy-id', + }, + site: parseBase64JSON('x-nf-site-info') ?? { + id: 'mock-netlify-site-id', + name: 'mock-netlify-site.netlify.app', + url: `${isHttps ? 'https' : 'http'}://localhost:${isRunningInNetlify ? 8888 : 4321}`, + }, + geo: parseBase64JSON('x-nf-geo') ?? { + city: 'Mock City', + country: { code: 'mock', name: 'Mock Country' }, + subdivision: { code: 'SD', name: 'Mock Subdivision' }, + + // @ts-expect-error: these are smhw missing from the Netlify types - fix is on the way + timezone: 'UTC', + longitude: 0, + latitude: 0, + }, + ip: + typeof req.headers['x-nf-client-connection-ip'] === 'string' + ? req.headers['x-nf-client-connection-ip'] + : req.socket.remoteAddress ?? '127.0.0.1', + server: { + region: 'local-dev', + }, + requestId: + typeof req.headers['x-nf-request-id'] === 'string' + ? req.headers['x-nf-request-id'] + : 'mock-netlify-request-id', + get cookies(): never { + throw new Error('Please use Astro.cookies instead.'); + }, + json: (input) => Response.json(input), + log: console.log, + next: () => { + throw new Error('`context.next` is not implemented for serverless functions'); + }, + get params(): never { + throw new Error("context.params don't contain any usable content in Astro."); + }, + rewrite() { + throw new Error('context.rewrite is not available in Astro.'); + }, + }; + + return context; + } + + return { + name: '@astrojs/netlify', + hooks: { + 'astro:config:setup': async ({ config, updateConfig }) => { + rootDir = config.root; + await cleanFunctions(); + + outDir = new URL('./dist/', rootDir); + + updateConfig({ + outDir, + build: { + redirects: false, + client: outDir, + server: ssrOutputDir(), + }, + vite: { + server: { + watch: { + ignored: [fileURLToPath(new URL('./.netlify/**', rootDir))], + }, + }, + }, + image: { + service: { + entrypoint: isRunningInNetlify ? '@astrojs/netlify/image-service.js' : undefined, + }, + }, + }); + }, + 'astro:config:done': ({ config, setAdapter }) => { + rootDir = config.root; + _config = config; + + if (config.image.domains.length || config.image.remotePatterns.length) { + throw new AstroError( + "config.image.domains and config.image.remotePatterns aren't supported by the Netlify adapter.", + 'See https://github.com/withastro/adapters/tree/main/packages/netlify#image-cdn for more.' + ); + } + + setAdapter({ + name: '@astrojs/netlify', + serverEntrypoint: '@astrojs/netlify/ssr-function.js', + exports: ['default'], + adapterFeatures: { + functionPerRoute: false, + edgeMiddleware: integrationConfig?.edgeMiddleware ?? false, + }, + supportedAstroFeatures: { + hybridOutput: 'stable', + staticOutput: 'stable', + serverOutput: 'stable', + assets: { + // keeping this as experimental at least until Netlify Image CDN is out of beta + supportKind: 'experimental', + // still using Netlify Image CDN instead + isSharpCompatible: true, + isSquooshCompatible: true, + }, + }, + }); + }, + 'astro:build:ssr': async ({ middlewareEntryPoint }) => { + astroMiddlewareEntryPoint = middlewareEntryPoint; + }, + 'astro:build:done': async ({ routes, dir, logger }) => { + await writeRedirects(routes, dir); + logger.info('Emitted _redirects'); + + if (_config.output !== 'static') { + await writeSSRFunction(); + logger.info('Generated SSR Function'); + } + + if (astroMiddlewareEntryPoint) { + await writeMiddleware(astroMiddlewareEntryPoint); + logger.info('Generated Middleware Edge Function'); + } + }, + + // local dev + 'astro:server:setup': async ({ server }) => { + server.middlewares.use((req, res, next) => { + const locals = Symbol.for('astro.locals'); + Reflect.set(req, locals, { + ...Reflect.get(req, locals), + netlify: { context: getLocalDevNetlifyContext(req) }, + }); + next(); + }); + }, + }, + }; +} diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts deleted file mode 100644 index 8812a8e89..000000000 --- a/packages/integrations/netlify/src/integration-functions.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { writeFile } from 'node:fs/promises'; -import { extname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro'; -import { generateEdgeMiddleware } from './middleware.js'; -import type { Args } from './netlify-functions.js'; -import { createRedirects } from './shared.js'; - -export const NETLIFY_EDGE_MIDDLEWARE_FILE = 'netlify-edge-middleware'; -export const ASTRO_LOCALS_HEADER = 'x-astro-locals'; - -export function getAdapter({ functionPerRoute, edgeMiddleware, ...args }: Args): AstroAdapter { - return { - name: '@astrojs/netlify/functions', - serverEntrypoint: '@astrojs/netlify/netlify-functions.js', - exports: ['handler'], - args, - adapterFeatures: { - functionPerRoute, - edgeMiddleware, - }, - supportedAstroFeatures: { - hybridOutput: 'stable', - staticOutput: 'stable', - serverOutput: 'stable', - assets: { - supportKind: 'stable', - isSharpCompatible: true, - isSquooshCompatible: true, - }, - }, - }; -} - -interface NetlifyFunctionsOptions { - dist?: URL; - builders?: boolean; - binaryMediaTypes?: string[]; - edgeMiddleware?: boolean; - functionPerRoute?: boolean; -} - -function netlifyFunctions({ - dist, - builders, - binaryMediaTypes, - functionPerRoute = false, - edgeMiddleware = false, -}: NetlifyFunctionsOptions = {}): AstroIntegration { - let _config: AstroConfig; - let _entryPoints: Map<RouteData, URL>; - let ssrEntryFile: string; - let _middlewareEntryPoint: URL; - return { - name: '@astrojs/netlify', - hooks: { - 'astro:config:setup': ({ config, updateConfig }) => { - const outDir = dist ?? new URL('./dist/', config.root); - updateConfig({ - outDir, - build: { - redirects: false, - client: outDir, - server: new URL('./.netlify/functions-internal/', config.root), - }, - }); - }, - 'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => { - if (middlewareEntryPoint) { - _middlewareEntryPoint = middlewareEntryPoint; - } - _entryPoints = entryPoints; - }, - 'astro:config:done': ({ config, setAdapter }) => { - setAdapter( - getAdapter({ - binaryMediaTypes, - builders, - functionPerRoute, - edgeMiddleware, - }) - ); - _config = config; - ssrEntryFile = config.build.serverEntry.replace(/\.m?js/, ''); - - if (config.output === 'static') { - // eslint-disable-next-line no-console - console.warn( - `[@astrojs/netlify] \`output: "server"\` or \`output: "hybrid"\` is required to use this adapter.` - ); - // eslint-disable-next-line no-console - console.warn( - `[@astrojs/netlify] Otherwise, this adapter is not required to deploy a static site to Netlify.` - ); - } - }, - 'astro:build:done': async ({ routes, dir }) => { - const functionsConfig = { - version: 1, - config: { - nodeModuleFormat: 'esm', - }, - }; - const functionsConfigPath = join(fileURLToPath(_config.build.server), 'entry.json'); - await writeFile(functionsConfigPath, JSON.stringify(functionsConfig)); - - const type = builders ? 'builders' : 'functions'; - const kind = type ?? 'functions'; - - if (_entryPoints.size) { - const routeToDynamicTargetMap = new Map(); - for (const [route, entryFile] of _entryPoints) { - const wholeFileUrl = fileURLToPath(entryFile); - - const extension = extname(wholeFileUrl); - const relative = wholeFileUrl - .replace(fileURLToPath(_config.build.server), '') - .replace(extension, '') - .replaceAll('\\', '/'); - const dynamicTarget = `/.netlify/${kind}/${relative}`; - - routeToDynamicTargetMap.set(route, dynamicTarget); - } - await createRedirects(_config, routeToDynamicTargetMap, dir); - } else { - const dynamicTarget = `/.netlify/${kind}/${ssrEntryFile}`; - const map: [RouteData, string][] = routes.map((route) => { - return [route, dynamicTarget]; - }); - const routeToDynamicTargetMap = new Map(Array.from(map)); - - 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 - ); - } - }, - }, - }; -} - -export { netlifyFunctions as default, netlifyFunctions }; diff --git a/packages/integrations/netlify/src/integration-static.ts b/packages/integrations/netlify/src/integration-static.ts deleted file mode 100644 index af2849867..000000000 --- a/packages/integrations/netlify/src/integration-static.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { AstroIntegration, RouteData } from 'astro'; -import { createRedirects } from './shared.js'; - -export function netlifyStatic(): AstroIntegration { - let _config: any; - return { - name: '@astrojs/netlify', - hooks: { - 'astro:config:setup': ({ updateConfig }) => { - updateConfig({ - build: { - // Do not output HTML redirects because we are building a `_redirects` file. - redirects: false, - }, - }); - }, - 'astro:config:done': ({ config }) => { - _config = config; - }, - 'astro:build:done': async ({ dir, routes }) => { - const mappedRoutes: [RouteData, string][] = routes.map((route) => [ - route, - `/.netlify/static/`, - ]); - const routesToDynamicTargetMap = new Map(Array.from(mappedRoutes)); - await createRedirects(_config, routesToDynamicTargetMap, dir); - }, - }, - }; -} diff --git a/packages/integrations/netlify/src/middleware.ts b/packages/integrations/netlify/src/middleware.ts deleted file mode 100644 index 3c2f4f697..000000000 --- a/packages/integrations/netlify/src/middleware.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -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 deleted file mode 100644 index 9c9d8ad3a..000000000 --- a/packages/integrations/netlify/src/netlify-functions.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { type Handler, builder } from '@netlify/functions'; -import type { SSRManifest } from 'astro'; -import { App } from 'astro/app'; -import { applyPolyfills } from 'astro/app/node'; -import { ASTRO_LOCALS_HEADER } from './integration-functions.js'; - -applyPolyfills(); - -export interface Args { - builders?: boolean; - binaryMediaTypes?: string[]; - edgeMiddleware: boolean; - functionPerRoute: boolean; -} - -function parseContentType(header?: string) { - return header?.split(';')[0] ?? ''; -} - -const clientAddressSymbol = Symbol.for('astro.clientAddress'); - -export const createExports = (manifest: SSRManifest, args: Args) => { - const app = new App(manifest); - - const builders = args.builders ?? false; - 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/heif', - '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 myHandler: Handler = async (event) => { - const { httpMethod, headers, rawUrl, body: requestBody, isBase64Encoded } = event; - const init: RequestInit = { - method: httpMethod, - headers: new Headers(headers as any), - }; - // Attach the event body the request, with proper encoding. - if (httpMethod !== 'GET' && httpMethod !== 'HEAD') { - const encoding = isBase64Encoded ? 'base64' : 'utf-8'; - init.body = - typeof requestBody === 'string' ? Buffer.from(requestBody, encoding) : requestBody; - } - - const request = new Request(rawUrl, init); - - const routeData = app.match(request); - const ip = headers['x-nf-client-connection-ip']; - Reflect.set(request, clientAddressSymbol, ip); - - let locals: Record<string, unknown> = {}; - - if (request.headers.has(ASTRO_LOCALS_HEADER)) { - let localsAsString = request.headers.get(ASTRO_LOCALS_HEADER); - if (localsAsString) { - locals = JSON.parse(localsAsString); - } - } - - let responseTtl = undefined; - - locals.runtime = builders - ? { - setBuildersTtl(ttl: number) { - responseTtl = ttl; - }, - } - : {}; - - const response: Response = await app.render(request, routeData, locals); - const responseHeaders = Object.fromEntries(response.headers.entries()); - - const responseContentType = parseContentType(responseHeaders['content-type']); - const responseIsBase64Encoded = knownBinaryMediaTypes.has(responseContentType); - - let responseBody: string; - if (responseIsBase64Encoded) { - const ab = await response.arrayBuffer(); - responseBody = Buffer.from(ab).toString('base64'); - } else { - responseBody = await response.text(); - } - - const fnResponse: any = { - statusCode: response.status, - headers: responseHeaders, - body: responseBody, - isBase64Encoded: responseIsBase64Encoded, - ttl: responseTtl, - }; - - const cookies = response.headers.get('set-cookie'); - if (cookies) { - fnResponse.multiValueHeaders = { - 'set-cookie': Array.isArray(cookies) ? cookies : splitCookiesString(cookies), - }; - } - - // Apply cookies set via Astro.cookies.set/delete - if (app.setCookieHeaders) { - const setCookieHeaders = Array.from(app.setCookieHeaders(response)); - fnResponse.multiValueHeaders = fnResponse.multiValueHeaders || {}; - if (!fnResponse.multiValueHeaders['set-cookie']) { - fnResponse.multiValueHeaders['set-cookie'] = []; - } - fnResponse.multiValueHeaders['set-cookie'].push(...setCookieHeaders); - } - - return fnResponse; - }; - - const handler = builders ? builder(myHandler) : myHandler; - - return { handler }; -}; - -/* - From: https://github.com/nfriedly/set-cookie-parser/blob/5cae030d8ef0f80eec58459e3583d43a07b984cb/lib/set-cookie.js#L144 - Set-Cookie header field-values are sometimes comma joined in one string. This splits them without choking on commas - that are within a single set-cookie field-value, such as in the Expires portion. - This is uncommon, but explicitly allowed - see https://tools.ietf.org/html/rfc2616#section-4.2 - Node.js does this for every header *except* set-cookie - see https://github.com/nodejs/node/blob/d5e363b77ebaf1caf67cd7528224b651c86815c1/lib/_http_incoming.js#L128 - React Native's fetch does this for *every* header, including set-cookie. - Based on: https://github.com/google/j2objc/commit/16820fdbc8f76ca0c33472810ce0cb03d20efe25 - Credits to: https://github.com/tomball for original and https://github.com/chrusart for JavaScript implementation -*/ -function splitCookiesString(cookiesString: string): string[] { - if (Array.isArray(cookiesString)) { - return cookiesString; - } - if (typeof cookiesString !== 'string') { - return []; - } - - let cookiesStrings = []; - let pos = 0; - let start; - let ch; - let lastComma; - let nextStart; - let cookiesSeparatorFound; - - function skipWhitespace() { - while (pos < cookiesString.length && /\s/.test(cookiesString.charAt(pos))) { - pos += 1; - } - return pos < cookiesString.length; - } - - function notSpecialChar() { - ch = cookiesString.charAt(pos); - - return ch !== '=' && ch !== ';' && ch !== ','; - } - - while (pos < cookiesString.length) { - start = pos; - cookiesSeparatorFound = false; - - while (skipWhitespace()) { - ch = cookiesString.charAt(pos); - if (ch === ',') { - // ',' is a cookie separator if we have later first '=', not ';' or ',' - lastComma = pos; - pos += 1; - - skipWhitespace(); - nextStart = pos; - - while (pos < cookiesString.length && notSpecialChar()) { - pos += 1; - } - - // currently special character - if (pos < cookiesString.length && cookiesString.charAt(pos) === '=') { - // we found cookies separator - cookiesSeparatorFound = true; - // pos is inside the next cookie, so back up and return it. - pos = nextStart; - cookiesStrings.push(cookiesString.substring(start, lastComma)); - start = pos; - } else { - // in param ',' or param separator ';', - // we continue from that comma - pos = lastComma + 1; - } - } else { - pos += 1; - } - } - - if (!cookiesSeparatorFound || pos >= cookiesString.length) { - cookiesStrings.push(cookiesString.substring(start, cookiesString.length)); - } - } - - return cookiesStrings; -} diff --git a/packages/integrations/netlify/src/shared.ts b/packages/integrations/netlify/src/shared.ts deleted file mode 100644 index fca3d5f0c..000000000 --- a/packages/integrations/netlify/src/shared.ts +++ /dev/null @@ -1,114 +0,0 @@ -import fs from 'node:fs'; -import npath from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects'; -import type { AstroConfig, RouteData } from 'astro'; -import esbuild from 'esbuild'; - -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, - routeToDynamicTargetMap: Map<RouteData, string>, - dir: URL -) { - const _redirectsURL = new URL('./_redirects', dir); - - const _redirects = createRedirectsFromAstroRoutes({ - config, - routeToDynamicTargetMap, - dir, - }); - const content = _redirects.print(); - - // 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, 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 {} - } -} diff --git a/packages/integrations/netlify/src/ssr-function.ts b/packages/integrations/netlify/src/ssr-function.ts new file mode 100644 index 000000000..c2b6ed14c --- /dev/null +++ b/packages/integrations/netlify/src/ssr-function.ts @@ -0,0 +1,56 @@ +import type { Context } from '@netlify/functions'; +import type { SSRManifest } from 'astro'; +import { App } from 'astro/app'; +import { applyPolyfills } from 'astro/app/node'; + +applyPolyfills(); + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Args {} + +const clientAddressSymbol = Symbol.for('astro.clientAddress'); + +export const createExports = (manifest: SSRManifest, _args: Args) => { + const app = new App(manifest); + + function createHandler(integrationConfig: { cacheOnDemandPages: boolean }) { + return async function handler(request: Request, context: Context) { + const routeData = app.match(request); + Reflect.set(request, clientAddressSymbol, context.ip); + + let locals: Record<string, unknown> = {}; + + if (request.headers.has('x-astro-locals')) { + locals = JSON.parse(request.headers.get('x-astro-locals')!); + } + + locals.netlify = { context }; + + const response = await app.render(request, routeData, locals); + + if (app.setCookieHeaders) { + for (const setCookieHeader of app.setCookieHeaders(response)) { + response.headers.append('Set-Cookie', setCookieHeader); + } + } + + if (integrationConfig.cacheOnDemandPages) { + // any user-provided Cache-Control headers take precedence + const hasCacheControl = [ + 'Cache-Control', + 'CDN-Cache-Control', + 'Netlify-CDN-Cache-Control', + ].some((header) => response.headers.has(header)); + + if (!hasCacheControl) { + // caches this page for up to a year + response.headers.append('CDN-Cache-Control', 'public, max-age=31536000, must-revalidate'); + } + } + + return response; + }; + } + + return { default: createHandler }; +}; diff --git a/packages/integrations/netlify/src/static.ts b/packages/integrations/netlify/src/static.ts new file mode 100644 index 000000000..4748f384a --- /dev/null +++ b/packages/integrations/netlify/src/static.ts @@ -0,0 +1,6 @@ +import netlifyIntegration from "./index.js" + +export default function staticIntegration() { + console.warn("The @astrojs/netlify/static import is deprecated and will be removed in a future release. Please use @astrojs/netlify instead.") + return netlifyIntegration() +}
\ No newline at end of file diff --git a/packages/integrations/netlify/src/types.d.ts b/packages/integrations/netlify/src/types.d.ts new file mode 100644 index 000000000..0df35e9e9 --- /dev/null +++ b/packages/integrations/netlify/src/types.d.ts @@ -0,0 +1 @@ +declare module "*.json";
\ No newline at end of file |