use regex::Regex;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
const CONFIG_CANDIDATES: &[&str] = &[
"open-next.config.ts",
"open-next.config.mts",
"open-next.config.cts",
"open-next.config.js",
"open-next.config.mjs",
"open-next.config.cjs",
];
const NEXT_CONFIG_CANDIDATES: &[&str] = &[
"next.config.ts",
"next.config.mts",
"next.config.cts",
"next.config.js",
"next.config.mjs",
"next.config.cjs",
];
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub(crate) struct OpenNextConfigInspection {
pub present: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub export_shape: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub build_command: Option<String>,
pub build_command_is_dynamic: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_limit_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_limit_default_mb: Option<u32>,
pub uses_workflow_build: bool,
pub uses_next_webpack: bool,
pub syncs_standalone_instrumentation: bool,
pub analysis: &'static str,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub(crate) struct NextConfigInspection {
pub present: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_path: Option<String>,
pub wrappers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_file_tracing_root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turbopack_root: Option<String>,
pub server_external_packages: Vec<String>,
pub remote_image_hostnames: Vec<String>,
pub workflow_lazy_discovery: bool,
pub uses_realpathed_next_resolution: bool,
pub analysis: &'static str,
}
pub(crate) fn inspect(worker_root: &Path) -> Result<OpenNextConfigInspection, String> {
let Some(path) = find_config(worker_root) else {
return Ok(OpenNextConfigInspection {
present: false,
config_path: None,
provider: None,
export_shape: None,
build_command: None,
build_command_is_dynamic: false,
memory_limit_env: None,
memory_limit_default_mb: None,
uses_workflow_build: false,
uses_next_webpack: false,
syncs_standalone_instrumentation: false,
analysis: "source-text",
});
};
let source = fs::read_to_string(&path)
.map_err(|error| format!("Failed to read {}: {}", path.display(), error))?;
let relative_path = path
.strip_prefix(worker_root)
.unwrap_or(path.as_path())
.to_string_lossy()
.replace('\\', "/");
Ok(inspect_source(&relative_path, &source))
}
pub(crate) fn inspect_next(worker_root: &Path) -> Result<NextConfigInspection, String> {
let Some(path) = NEXT_CONFIG_CANDIDATES
.iter()
.map(|candidate| worker_root.join(candidate))
.find(|path| path.is_file())
else {
return Ok(NextConfigInspection {
present: false,
config_path: None,
wrappers: Vec::new(),
output: None,
output_file_tracing_root: None,
turbopack_root: None,
server_external_packages: Vec::new(),
remote_image_hostnames: Vec::new(),
workflow_lazy_discovery: false,
uses_realpathed_next_resolution: false,
analysis: "source-text",
});
};
let source = fs::read_to_string(&path)
.map_err(|error| format!("Failed to read {}: {}", path.display(), error))?;
let relative_path = path
.strip_prefix(worker_root)
.unwrap_or(path.as_path())
.to_string_lossy()
.replace('\\', "/");
Ok(inspect_next_source(&relative_path, &source))
}
fn inspect_next_source(config_path: &str, source: &str) -> NextConfigInspection {
let mut wrappers = Vec::new();
if source.contains("withWorkflow") {
wrappers.push("withWorkflow".to_string());
}
if source.contains("withVercelToolbar") {
wrappers.push("withVercelToolbar".to_string());
}
NextConfigInspection {
present: true,
config_path: Some(config_path.to_string()),
wrappers,
output: extract_property_value(source, "output"),
output_file_tracing_root: extract_identifier_property(source, "outputFileTracingRoot"),
turbopack_root: extract_nested_identifier_property(source, "turbopack", "root"),
server_external_packages: extract_string_array_property(source, "serverExternalPackages"),
remote_image_hostnames: extract_named_string_values(source, "remotePatterns", "hostname"),
workflow_lazy_discovery: source.contains("lazyDiscovery: true"),
uses_realpathed_next_resolution: source.contains("realpathSync")
&& source.contains(r#"require.resolve("next/package.json")"#),
analysis: "source-text",
}
}
fn find_config(worker_root: &Path) -> Option<PathBuf> {
CONFIG_CANDIDATES
.iter()
.map(|candidate| worker_root.join(candidate))
.find(|path| path.is_file())
}
fn inspect_source(config_path: &str, source: &str) -> OpenNextConfigInspection {
let provider = source
.contains("@opennextjs/cloudflare")
.then(|| "@opennextjs/cloudflare".to_string());
let export_shape = if source.contains("export default defineCloudflareConfig") {
Some("defineCloudflareConfig".to_string())
} else if source.contains("export default") {
Some("object".to_string())
} else {
None
};
let build_command = extract_property_value(source, "buildCommand");
let memory = Regex::new(r#"process\.env\.([A-Z][A-Z0-9_]*)\s*\?\?\s*['"]([0-9]+)['"]"#)
.ok()
.and_then(|regex| regex.captures(source));
let memory_limit_env = memory
.as_ref()
.and_then(|captures| captures.get(1).map(|value| value.as_str().to_string()));
let memory_limit_default_mb = memory
.as_ref()
.and_then(|captures| captures.get(2))
.and_then(|value| value.as_str().parse::<u32>().ok());
OpenNextConfigInspection {
present: true,
config_path: Some(config_path.to_string()),
provider,
export_shape,
build_command_is_dynamic: build_command
.as_deref()
.is_some_and(|command| command.contains("$")),
build_command,
uses_workflow_build: source.contains("workflow-build.mjs"),
uses_next_webpack: source.contains("next build --webpack"),
syncs_standalone_instrumentation: source.contains("sync-standalone-instrumentation.mjs"),
memory_limit_env,
memory_limit_default_mb,
analysis: "source-text",
}
}
fn extract_property_value(source: &str, property: &str) -> Option<String> {
let property_start = source.find(property)? + property.len();
let remainder = source[property_start..].trim_start();
let remainder = remainder.strip_prefix(':')?.trim_start();
let delimiter = remainder.as_bytes().first().copied()?;
let end_delimiter = if delimiter == 96 { 96 } else { delimiter };
let body = &remainder[1..];
let end = body
.as_bytes()
.iter()
.position(|byte| *byte == end_delimiter)?;
Some(body[..end].to_string())
}
fn extract_identifier_property(source: &str, property: &str) -> Option<String> {
let property_start = source.find(property)? + property.len();
let remainder = source[property_start..]
.trim_start()
.strip_prefix(':')?
.trim_start();
let value = remainder
.split(|character| character == ',' || character == '}' || character == '\n')
.next()?
.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn extract_nested_identifier_property(
source: &str,
parent: &str,
property: &str,
) -> Option<String> {
let parent_start = source.find(parent)?;
extract_identifier_property(&source[parent_start..], property)
}
fn extract_string_array_property(source: &str, property: &str) -> Vec<String> {
let Some(property_start) = source.find(property).map(|index| index + property.len()) else {
return Vec::new();
};
let Some(remainder) = source[property_start..]
.trim_start()
.strip_prefix(':')
.map(str::trim_start)
else {
return Vec::new();
};
let Some(end) = remainder.find(']') else {
return Vec::new();
};
let Ok(regex) = Regex::new(r#"["']([^"']+)["']"#) else {
return Vec::new();
};
regex
.captures_iter(&remainder[..end])
.filter_map(|captures| captures.get(1).map(|value| value.as_str().to_string()))
.collect()
}
fn extract_named_string_values(source: &str, section: &str, property: &str) -> Vec<String> {
let Some(section_start) = source.find(section) else {
return Vec::new();
};
let section = &source[section_start..];
let section_end = section.find(']').unwrap_or(section.len());
let Ok(regex) = Regex::new(&format!(r#"{property}\s*:\s*["']([^"']+)["']"#)) else {
return Vec::new();
};
regex
.captures_iter(§ion[..section_end])
.filter_map(|captures| captures.get(1).map(|value| value.as_str().to_string()))
.collect()
}
#[cfg(test)]
mod tests {
use super::{inspect_source, OpenNextConfigInspection};
#[test]
fn inspects_cloudflare_build_configuration_without_executing_it() {
let inspection = inspect_source(
"open-next.config.ts",
r#"
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
const nextBuildMaxOldSpaceSizeMb =
process.env.NEXT_BUILD_MAX_OLD_SPACE_SIZE_MB ?? "4096";
export default {
...defineCloudflareConfig(),
buildCommand: 'node scripts/workflow-build.mjs && node ./node_modules/next/dist/bin/next build --webpack && node scripts/sync-standalone-instrumentation.mjs',
};
"#,
);
assert_eq!(
inspection.config_path.as_deref(),
Some("open-next.config.ts")
);
assert_eq!(
inspection.provider.as_deref(),
Some("@opennextjs/cloudflare")
);
assert_eq!(inspection.export_shape.as_deref(), Some("object"));
assert_eq!(
inspection.memory_limit_env.as_deref(),
Some("NEXT_BUILD_MAX_OLD_SPACE_SIZE_MB")
);
assert_eq!(inspection.memory_limit_default_mb, Some(4096));
assert!(inspection.uses_workflow_build);
assert!(inspection.uses_next_webpack);
assert!(inspection.syncs_standalone_instrumentation);
}
#[test]
fn distinguishes_default_adapter_config_without_a_custom_build_command() {
let inspection = inspect_source(
"open-next.config.ts",
r#"import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig();"#,
);
assert_eq!(
inspection,
OpenNextConfigInspection {
present: true,
config_path: Some("open-next.config.ts".to_string()),
provider: Some("@opennextjs/cloudflare".to_string()),
export_shape: Some("defineCloudflareConfig".to_string()),
build_command: None,
build_command_is_dynamic: false,
memory_limit_env: None,
memory_limit_default_mb: None,
uses_workflow_build: false,
uses_next_webpack: false,
syncs_standalone_instrumentation: false,
analysis: "source-text",
}
);
}
#[test]
fn inspects_next_wrappers_and_cloudflare_build_workarounds() {
let parsed = super::inspect_next_source(
"next.config.ts",
r#"
import { realpathSync } from "node:fs";
const nextPackage = require.resolve("next/package.json");
import withVercelToolbar from "@vercel/toolbar/plugins/next";
import { withWorkflow } from "workflow/next";
const repoRoot = resolveTurbopackRoot(configDir);
const nextConfig = {
output: "standalone",
outputFileTracingRoot: repoRoot,
serverExternalPackages: ["pg"],
images: { remotePatterns: [{ hostname: "example.com", protocol: "https" }] },
turbopack: { root: repoRoot },
};
export default withWorkflow(withVercelToolbar()(nextConfig), {
workflows: { lazyDiscovery: true }
});
"#,
);
assert_eq!(parsed.wrappers, vec!["withWorkflow", "withVercelToolbar"]);
assert_eq!(parsed.output.as_deref(), Some("standalone"));
assert_eq!(parsed.output_file_tracing_root.as_deref(), Some("repoRoot"));
assert_eq!(parsed.turbopack_root.as_deref(), Some("repoRoot"));
assert_eq!(parsed.server_external_packages, vec!["pg"]);
assert_eq!(parsed.remote_image_hostnames, vec!["example.com"]);
assert!(parsed.workflow_lazy_discovery);
assert!(parsed.uses_realpathed_next_resolution);
}
}