ferrosite 0.1.0

A railway-oriented static site generator for personal homepages
Documentation
use std::path::Path;

use crate::config::AwsDeployConfig;
use crate::error::{SiteError, SiteResult};
use crate::plugin::{Plugin, PluginRegistry};

use super::{count_html_files, require_tool, serialized_cqrs, DeployResult, Deployer};

pub struct AwsDeployer {
    config: AwsDeployConfig,
}

impl AwsDeployer {
    pub(super) fn new(config: AwsDeployConfig) -> Self {
        Self { config }
    }
}

impl Deployer for AwsDeployer {
    fn provider_name(&self) -> &'static str {
        "AWS S3 + CloudFront"
    }

    fn deploy_static(&self, dist_dir: &Path) -> SiteResult<DeployResult> {
        println!("☁️  Deploying to AWS S3: s3://{}", self.config.bucket_name);

        require_tool("aws", "https://aws.amazon.com/cli/")?;

        let sync_output = std::process::Command::new("aws")
            .args([
                "s3",
                "sync",
                dist_dir.to_str().unwrap_or("dist"),
                &format!("s3://{}", self.config.bucket_name),
                "--delete",
                "--region",
                &self.config.region,
                "--exclude",
                "_workers/*",
            ])
            .output()
            .map_err(|e| SiteError::Deploy {
                provider: "aws".into(),
                message: format!("aws s3 sync failed: {}", e),
            })?;

        if !sync_output.status.success() {
            let stderr = String::from_utf8_lossy(&sync_output.stderr);
            return Err(SiteError::Deploy {
                provider: "aws".into(),
                message: stderr.to_string(),
            });
        }

        if let Some(dist_id) = &self.config.cloudfront_distribution_id {
            println!("  Invalidating CloudFront distribution: {}", dist_id);
            let _ = std::process::Command::new("aws")
                .args([
                    "cloudfront",
                    "create-invalidation",
                    "--distribution-id",
                    dist_id,
                    "--paths",
                    "/*",
                ])
                .output();
        }

        let url = self.config.domain.clone().unwrap_or_else(|| {
            format!(
                "https://{}.s3-website-{}.amazonaws.com",
                self.config.bucket_name, self.config.region
            )
        });

        Ok(DeployResult {
            url,
            provider: "AWS S3 + CloudFront".into(),
            pages_deployed: count_html_files(dist_dir),
        })
    }

    fn deploy_workers(&self, _dist_dir: &Path, plugins: &PluginRegistry) -> SiteResult<()> {
        if plugins.is_empty() {
            return Ok(());
        }

        let staging_dir = _dist_dir.join("_ferrosite").join("deploy").join("aws");
        std::fs::create_dir_all(&staging_dir)?;

        println!("⚡ Deploying Lambda functions via AWS SAM/CDK…");
        for plugin in plugins.workers() {
            let plugin_dir = staging_dir.join(&plugin.manifest.name);
            std::fs::create_dir_all(&plugin_dir)?;
            std::fs::write(
                plugin_dir.join("index.js"),
                generate_aws_lambda_worker(plugin),
            )?;
        }
        println!("  Staged Lambda handlers at {}", staging_dir.display());
        println!("  Note: Lambda deployment requires SAM CLI or CDK. Run 'ferrosite deploy-workers --provider aws' for interactive setup.");
        Ok(())
    }
}

