import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as sys from "@lazarv/react-server/lib/sys.mjs";
import {
banner,
createAdapter,
deepMerge,
message,
readToml,
success,
writeToml,
} from "@lazarv/react-server/adapters/core";
import { copyFile, mkdir } from "node:fs/promises";
const cwd = sys.cwd();
const outDir = join(cwd, "netlify");
const outStaticDir = join(outDir, "static");
const functionsDir = join(outDir, "functions");
const edgeFunctionsDir = join(outDir, "edge-functions");
const adapterDir = dirname(fileURLToPath(import.meta.url));
// Helper to determine if edge functions should be used
// Checks both adapter options (edgeFunctions) and CLI build arg (--edge)
const isEdgeFunctions = (adapterOptions, cliOptions) =>
Boolean(adapterOptions?.edgeFunctions || cliOptions?.edge === true);
/**
* Build options that the Netlify adapter requires.
* These are automatically applied when using this adapter.
* @param {Object} adapterOptions - Adapter configuration options
* @param {boolean} adapterOptions.edgeFunctions - When true, builds for Netlify Edge Functions instead of serverless
* @param {Object} cliOptions - CLI build options
* @param {boolean} cliOptions.edge - When true (--edge flag), builds for Netlify Edge Functions instead of serverless
*/
export const buildOptions = (adapterOptions, cliOptions) => ({
// Preserve the original CLI --edge flag value before it gets overwritten
// by the edge object below. The handler checks this to determine function type.
netlifyEdgeFunctions: isEdgeFunctions(adapterOptions, cliOptions),
// Enable edge build mode for both serverless and edge functions:
// - Adds the edge entry as an input to the build
// - Bundles react-server internals into a shared chunk
// - Route modules import from the shared chunk (no bare specifiers at runtime)
edge: {
// Use the appropriate entry point based on function type:
// - edge.mjs for Netlify Edge Functions (Deno runtime)
// - node.mjs for Netlify Serverless Functions (Node.js runtime)
entry: isEdgeFunctions(adapterOptions, cliOptions)
? join(adapterDir, "functions/edge.mjs")
: join(adapterDir, "functions/node.mjs"),
},
});
export const adapter = createAdapter({
name: "Netlify",
outDir,
outStaticDir,
// outServerDir is computed dynamically based on edgeFunctions option or --edge CLI flag
handler: async function ({
adapterOptions,
copy,
files,
options,
reactServerDir,
reactServerOutDir,
}) {
// Check the preserved flag (set by buildOptions) or adapter config
const isEdge = Boolean(
options?.netlifyEdgeFunctions || adapterOptions?.edgeFunctions
);
const outServerDir = isEdge
? edgeFunctionsDir
: join(functionsDir, "server");
const edgeConfig =
typeof adapterOptions?.edgeFunctions === "object"
? (adapterOptions.edgeFunctions.config ?? {})
: {};
// Copy server files to the computed output directory
if (isEdge) {
await mkdir(join(outServerDir, `${reactServerOutDir}/server`), {
recursive: true,
});
await copyFile(
join(reactServerDir, "server/edge.mjs"),
join(outServerDir, `${reactServerOutDir}/server/edge.mjs`)
);
// Copy source map file for edge.mjs if sourcemaps are enabled
if (options.sourcemap) {
const edgeMapPath = join(reactServerDir, "server/edge.mjs.map");
if (existsSync(edgeMapPath)) {
await copyFile(
edgeMapPath,
join(outServerDir, `${reactServerOutDir}/server/edge.mjs.map`)
);
}
}
} else {
await copy.server(outServerDir);
}
if (isEdge) {
// Netlify Edge Functions mode
banner("building Netlify Edge Function", { emoji: "⚡" });
message("creating", "server edge function");
// Create server.mjs that re-exports from the bundled edge.mjs
const entryFile = join(outServerDir, "server.mjs");
writeFileSync(
entryFile,
`export { default } from "./${reactServerOutDir}/server/edge.mjs";
export const config = {
path: "/*",
...${JSON.stringify(edgeConfig)},
};
`
);
success("server edge function created");
} else if (adapterOptions?.serverlessFunctions !== false) {
// Node.js Serverless Functions mode (default)
// Netlify supports directory-based functions where the directory name is the function name
// and it looks for index.mjs inside the directory
banner("building Netlify Serverless Function", { emoji: "⚡" });
message("creating", "server function");
// Create index.mjs that re-exports from the bundled edge.mjs
// Note: `preferStatic` is intentionally omitted (Netlify default
// `false`) so the function runs for every request and can do
// Accept-aware content negotiation. The framework's in-process
// static handler still serves matching files directly without SSR
// overhead. Users who don't need content negotiation can opt back
// in via `adapterOptions.functions.config.preferStatic = true`.
const entryFile = join(outServerDir, "index.mjs");
writeFileSync(
entryFile,
`export { default } from "./${reactServerOutDir}/server/edge.mjs";
export const config = {
path: "/*",
...${JSON.stringify(adapterOptions?.functions?.config ?? {})},
};
`
);
// Create package.json for ESM support
writeFileSync(
join(outServerDir, "package.json"),
JSON.stringify({ type: "module" }, null, 2)
);
success("server function created");
}
// Create netlify.toml configuration
banner("creating Netlify configuration", { emoji: "⚙️" });
// Try to get app name from adapter options or package.json
let appName = adapterOptions?.name;
if (!appName) {
const packageJsonPath = join(cwd, "package.json");
if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(
readFileSync(packageJsonPath, "utf-8")
);
// Remove scope from package name (e.g., @scope/name -> name)
appName = packageJson.name?.replace(/^@[^/]+\//, "");
} catch {
// Ignore parsing errors
}
}
}
// Build explicit list of static files to exclude from edge function
// Static files must be served directly from CDN without invoking the edge function
// Use a Set to automatically deduplicate paths
const excludedPaths = new Set();
const existingNetlifyPath = join(cwd, "react-server.netlify.toml");
const userConfig = readToml(existingNetlifyPath);
if (isEdge) {
// Get all static files from the build output
const [staticFiles, assetFiles, clientFiles, publicFiles] =
await Promise.all([
files.static(),
files.assets(),
files.client(),
files.public(),
]);
const isRscFile = (f) =>
f === "rsc.x-component" ||
f.endsWith("/rsc.x-component") ||
f.endsWith(".rsc.x-component");