index.ts

  1/**
  2 * Shared Next.js configuration utilities
  3 *
  4 * Reusable configuration for Next.js apps in a monorepo: CSP headers,
  5 * webpack tweaks, remote image patterns, rewrites.
  6 *
  7 * Derived from a real monorepo shape — sanitized of company-specific identifiers
  8 * but structurally identical so patch mechanics get tested against realistic
  9 * CSP/rewrite layering.
 10 */
 11
 12import type { NextConfig } from "next";
 13import {
 14  buildConnectSrc,
 15  getSupabaseOrigin,
 16  HSTS_VALUE,
 17  PERMISSIONS_POLICY_VALUE,
 18} from "../security/origins";
 19
 20export interface SharedNextConfigOptions {
 21  appName?: string;
 22  additionalImgSrc?: string[];
 23  additionalConnectSrc?: string[];
 24  additionalScriptSrc?: string[];
 25  enableMapbox?: boolean;
 26  serverExternalPackages?: string[];
 27  optimizePackageImports?: string[];
 28  transpilePackages?: string[];
 29}
 30
 31const DEFAULT_WORKSPACE_TRANSPILE_PACKAGES = [
 32  "@app/backend",
 33  "@app/database",
 34  "@app/mcp-ui",
 35  "@app/shared",
 36  "@app/supabase-client",
 37  "@app/ui",
 38];
 39
 40const KNOWN_SUPABASE_CUSTOM_DOMAINS = [
 41  "db.example.com",
 42  "db-staging.example.com",
 43] as const;
 44
 45const KNOWN_SUPABASE_CUSTOM_ORIGINS = KNOWN_SUPABASE_CUSTOM_DOMAINS.map(
 46  (h) => `https://${h}`
 47);
 48
 49interface CSPConfig {
 50  scriptSrc: string;
 51  styleSrc: string;
 52  imgSrc: string;
 53  fontSrc: string;
 54  connectSrc: string;
 55  frameSrc: string;
 56  workerSrc: string;
 57  childSrc: string;
 58}
 59
 60function buildCSPConfig(options: SharedNextConfigOptions = {}): CSPConfig {
 61  const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
 62  const supabaseOrigin = getSupabaseOrigin();
 63  const connectSrcBase = buildConnectSrc(apiUrl);
 64
 65  const isPreview = true;
 66
 67  const scriptSrc = [
 68    "'self'",
 69    "'unsafe-eval'",
 70    "'unsafe-inline'",
 71    "https://va.vercel-scripts.com",
 72    ...(isPreview ? ["https://vercel.live"] : []),
 73    ...(options.additionalScriptSrc || []),
 74  ].join(" ");
 75
 76  const styleSrc = [
 77    "'self'",
 78    "'unsafe-inline'",
 79    ...(isPreview ? ["https://fonts.googleapis.com"] : []),
 80  ].join(" ");
 81
 82  const imgSrc = [
 83    "'self'",
 84    "data:",
 85    "blob:",
 86    "http://localhost:54321",
 87    "http://127.0.0.1:54321",
 88    "https://*.supabase.co",
 89    ...KNOWN_SUPABASE_CUSTOM_ORIGINS,
 90    ...(supabaseOrigin &&
 91    !KNOWN_SUPABASE_CUSTOM_ORIGINS.includes(
 92      supabaseOrigin as (typeof KNOWN_SUPABASE_CUSTOM_ORIGINS)[number]
 93    )
 94      ? [supabaseOrigin]
 95      : []),
 96    ...(options.enableMapbox
 97      ? ["https://api.mapbox.com", "https://*.tiles.mapbox.com"]
 98      : []),
 99    ...(isPreview ? ["https://vercel.com", "https://vercel.live"] : []),
100    ...(options.additionalImgSrc || []),
101  ].join(" ");
102
103  const fontSrc = [
104    "'self'",
105    "data:",
106    ...(options.enableMapbox ? ["https://api.mapbox.com"] : []),
107    ...(isPreview ? ["https://fonts.gstatic.com", "https://vercel.live"] : []),
108  ].join(" ");
109
110  const connectExtras = [
111    ...(options.enableMapbox
112      ? [
113          "https://api.mapbox.com",
114          "https://*.tiles.mapbox.com",
115          "https://events.mapbox.com",
116        ]
117      : []),
118    ...(isPreview
119      ? ["https://vercel.live", "wss://*.pusher.com", "https://*.pusher.com"]
120      : []),
121    ...(options.additionalConnectSrc || []),
122  ];
123
124  const connectSrc = [...connectSrcBase, ...connectExtras].join(" ");
125  const frameSrc = isPreview ? "'self' https://vercel.live" : "'self'";
126  const workerSrc = "'self' blob:";
127  const childSrc = "'self' blob:";
128
129  return {
130    scriptSrc,
131    styleSrc,
132    imgSrc,
133    fontSrc,
134    connectSrc,
135    frameSrc,
136    workerSrc,
137    childSrc,
138  };
139}
140
141export function buildSecurityHeaders(
142  options: SharedNextConfigOptions = {}
143): NextConfig["headers"] {
144  return () => {
145    const csp = buildCSPConfig(options);
146    const isPreview = true;
147
148    return Promise.resolve([
149      {
150        source: "/(.*)",
151        headers: [
152          {
153            key: "Content-Security-Policy",
154            value: `default-src 'self'; script-src ${csp.scriptSrc}; style-src ${csp.styleSrc}; img-src ${csp.imgSrc}; font-src ${csp.fontSrc}; connect-src ${csp.connectSrc}; frame-src ${csp.frameSrc}; worker-src ${csp.workerSrc}; child-src ${csp.childSrc}; frame-ancestors 'self';`,
155          },
156          { key: "X-Frame-Options", value: "SAMEORIGIN" },
157          { key: "Strict-Transport-Security", value: HSTS_VALUE },
158          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
159          { key: "Permissions-Policy", value: PERMISSIONS_POLICY_VALUE },
160          { key: "Cross-Origin-Embedder-Policy", value: "unsafe-none" },
161          { key: "Cross-Origin-Resource-Policy", value: "cross-origin" },
162          {
163            key: "Cross-Origin-Opener-Policy",
164            value: "same-origin-allow-popups",
165          },
166          ...(isPreview
167            ? [
168                {
169                  key: "Access-Control-Allow-Origin",
170                  value: "https://vercel.live",
171                },
172                {
173                  key: "Access-Control-Allow-Methods",
174                  value: "GET,POST,PUT,PATCH,DELETE,OPTIONS",
175                },
176                { key: "Access-Control-Allow-Headers", value: "*" },
177                { key: "Access-Control-Allow-Credentials", value: "true" },
178                { key: "Vary", value: "Origin" },
179              ]
180            : []),
181        ],
182      },
183    ]);
184  };
185}
186
187export function buildApiProxyRewrites(): NextConfig["rewrites"] {
188  return () => {
189    const rewrites: Array<{ source: string; destination: string }> = [];
190
191    if (
192      process.env.NEXT_PUBLIC_API_PROXY === "1" &&
193      process.env.NEXT_PUBLIC_API_URL
194    ) {
195      const proxyPath = process.env.NEXT_PUBLIC_API_PROXY_PATH || "/backend";
196      rewrites.push({
197        source: `${proxyPath}/:path*`,
198        destination: `${process.env.NEXT_PUBLIC_API_URL}/:path*`,
199      });
200    }
201
202    return {
203      beforeFiles: rewrites,
204      afterFiles: [],
205      fallback: [],
206    };
207  };
208}
209
210type WebpackConfig = Parameters<NonNullable<NextConfig["webpack"]>>[0];
211type WebpackContext = Parameters<NonNullable<NextConfig["webpack"]>>[1];
212
213export function buildWebpackConfig(
214  options: SharedNextConfigOptions = {}
215): NextConfig["webpack"] {
216  return (config: WebpackConfig, context: WebpackContext) => {
217    if (context.isServer && options.serverExternalPackages?.length) {
218      config.externals = config.externals || [];
219      if (Array.isArray(config.externals)) {
220        for (const pkg of options.serverExternalPackages) {
221          config.externals.push({ [pkg]: `commonjs ${pkg}` });
222        }
223      }
224    }
225    return config;
226  };
227}
228
229export function buildSupabaseRemotePatterns(): Array<{
230  protocol: "http" | "https";
231  hostname: string;
232  port?: string;
233  pathname: string;
234}> {
235  return [
236    {
237      protocol: "http",
238      hostname: "localhost",
239      port: "54321",
240      pathname: "/storage/v1/object/public/**",
241    },
242    {
243      protocol: "https",
244      hostname: "*.supabase.co",
245      pathname: "/storage/v1/object/public/**",
246    },
247    ...KNOWN_SUPABASE_CUSTOM_DOMAINS.map((hostname) => ({
248      protocol: "https" as const,
249      hostname,
250      pathname: "/storage/v1/object/public/**",
251    })),
252  ];
253}
254
255/**
256 * Create a shared Next.js configuration base. Apps extend this via spread.
257 */
258export function createBaseNextConfig(
259  options: SharedNextConfigOptions = {}
260): NextConfig {
261  const transpilePackages = Array.from(
262    new Set([
263      ...DEFAULT_WORKSPACE_TRANSPILE_PACKAGES,
264      ...(options.transpilePackages || []),
265    ])
266  );
267
268  return {
269    experimental: {
270      turbopackFileSystemCacheForDev: true,
271      ...(options.optimizePackageImports && {
272        optimizePackageImports: options.optimizePackageImports,
273      }),
274    },
275    env: {
276      VERCEL_RELATED_PROJECTS: process.env.VERCEL_RELATED_PROJECTS || "",
277      VERCEL_ENV: process.env.VERCEL_ENV || "",
278    },
279    ...(options.serverExternalPackages && {
280      serverExternalPackages: options.serverExternalPackages,
281    }),
282    transpilePackages,
283    webpack: buildWebpackConfig(options),
284    headers: buildSecurityHeaders(options),
285    rewrites: buildApiProxyRewrites(),
286  };
287}