xbp 10.40.1

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Static inspection of the optional OpenNext configuration owned by a Worker app.
//!
//! The file is intentionally treated as source text. XBP must not execute a
//! repository's TypeScript config during an inventory or preflight operation,
//! because doing so could run arbitrary build-time code or resolve secrets.

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(&section[..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);
    }
}