pub(crate) fn generate_aws_lambda_worker(plugin: &Plugin) -> String {
    let (commands_json, queries_json) = serialized_cqrs(plugin);
    let manifest = &plugin.manifest;

    format!(
        r#"// Auto-generated CQRS wrapper for plugin: {name}
// Route: {route}
// Worker runtime: aws-lambda

const COMMANDS = {commands};
const QUERIES = {queries};

const CORS_HEADERS = {{
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
  "Content-Type": "application/json",
}};

function defaultCommandName() {{
    return COMMANDS.length === 1 ? COMMANDS[0].name : null;
}}

function wantsJsonResponse(headers) {{
    return headerValue(headers, "accept").toLowerCase().includes("application/json");
}}

function normalizeRedirectTarget(target) {{
    if (typeof target !== "string") {{
        return "/contact/";
    }}

    if (!target.startsWith("/") || target.startsWith("//")) {{
        return "/contact/";
    }}

    return target;
}}

function redirectLocation(payload, status) {{
    const base = normalizeRedirectTarget(payload?.redirect_to);
    const url = new URL(base, "https://ferrosite.local");
    url.hash = status === "success" ? "contact-form-success" : "contact-form-error";
    return `${{url.pathname}}${{url.search}}${{url.hash}}`;
}}

function redirectResponse(location) {{
    return {{
        statusCode: 303,
        headers: {{
            ...CORS_HEADERS,
            Location: location,
        }},
        body: "",
    }};
}}

function normalizeCommandEnvelope(body) {{
    const raw = body && typeof body === "object" && !Array.isArray(body) ? body : {{}};

    if (typeof raw.command === "string" && raw.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload)) {{
        return {{ command: raw.command, payload: raw.payload }};
    }}

    if (typeof raw.command === "string") {{
        const {{ command, ...payload }} = raw;
        return {{ command, payload }};
    }}

    const inferred = defaultCommandName();
    if (inferred) {{
        return {{ command: inferred, payload: raw }};
    }}

    throw new Error("Command is required");
}}

function headerValue(headers, name) {{
    if (!headers) {{
        return "";
    }}

    const direct = headers[name] ?? headers[name.toLowerCase()] ?? headers[name.toUpperCase()];
    if (typeof direct === "string") {{
        return direct;
    }}

    return "";
}}

function parseCommandRequest(rawBody, contentType) {{
    if (rawBody && typeof rawBody === "object") {{
        return normalizeCommandEnvelope(rawBody);
    }}

    const raw = typeof rawBody === "string" ? rawBody : "";
    const normalizedType = (contentType || "").toLowerCase();

    if (!raw.trim()) {{
        return normalizeCommandEnvelope({{}});
    }}

    if (normalizedType.includes("application/x-www-form-urlencoded") || raw.includes("=")) {{
        return normalizeCommandEnvelope(Object.fromEntries(new URLSearchParams(raw).entries()));
    }}

    return normalizeCommandEnvelope(JSON.parse(raw));
}}

{worker_source}

function jsonResponse(body, statusCode = 200) {{
  return {{
    statusCode,
    headers: CORS_HEADERS,
    body: JSON.stringify(body),
  }};
}}

exports.handler = async function(event, context) {{
  const method = event?.requestContext?.http?.method || event?.httpMethod || "GET";

  if (method === "OPTIONS") {{
    return {{
      statusCode: 204,
      headers: CORS_HEADERS,
      body: "",
    }};
  }}

  try {{
    if (method === "POST") {{
      const rawBody = event?.body || "{{}}";
      const decodedBody = event?.isBase64Encoded
        ? Buffer.from(rawBody, "base64").toString("utf8")
        : rawBody;
            const contentType = headerValue(event?.headers, "content-type");
            const {{ command, payload }} = parseCommandRequest(decodedBody, contentType);

      const known = COMMANDS.find(c => c.name === command);
      if (!known) {{
        return jsonResponse({{ error: `Unknown command: ${{command}}` }}, 400);
      }}

      const result = await handleCommand(command, payload, process.env, context);
            if (!wantsJsonResponse(event?.headers)) {{
                return redirectResponse(redirectLocation(payload, "success"));
            }}
      return jsonResponse({{ ok: true, result }});
    }}

    if (method === "GET") {{
      const params = event?.queryStringParameters || {{}};
      const query = params.query;

      const known = QUERIES.find(q => q.name === query);
      if (!known) {{
        return jsonResponse({{ error: `Unknown query: ${{query}}` }}, 400);
      }}

      const result = await handleQuery(query, params, process.env, context);
      return jsonResponse({{ ok: true, result }});
    }}

    return jsonResponse({{ error: "Method not allowed" }}, 405);
    }} catch (err) {{
        if (method === "POST" && !wantsJsonResponse(event?.headers)) {{
            try {{
                const rawBody = event?.body || "";
                const decodedBody = event?.isBase64Encoded
                    ? Buffer.from(rawBody, "base64").toString("utf8")
                    : rawBody;
                const payload = parseCommandRequest(decodedBody, headerValue(event?.headers, "content-type"));
                return redirectResponse(redirectLocation(payload.payload || payload, "error"));
            }} catch {{
                return redirectResponse(redirectLocation({{}}, "error"));
            }}
        }}
    return jsonResponse({{ error: err.message }}, 500);
  }}
}};
"#,
        name = manifest.name,
        route = manifest.worker_route,
        commands = commands_json,
        queries = queries_json,
        worker_source = plugin.worker_source,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plugin::{PluginManifest, SandboxConfig};
    use std::path::PathBuf;

    fn fixture_plugin() -> Plugin {
        Plugin {
            manifest: PluginManifest {
                name: "contact-form".into(),
                version: "1.0.0".into(),
                description: String::new(),
                author: "ferrosite".into(),
                slots: vec!["contact-form".into()],
                head_inject: Vec::new(),
                commands: Vec::new(),
                queries: Vec::new(),
                component_file: "component.js".into(),
                worker_file: "worker.js".into(),
                worker_route: "/api/contact".into(),
                worker_runtime: "cloudflare-worker".into(),
                required_env: Vec::new(),
                sandbox: SandboxConfig::default(),
            },
            component_source: String::new(),
            worker_source:
                "async function handleCommand() { return { ok: true }; }\nasync function handleQuery() { return { ok: true }; }"
                    .into(),
            dir: PathBuf::from("plugins/contact-form"),
        }
    }

    #[test]
    fn generates_lambda_wrapper() {
        let output = generate_aws_lambda_worker(&fixture_plugin());
        assert!(output.contains("exports.handler = async function"));
        assert!(output.contains("parseCommandRequest"));
        assert!(output.contains("URLSearchParams"));
        assert!(output.contains("contact-form-success"));
    }
}