summaryrefslogtreecommitdiff
path: root/packages/integrations/netlify/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/netlify/src')
-rw-r--r--packages/integrations/netlify/src/functions.ts9
-rw-r--r--packages/integrations/netlify/src/image-service.ts57
-rw-r--r--packages/integrations/netlify/src/index.ts539
-rw-r--r--packages/integrations/netlify/src/lib/nft.ts83
-rw-r--r--packages/integrations/netlify/src/polyfill.ts3
-rw-r--r--packages/integrations/netlify/src/ssr-function.ts78
-rw-r--r--packages/integrations/netlify/src/static.ts8
7 files changed, 777 insertions, 0 deletions
diff --git a/packages/integrations/netlify/src/functions.ts b/packages/integrations/netlify/src/functions.ts
new file mode 100644
index 000000000..58428fec0
--- /dev/null
+++ b/packages/integrations/netlify/src/functions.ts
@@ -0,0 +1,9 @@
+import type { AstroIntegration } from 'astro';
+import netlifyIntegration, { type NetlifyIntegrationConfig } from './index.js';
+
+export default function functionsIntegration(config: NetlifyIntegrationConfig): AstroIntegration {
+ 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);
+}
diff --git a/packages/integrations/netlify/src/image-service.ts b/packages/integrations/netlify/src/image-service.ts
new file mode 100644
index 000000000..afdcc3917
--- /dev/null
+++ b/packages/integrations/netlify/src/image-service.ts
@@ -0,0 +1,57 @@
+import type { ExternalImageService, ImageMetadata } from 'astro';
+import { baseService } from 'astro/assets';
+import { AstroError } from 'astro/errors';
+
+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
new file mode 100644
index 000000000..2c0fde008
--- /dev/null
+++ b/packages/integrations/netlify/src/index.ts
@@ -0,0 +1,539 @@
+import { randomUUID } from 'node:crypto';
+import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
+import type { IncomingMessage } from 'node:http';
+import { fileURLToPath } from 'node:url';
+import { emptyDir } from '@astrojs/internal-helpers/fs';
+import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
+import type { Context } from '@netlify/functions';
+import type {
+ AstroConfig,
+ AstroIntegration,
+ AstroIntegrationLogger,
+ HookParameters,
+ IntegrationResolvedRoute,
+} from 'astro';
+import { build } from 'esbuild';
+import { copyDependenciesToFunction } from './lib/nft.js';
+import type { Args } from './ssr-function.js';
+
+const { version: packageVersion } = JSON.parse(
+ await readFile(new URL('../package.json', import.meta.url), 'utf8')
+);
+
+export interface NetlifyLocals {
+ netlify: {
+ context: Context;
+ };
+}
+
+type RemotePattern = AstroConfig['image']['remotePatterns'][number];
+
+/**
+ * Convert a remote pattern object to a regex string
+ */
+export function remotePatternToRegex(
+ pattern: RemotePattern,
+ logger: AstroIntegrationLogger
+): string | undefined {
+ let { protocol, hostname, port, pathname } = pattern;
+
+ let regexStr = '';
+
+ if (protocol) {
+ regexStr += `${protocol}://`;
+ } else {
+ // Default to matching any protocol
+ regexStr += '[a-z]+://';
+ }
+
+ if (hostname) {
+ if (hostname.startsWith('**.')) {
+ // match any number of subdomains
+ regexStr += '([a-z0-9-]+\\.)*';
+ hostname = hostname.substring(3);
+ } else if (hostname.startsWith('*.')) {
+ // match one subdomain
+ regexStr += '([a-z0-9-]+\\.)?';
+ hostname = hostname.substring(2); // Remove '*.' from the beginning
+ }
+ // Escape dots in the hostname
+ regexStr += hostname.replace(/\./g, '\\.');
+ } else {
+ regexStr += '[a-z0-9.-]+';
+ }
+
+ if (port) {
+ regexStr += `:${port}`;
+ } else {
+ // Default to matching any port
+ regexStr += '(:[0-9]+)?';
+ }
+
+ if (pathname) {
+ if (pathname.endsWith('/**')) {
+ // Match any path.
+ regexStr += `(\\${pathname.replace('/**', '')}.*)`;
+ }
+ if (pathname.endsWith('/*')) {
+ // Match one level of path
+ regexStr += `(\\${pathname.replace('/*', '')}\/[^/?#]+)\/?`;
+ } else {
+ // Exact match
+ regexStr += `(\\${pathname})`;
+ }
+ } else {
+ // Default to matching any path
+ regexStr += '(\\/[^?#]*)?';
+ }
+ if (!regexStr.endsWith('.*)')) {
+ // Match query, but only if it's not already matched by the pathname
+ regexStr += '([?][^#]*)?';
+ }
+ try {
+ new RegExp(regexStr);
+ } catch (e) {
+ logger.warn(
+ `Could not generate a valid regex from the remotePattern "${JSON.stringify(
+ pattern
+ )}". Please check the syntax.`
+ );
+ return undefined;
+ }
+ return regexStr;
+}
+
+async function writeNetlifyFrameworkConfig(config: AstroConfig, logger: AstroIntegrationLogger) {
+ const remoteImages: Array<string> = [];
+ // Domains get a simple regex match
+ remoteImages.push(
+ ...config.image.domains.map((domain) => `https?:\/\/${domain.replaceAll('.', '\\.')}\/.*`)
+ );
+ // Remote patterns need to be converted to regexes
+ remoteImages.push(
+ ...config.image.remotePatterns
+ .map((pattern) => remotePatternToRegex(pattern, logger))
+ .filter(Boolean as unknown as (pattern?: string) => pattern is string)
+ );
+
+ const headers = config.build.assetsPrefix
+ ? undefined
+ : [
+ {
+ for: `${config.base}${config.base.endsWith('/') ? '' : '/'}${config.build.assets}/*`,
+ values: {
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ },
+ },
+ ];
+
+ // See https://docs.netlify.com/image-cdn/create-integration/
+ const deployConfigDir = new URL('.netlify/v1/', config.root);
+ await mkdir(deployConfigDir, { recursive: true });
+ await writeFile(
+ new URL('./config.json', deployConfigDir),
+ JSON.stringify({
+ images: { remote_images: remoteImages },
+ headers,
+ })
+ );
+}
+
+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 {false}
+ */
+ edgeMiddleware?: boolean;
+
+ /**
+ * If enabled, Netlify Image CDN is used for image optimization.
+ * This transforms images on-the-fly without impacting build times.
+ *
+ * If disabled, Astro's built-in image optimization is run at build-time instead.
+ *
+ * @default {true}
+ */
+ imageCDN?: 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;
+ // Secret used to verify that the caller is the astro-generated edge middleware and not a third-party
+ const middlewareSecret = randomUUID();
+
+ let finalBuildOutput: HookParameters<'astro:config:done'>['buildOutput'];
+
+ const TRACE_CACHE = {};
+
+ const ssrBuildDir = () => new URL('./.netlify/build/', rootDir);
+ const ssrOutputDir = () => new URL('./.netlify/v1/functions/ssr/', rootDir);
+ const middlewareOutputDir = () => new URL('.netlify/v1/edge-functions/middleware/', rootDir);
+
+ const cleanFunctions = async () =>
+ await Promise.all([
+ emptyDir(middlewareOutputDir()),
+ emptyDir(ssrOutputDir()),
+ emptyDir(ssrBuildDir()),
+ ]);
+
+ async function writeRedirects(
+ routes: IntegrationResolvedRoute[],
+ dir: URL,
+ buildOutput: HookParameters<'astro:config:done'>['buildOutput'],
+ assets: HookParameters<'astro:build:done'>['assets']
+ ) {
+ // all other routes are handled by SSR
+ const staticRedirects = routes.filter(
+ (route) => route.type === 'redirect' && (route.redirect || route.redirectRoute)
+ );
+
+ // this is needed to support redirects to dynamic routes
+ // on static. not sure why this is needed, but it works.
+ for (const { pattern, redirectRoute } of staticRedirects) {
+ const distURL = assets.get(pattern);
+ if (!distURL && redirectRoute) {
+ const redirectDistURL = assets.get(redirectRoute.pattern);
+ if (redirectDistURL) {
+ assets.set(pattern, redirectDistURL);
+ }
+ }
+ }
+
+ const fallback = finalBuildOutput === 'static' ? '/.netlify/static' : '/.netlify/functions/ssr';
+ const redirects = createRedirectsFromAstroRoutes({
+ config: _config,
+ dir,
+ routeToDynamicTargetMap: new Map(staticRedirects.map((route) => [route, fallback])),
+ buildOutput,
+ assets,
+ });
+
+ if (!redirects.empty()) {
+ await appendFile(new URL('_redirects', outDir), `\n${redirects.print()}\n`);
+ }
+ }
+
+ async function writeSSRFunction({
+ notFoundContent,
+ logger,
+ root,
+ }: {
+ notFoundContent?: string;
+ logger: AstroIntegrationLogger;
+ root: URL;
+ }) {
+ const entry = new URL('./entry.mjs', ssrBuildDir());
+
+ const { handler } = await copyDependenciesToFunction(
+ {
+ entry,
+ outDir: ssrOutputDir(),
+ includeFiles: [],
+ excludeFiles: [],
+ logger,
+ root,
+ },
+ TRACE_CACHE
+ );
+
+ await writeFile(
+ new URL('./ssr.mjs', ssrOutputDir()),
+ `
+ import createSSRHandler from './${handler}';
+ export default createSSRHandler(${JSON.stringify({
+ cacheOnDemandPages: Boolean(integrationConfig?.cacheOnDemandPages),
+ notFoundContent,
+ })});
+ export const config = {
+ includedFiles: ['**/*'],
+ name: 'Astro SSR',
+ nodeBundler: 'none',
+ generator: '@astrojs/netlify@${packageVersion}',
+ path: '/*',
+ preferStatic: true,
+ };
+ `
+ );
+ }
+
+ async function writeMiddleware(entrypoint: URL) {
+ await mkdir(middlewareOutputDir(), { recursive: true });
+ await writeFile(
+ new URL('./entry.mjs', middlewareOutputDir()),
+ /* ts */ `
+ import { onRequest } from "${fileURLToPath(entrypoint).replaceAll('\\', '/')}";
+ import { createContext, trySerializeLocals } from 'astro/middleware';
+
+ export default async (request, context) => {
+ const ctx = createContext({
+ request,
+ params: {},
+ locals: { netlify: { context } }
+ });
+ // https://docs.netlify.com/edge-functions/api/#return-a-rewrite
+ ctx.rewrite = (target) => {
+ if(target instanceof Request) {
+ // We can only mutate headers, so if anything else is different, we need to fetch
+ // the target URL instead.
+ if(target.method !== request.method || target.body || target.url.origin !== request.url.origin) {
+ return fetch(target);
+ }
+ // We can't replace the headers object, so we need to delete all headers and set them again
+ request.headers.forEach((_value, key) => {
+ request.headers.delete(key);
+ });
+ target.headers.forEach((value, key) => {
+ request.headers.set(key, value);
+ });
+ return new URL(target.url);
+ }
+ return new URL(target, request.url);
+ };
+ const next = () => {
+ const { netlify, ...otherLocals } = ctx.locals;
+ request.headers.set("x-astro-locals", trySerializeLocals(otherLocals));
+ request.headers.set("x-astro-middleware-secret", "${middlewareSecret}");
+ 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()))],
+ // allow `node:` prefixed imports, which are valid in netlify's deno edge runtime
+ plugins: [
+ {
+ name: 'allowNodePrefixedImports',
+ setup(puglinBuild) {
+ puglinBuild.onResolve({ filter: /^node:.*$/ }, (args) => ({
+ path: args.path,
+ external: true,
+ }));
+ },
+ },
+ ],
+ target: 'es2022',
+ platform: 'neutral',
+ mainFields: ['module', 'main'],
+ outfile: fileURLToPath(new URL('./middleware.mjs', middlewareOutputDir())),
+ allowOverwrite: true,
+ format: 'esm',
+ bundle: true,
+ minify: false,
+ external: ['sharp'],
+ banner: {
+ // Import Deno polyfill for `process.env` at the top of the file
+ js: 'import process from "node:process";',
+ },
+ });
+ }
+
+ 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',
+ },
+ // TODO: this has type conflicts with @netlify/functions ^2.8.1
+ // @ts-expect-error: this has type conflicts with @netlify/functions ^2.8.1
+ 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' },
+ 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.');
+ },
+ // @ts-expect-error This is not currently included in the public Netlify types
+ flags: undefined,
+ json: (input) => Response.json(input),
+ log: console.info,
+ 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;
+ }
+
+ let routes: IntegrationResolvedRoute[];
+
+ return {
+ name: '@astrojs/netlify',
+ hooks: {
+ 'astro:config:setup': async ({ config, updateConfig }) => {
+ rootDir = config.root;
+ await cleanFunctions();
+
+ outDir = new URL('./dist/', rootDir);
+
+ const enableImageCDN = isRunningInNetlify && (integrationConfig?.imageCDN ?? true);
+
+ updateConfig({
+ outDir,
+ build: {
+ redirects: false,
+ client: outDir,
+ server: ssrBuildDir(),
+ },
+ vite: {
+ server: {
+ watch: {
+ ignored: [fileURLToPath(new URL('./.netlify/**', rootDir))],
+ },
+ },
+ },
+ image: {
+ service: {
+ entrypoint: enableImageCDN ? '@astrojs/netlify/image-service.js' : undefined,
+ },
+ },
+ });
+ },
+ 'astro:routes:resolved': (params) => {
+ routes = params.routes;
+ },
+ 'astro:config:done': async ({ config, setAdapter, logger, buildOutput }) => {
+ rootDir = config.root;
+ _config = config;
+
+ finalBuildOutput = buildOutput;
+
+ await writeNetlifyFrameworkConfig(config, logger);
+
+ const edgeMiddleware = integrationConfig?.edgeMiddleware ?? false;
+
+ setAdapter({
+ name: '@astrojs/netlify',
+ serverEntrypoint: '@astrojs/netlify/ssr-function.js',
+ exports: ['default'],
+ adapterFeatures: {
+ edgeMiddleware,
+ },
+ args: { middlewareSecret } satisfies Args,
+ supportedAstroFeatures: {
+ hybridOutput: 'stable',
+ staticOutput: 'stable',
+ serverOutput: 'stable',
+ sharpImageService: 'stable',
+ envGetSecret: 'stable',
+ },
+ });
+ },
+ 'astro:build:ssr': async ({ middlewareEntryPoint }) => {
+ astroMiddlewareEntryPoint = middlewareEntryPoint;
+ },
+ 'astro:build:done': async ({ assets, dir, logger }) => {
+ await writeRedirects(routes, dir, finalBuildOutput, assets);
+ logger.info('Emitted _redirects');
+
+ if (finalBuildOutput !== 'static') {
+ let notFoundContent = undefined;
+ try {
+ notFoundContent = await readFile(new URL('./404.html', dir), 'utf8');
+ } catch {}
+ await writeSSRFunction({ notFoundContent, logger, root: _config.root });
+ 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/lib/nft.ts b/packages/integrations/netlify/src/lib/nft.ts
new file mode 100644
index 000000000..c0584fd92
--- /dev/null
+++ b/packages/integrations/netlify/src/lib/nft.ts
@@ -0,0 +1,83 @@
+import { posix, relative, sep } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { copyFilesToFolder } from '@astrojs/internal-helpers/fs';
+import { appendForwardSlash } from '@astrojs/internal-helpers/path';
+import type { AstroIntegrationLogger } from 'astro';
+import { searchForWorkspaceRoot } from 'vite';
+
+// Based on the equivalent function in `@astrojs/vercel`
+export async function copyDependenciesToFunction(
+ {
+ entry,
+ outDir,
+ includeFiles,
+ excludeFiles,
+ logger,
+ root,
+ }: {
+ entry: URL;
+ outDir: URL;
+ includeFiles: URL[];
+ excludeFiles: URL[];
+ logger: AstroIntegrationLogger;
+ root: URL;
+ },
+ // we want to pass the caching by reference, and not by value
+ cache: object
+): Promise<{ handler: string }> {
+ const entryPath = fileURLToPath(entry);
+ logger.info(`Bundling function ${relative(fileURLToPath(outDir), entryPath)}`);
+
+ // Set the base to the workspace root
+ const base = pathToFileURL(appendForwardSlash(searchForWorkspaceRoot(fileURLToPath(root))));
+
+ // The Vite bundle includes an import to `@vercel/nft` for some reason,
+ // and that trips up `@vercel/nft` itself during the adapter build. Using a
+ // dynamic import helps prevent the issue.
+ // TODO: investigate why
+ const { nodeFileTrace } = await import('@vercel/nft');
+ const result = await nodeFileTrace([entryPath], {
+ base: fileURLToPath(base),
+ cache,
+ });
+
+ for (const error of result.warnings) {
+ if (error.message.startsWith('Failed to resolve dependency')) {
+ const [, module, file] =
+ /Cannot find module '(.+?)' loaded from (.+)/.exec(error.message) || [];
+
+ // The import(astroRemark) sometimes fails to resolve, but it's not a problem
+ if (module === '@astrojs/') continue;
+
+ // Sharp is always external and won't be able to be resolved, but that's also not a problem
+ if (module === 'sharp') continue;
+
+ if (entryPath === file) {
+ logger.debug(
+ `The module "${module}" couldn't be resolved. This may not be a problem, but it's worth checking.`
+ );
+ } else {
+ logger.debug(
+ `The module "${module}" inside the file "${file}" couldn't be resolved. This may not be a problem, but it's worth checking.`
+ );
+ }
+ }
+ // parse errors are likely not js and can safely be ignored,
+ // such as this html file in "main" meant for nw instead of node:
+ // https://github.com/vercel/nft/issues/311
+ else if (!error.message.startsWith('Failed to parse')) {
+ throw error;
+ }
+ }
+
+ const commonAncestor = await copyFilesToFolder(
+ [...result.fileList].map((file) => new URL(file, base)).concat(includeFiles),
+ outDir,
+ excludeFiles
+ );
+
+ return {
+ // serverEntry location inside the outDir, converted to posix
+ handler: relative(commonAncestor, entryPath).split(sep).join(posix.sep),
+ };
+}
diff --git a/packages/integrations/netlify/src/polyfill.ts b/packages/integrations/netlify/src/polyfill.ts
new file mode 100644
index 000000000..dc00f45d7
--- /dev/null
+++ b/packages/integrations/netlify/src/polyfill.ts
@@ -0,0 +1,3 @@
+import { applyPolyfills } from 'astro/app/node';
+
+applyPolyfills();
diff --git a/packages/integrations/netlify/src/ssr-function.ts b/packages/integrations/netlify/src/ssr-function.ts
new file mode 100644
index 000000000..5ea2e97f1
--- /dev/null
+++ b/packages/integrations/netlify/src/ssr-function.ts
@@ -0,0 +1,78 @@
+// Keep at the top
+import './polyfill.js';
+
+import type { Context } from '@netlify/functions';
+import type { SSRManifest } from 'astro';
+import { App } from 'astro/app';
+import { setGetEnv } from 'astro/env/setup';
+
+setGetEnv((key) => process.env[key]);
+
+export interface Args {
+ middlewareSecret: string;
+}
+
+const clientAddressSymbol = Symbol.for('astro.clientAddress');
+
+export const createExports = (manifest: SSRManifest, { middlewareSecret }: Args) => {
+ const app = new App(manifest);
+
+ function createHandler(integrationConfig: {
+ cacheOnDemandPages: boolean;
+ notFoundContent?: string;
+ }) {
+ return async function handler(request: Request, context: Context) {
+ const routeData = app.match(request);
+ if (!routeData && typeof integrationConfig.notFoundContent !== 'undefined') {
+ return new Response(integrationConfig.notFoundContent, {
+ status: 404,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+ }
+
+ Reflect.set(request, clientAddressSymbol, context.ip);
+ let locals: Record<string, unknown> = {};
+
+ const astroLocalsHeader = request.headers.get('x-astro-locals');
+ const middlewareSecretHeader = request.headers.get('x-astro-middleware-secret');
+ if (astroLocalsHeader) {
+ if (middlewareSecretHeader !== middlewareSecret) {
+ return new Response('Forbidden', { status: 403 });
+ }
+ // hide the secret from the rest of user and library code
+ request.headers.delete('x-astro-middleware-secret');
+ locals = JSON.parse(astroLocalsHeader);
+ }
+
+ 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) {
+ const isCacheableMethod = ['GET', 'HEAD'].includes(request.method);
+
+ // 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 (isCacheableMethod && !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..1c809ef8f
--- /dev/null
+++ b/packages/integrations/netlify/src/static.ts
@@ -0,0 +1,8 @@
+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();
+}