next.js/crates/next-error-code-swc-plugin/src/lib.rs
lib.rs693 lines21.7 KB
use std::fs;

use rustc_hash::FxHashMap;
use swc_core::{
    common::{Span, SyntaxContext},
    ecma::{ast::*, transforms::testing::test_inline, visit::*},
    plugin::{plugin_transform, proxies::TransformPluginProgramMetadata},
};

pub struct TransformVisitor {
    errors: FxHashMap<String, String>,
    resolved_bindings: FxHashMap<Id, String>,
}

struct BindingCollector {
    bindings: FxHashMap<Id, String>,
}

impl Visit for BindingCollector {
    fn visit_var_declarator(&mut self, n: &VarDeclarator) {
        if let Pat::Ident(binding_ident) = &n.name {
            if let Some(init) = &n.init {
                let resolved = stringify_new_error_arg(init, &self.bindings);
                self.bindings.insert(binding_ident.to_id(), resolved);
            }
        }
        n.visit_children_with(self);
    }
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct NewError {
    error_message: String,
}

fn is_error_class_name(name: &str) -> bool {
    // Error classes are collected by https://gist.github.com/eps1lon/6cce3059dfa061f2a7dc28305fdaddae#file-collect-error-constructors-mjs
    name == "AggregateError"
        // built-in error classes
        || name == "Error"
        || name == "EvalError"
        || name == "RangeError"
        || name == "ReferenceError"
        || name == "SyntaxError"
        || name == "TypeError"
        || name == "URIError"
        // custom error classes
        || name == "ApiError"
        || name == "BailoutToCSRError"
        || name == "BubbledError"
        || name == "CanaryOnlyError"
        || name == "Cancel"
        || name == "CompileError"
        || name == "CssSyntaxError"
        || name == "DecodeError"
        || name == "DynamicServerError"
        || name == "ExportError"
        || name == "ImageError"
        || name == "InstantValidationError"
        || name == "InvariantError"
        || name == "ModuleBuildError"
        || name == "NestedMiddlewareError"
        || name == "NoFallbackError"
        || name == "NoSuchDeclarationError"
        || name == "PageSignatureError"
        || name == "PostCSSSyntaxError"
        || name == "ReadonlyHeadersError"
        || name == "ReadonlyRequestCookiesError"
        || name == "ReadonlyURLSearchParamsError"
        || name == "ResponseAborted"
        || name == "SerializableError"
        || name == "StaticGenBailoutError"
        || name == "TimeoutError"
        || name == "UnrecognizedActionError"
        || name == "Warning"
}

// Get the string representation of the message argument of `new Error(...)`
fn stringify_new_error_arg(expr: &Expr, bindings: &FxHashMap<Id, String>) -> String {
    match expr {
        Expr::Lit(lit) => match lit {
            Lit::Str(str_lit) => str_lit.value.to_string(),
            _ => "%s".to_string(),
        },

        Expr::Tpl(tpl) => {
            let mut result = String::new();
            let mut expr_iter = tpl.exprs.iter();

            for (_i, quasi) in tpl.quasis.iter().enumerate() {
                result.push_str(&quasi.raw);
                if let Some(expr) = expr_iter.next() {
                    result.push_str(&stringify_new_error_arg(expr, bindings));
                }
            }
            result
        }

        Expr::Bin(bin_expr) => {
            format!(
                "{}{}",
                stringify_new_error_arg(&bin_expr.left, bindings),
                stringify_new_error_arg(&bin_expr.right, bindings)
            )
        }

        Expr::Ident(ident) => bindings
            .get(&ident.to_id())
            .cloned()
            .unwrap_or_else(|| "%s".to_string()),

        _ => "%s".to_string(),
    }
}

impl TransformVisitor {
    // Look up `error_message` in `errors.json`. On miss, spill to
    // `cwd/.errors/<hash>.json` so the check-error-codes consolidation step can
    // pick it up.
    fn lookup_or_emit(&self, error_message: String) -> Option<String> {
        // Normalize line endings by converting Windows CRLF (\r\n) to Unix LF (\n)
        // This ensures the comparison works consistently across different operating systems.
        // We assume `errors.json` uses Unix LF (\n) as line endings.
        let error_message = error_message.replace("\r\n", "\n");

        if let Some(code) = self
            .errors
            .iter()
            .find_map(|(key, value)| (*value == error_message).then_some(key))
        {
            return Some(format!("E{}", code));
        }

        let new_error = serde_json::to_string(&NewError { error_message }).unwrap();
        let hash_hex = format!("{:x}", md5::compute(new_error.as_bytes()));
        let file_path = format!("cwd/.errors/{}.json", &hash_hex[0..8]);

        let _ = fs::create_dir_all("cwd/.errors");
        let _ = fs::write(&file_path, new_error);

        None
    }

    // Build `Object.defineProperty(<target>, "__NEXT_ERROR_CODE", { value:
    // "<code>", enumerable: false, configurable: true })`.
    fn build_define_property_call(
        &self,
        span: Span,
        ctxt: SyntaxContext,
        code: String,
        target: Box<Expr>,
    ) -> CallExpr {
        CallExpr {
            span,
            callee: Callee::Expr(Box::new(Expr::Member(MemberExpr {
                span,
                obj: Box::new(Expr::Ident(Ident::new(
                    "Object".into(),
                    span,
                    Default::default(),
                ))),
                prop: MemberProp::Ident("defineProperty".into()),
            }))),
            args: vec![
                ExprOrSpread {
                    spread: None,
                    expr: target,
                },
                ExprOrSpread {
                    spread: None,
                    expr: Box::new(Expr::Lit(Lit::Str(Str {
                        span,
                        value: "__NEXT_ERROR_CODE".into(),
                        raw: None,
                    }))),
                },
                ExprOrSpread {
                    spread: None,
                    expr: Box::new(Expr::Object(ObjectLit {
                        span,
                        props: vec![
                            PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
                                key: PropName::Ident("value".into()),
                                value: Box::new(Expr::Lit(Lit::Str(Str {
                                    span,
                                    value: code.into(),
                                    raw: None,
                                }))),
                            }))),
                            PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
                                key: PropName::Ident("enumerable".into()),
                                value: Box::new(Expr::Lit(Lit::Bool(Bool { span, value: false }))),
                            }))),
                            PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
                                key: PropName::Ident("configurable".into()),
                                value: Box::new(Expr::Lit(Lit::Bool(Bool { span, value: true }))),
                            }))),
                        ],
                    })),
                },
            ],
            type_args: None,
            ctxt,
        }
    }
}

impl VisitMut for TransformVisitor {
    fn visit_mut_program(&mut self, program: &mut Program) {
        let mut collector = BindingCollector {
            bindings: FxHashMap::default(),
        };
        program.visit_with(&mut collector);
        self.resolved_bindings = collector.bindings;
        program.visit_mut_children_with(self);
    }

    fn visit_mut_expr(&mut self, expr: &mut Expr) {
        let mut error_message: Option<String> = None;

        // The first arg to `Object.defineProperty(new Error(...), "__NEXT_ERROR_CODE", { value:
        // "$code", enumerable: false })`
        let mut new_error_expr: Option<NewExpr> = None;

        // Find expressions like `new Error(...)` or `Error(...)`
        // And populate `error_message` and `new_error_expr` when found
        match expr {
            Expr::New(new_expr) => match &*new_expr.callee {
                Expr::Ident(ident) if is_error_class_name(ident.sym.as_str()) => {
                    if let Some(args) = &new_expr.args {
                        // AggregateError(errors, message) has the message as the second arg
                        let message_arg_index = if ident.sym.as_str() == "AggregateError" {
                            1
                        } else {
                            0
                        };
                        if let Some(message_arg) = args.get(message_arg_index) {
                            new_error_expr = Some(new_expr.clone());
                            error_message = Some(stringify_new_error_arg(
                                &message_arg.expr,
                                &self.resolved_bindings,
                            ));
                        }
                    }
                }
                _ => {}
            },
            Expr::Call(call_expr) => match &call_expr.callee {
                Callee::Expr(expr) => match &**expr {
                    Expr::Ident(ident) if is_error_class_name(ident.sym.as_str()) => {
                        // AggregateError(errors, message) has the message as the second arg
                        let message_arg_index = if ident.sym.as_str() == "AggregateError" {
                            1
                        } else {
                            0
                        };
                        if let Some(message_arg) = call_expr.args.get(message_arg_index) {
                            error_message = Some(stringify_new_error_arg(
                                &message_arg.expr,
                                &self.resolved_bindings,
                            ));

                            // For `Error(...)`, we convert it to `new Error(...)` to make the
                            // following code simpler
                            new_error_expr = Some(NewExpr {
                                span: call_expr.span,
                                callee: Box::new(Expr::Ident(ident.clone())),
                                args: Some(call_expr.args.clone()),
                                type_args: None,
                                ctxt: call_expr.ctxt,
                            });
                        }
                    }
                    _ => {}
                },
                _ => {}
            },
            _ => {}
        }

        if new_error_expr.is_none() || error_message.is_none() {
            assert!(
                new_error_expr.is_none() && error_message.is_none(),
                "Expected both new_error_expr and error_message to be None, but new_error_expr is \
                 {:?} and error_message is {:?}",
                new_error_expr,
                error_message
            );
            expr.visit_mut_children_with(self);
            return;
        }

        let new_error_expr: NewExpr = new_error_expr.unwrap();
        let error_message = error_message.unwrap();

        if let Some(code) = self.lookup_or_emit(error_message) {
            let span = new_error_expr.span;
            let ctxt = new_error_expr.ctxt;
            let call = self.build_define_property_call(
                span,
                ctxt,
                code,
                Box::new(Expr::New(new_error_expr)),
            );
            *expr = Expr::Call(call);
        }
    }

    fn visit_mut_class(&mut self, class: &mut Class) {
        // Visit children first so any `new Error(...)` inside methods is still
        // rewritten by `visit_mut_expr`.
        class.visit_mut_children_with(self);

        // Only classes that extend a recognized Error class.
        let super_class_name = match class.super_class.as_deref() {
            Some(Expr::Ident(ident)) if is_error_class_name(ident.sym.as_str()) => {
                ident.sym.as_str()
            }
            _ => return,
        };

        // `AggregateError(errors, message)` takes the message as the second
        // argument. All other recognized error classes take it as the first.
        let message_arg_index = if super_class_name == "AggregateError" {
            1
        } else {
            0
        };

        // Skip the injection if the class already declares `__NEXT_ERROR_CODE`
        // itself. This respects manual overrides in classes whose code can't
        // be derived statically from the `super(...)` message.
        let declares_error_code = class.body.iter().any(|member| match member {
            ClassMember::ClassProp(prop) => matches!(
                &prop.key,
                PropName::Ident(ident) if ident.sym.as_str() == "__NEXT_ERROR_CODE"
            ),
            _ => false,
        });
        if declares_error_code {
            return;
        }

        // Find the first constructor with a body.
        let ctor = class.body.iter_mut().find_map(|member| match member {
            ClassMember::Constructor(Constructor { body: Some(_), .. }) => {
                if let ClassMember::Constructor(ctor) = member {
                    Some(ctor)
                } else {
                    None
                }
            }
            _ => None,
        });
        let Some(ctor) = ctor else {
            return;
        };
        let Some(body) = ctor.body.as_mut() else {
            return;
        };

        // Locate the first top-level `super(arg)` statement.
        let mut super_index: Option<usize> = None;
        let mut super_info: Option<(Span, SyntaxContext, String)> = None;
        for (i, stmt) in body.stmts.iter().enumerate() {
            if let Stmt::Expr(ExprStmt { expr, .. }) = stmt
                && let Expr::Call(CallExpr {
                    callee: Callee::Super(_),
                    args,
                    span,
                    ctxt,
                    ..
                }) = &**expr
                && let Some(message_arg) = args.get(message_arg_index)
                && message_arg.spread.is_none()
            {
                let message = stringify_new_error_arg(&message_arg.expr, &self.resolved_bindings);
                super_index = Some(i);
                super_info = Some((*span, *ctxt, message));
                break;
            }
        }

        let Some(stmt_index) = super_index else {
            return;
        };
        let (span, ctxt, message) = super_info.unwrap();

        let Some(code) = self.lookup_or_emit(message) else {
            return;
        };

        // Insert `Object.defineProperty(this, "__NEXT_ERROR_CODE", { ... })`
        // immediately after the super call.
        let call = self.build_define_property_call(
            span,
            ctxt,
            code,
            Box::new(Expr::This(ThisExpr { span })),
        );
        let new_stmt = Stmt::Expr(ExprStmt {
            span,
            expr: Box::new(Expr::Call(call)),
        });
        body.stmts.insert(stmt_index + 1, new_stmt);
    }
}

#[plugin_transform]
pub fn process_transform(
    mut program: Program,
    _metadata: TransformPluginProgramMetadata,
) -> Program {
    let errors_json = fs::read_to_string("/cwd/errors.json")
        .unwrap_or_else(|e| panic!("failed to read errors.json: {}", e));
    let errors: FxHashMap<String, String> = serde_json::from_str(&errors_json)
        .unwrap_or_else(|e| panic!("failed to parse errors.json: {}", e));

    let mut visitor = TransformVisitor {
        errors,
        resolved_bindings: FxHashMap::default(),
    };

    visitor.visit_mut_program(&mut program);
    program
}

test_inline!(
    Default::default(),
    |_| visit_mut_pass(TransformVisitor {
        errors: FxHashMap::from_iter([
            ("1".to_string(), "Failed to fetch user %s: %s".to_string()),
            ("2".to_string(), "Request failed: %s".to_string()),
            ("3".to_string(), "Generic error".to_string()),
            ("4".to_string(), "Empty error".to_string()),
            (
                "5".to_string(),
                "Pattern should define hostname but found\n%s".to_string()
            ),
            (
                "6".to_string(),
                "This is an extracted error message.".to_string()
            ),
        ]),
        resolved_bindings: FxHashMap::default(),
    }),
    realistic_api_handler,
    // Input codes
    r#"
async function fetchUserData(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
            throw new Error(`Failed to fetch user ${userId}: ${response.statusText}`);
        }
        return await response.json();
    } catch (err) {
        throw new Error(`Request failed: ${err.message}`);
    }
}

function test1() {
    throw Error("Generic error");
}

function test2() {
    throw Error();
}

function test3() {
    throw new Error("Generic error");
}

function test4() {
    throw new Error();
    throw new Error("Pattern should define hostname but found\n" + JSON.stringify(pattern));
}

const extractedErrorMessage = 'This is an extracted error message.';

function test5() {
    throw new Error(extractedErrorMessage);
}"#,
    // Output codes after transformed with plugin
    r#"
async function fetchUserData(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
            throw Object.defineProperty(new Error(`Failed to fetch user ${userId}: ${response.statusText}`), "__NEXT_ERROR_CODE", {
                value: "E1",
                enumerable: false,
                configurable: true
            });
        }
        return await response.json();
    } catch (err) {
        throw Object.defineProperty(new Error(`Request failed: ${err.message}`), "__NEXT_ERROR_CODE", {
            value: "E2",
            enumerable: false,
            configurable: true
        });
    }
}
function test1() {
    throw Object.defineProperty(new Error("Generic error"), "__NEXT_ERROR_CODE", {
        value: "E3",
        enumerable: false,
        configurable: true
    });
}
function test2() {
    throw Error();
}
function test3() {
    throw Object.defineProperty(new Error("Generic error"), "__NEXT_ERROR_CODE", {
        value: "E3",
        enumerable: false,
        configurable: true
    });
}
function test4() {
    throw new Error();
    throw Object.defineProperty(new Error("Pattern should define hostname but found\n" + JSON.stringify(pattern)), "__NEXT_ERROR_CODE", {
        value: "E5",
        enumerable: false,
        configurable: true
    });
}
const extractedErrorMessage = 'This is an extracted error message.';
function test5() {
    throw Object.defineProperty(new Error(extractedErrorMessage), "__NEXT_ERROR_CODE", {
        value: "E6",
        enumerable: false,
        configurable: true
    });
}
"#
);

test_inline!(
    Default::default(),
    |_| visit_mut_pass(TransformVisitor {
        errors: FxHashMap::from_iter([
            ("7".to_string(), "Timeout reached".to_string()),
            ("8".to_string(), "Prefix: %s".to_string()),
        ]),
        resolved_bindings: FxHashMap::default(),
    }),
    subclass_super_messages,
    // Input codes
    r#"
class LiteralSuper extends Error {
    constructor() {
        super("Timeout reached");
    }
}

class TemplateSuper extends Error {
    constructor(x) {
        super(`Prefix: ${x}`);
    }
}

class ExtendsKnownSubclass extends ApiError {
    constructor() {
        super("Timeout reached");
        this.extra = 1;
    }
}

class NoCtor extends Error {}

class SpreadSuper extends Error {
    constructor(...args) {
        super(...args);
    }
}

class ExtendsUnknown extends Foo {
    constructor() {
        super("Timeout reached");
    }
}

class SuperInIf extends Error {
    constructor(cond) {
        if (cond) {
            super("Timeout reached");
        } else {
            super("Timeout reached");
        }
    }
}

class UnknownMessage extends Error {
    constructor() {
        super("Not in errors.json");
    }
}

class AggregateSubclass extends AggregateError {
    constructor(errors) {
        super(errors, "Timeout reached");
    }
}

class ManualErrorCode extends Error {
    __NEXT_ERROR_CODE = 'Manual';
    constructor(message) {
        super(message);
    }
}
"#,
    // Output codes after transformed with plugin
    r#"
class LiteralSuper extends Error {
    constructor(){
        super("Timeout reached");
        Object.defineProperty(this, "__NEXT_ERROR_CODE", {
            value: "E7",
            enumerable: false,
            configurable: true
        });
    }
}
class TemplateSuper extends Error {
    constructor(x){
        super(`Prefix: ${x}`);
        Object.defineProperty(this, "__NEXT_ERROR_CODE", {
            value: "E8",
            enumerable: false,
            configurable: true
        });
    }
}
class ExtendsKnownSubclass extends ApiError {
    constructor(){
        super("Timeout reached");
        Object.defineProperty(this, "__NEXT_ERROR_CODE", {
            value: "E7",
            enumerable: false,
            configurable: true
        });
        this.extra = 1;
    }
}
class NoCtor extends Error {
}
class SpreadSuper extends Error {
    constructor(...args){
        super(...args);
    }
}
class ExtendsUnknown extends Foo {
    constructor(){
        super("Timeout reached");
    }
}
class SuperInIf extends Error {
    constructor(cond){
        if (cond) {
            super("Timeout reached");
        } else {
            super("Timeout reached");
        }
    }
}
class UnknownMessage extends Error {
    constructor(){
        super("Not in errors.json");
    }
}
class AggregateSubclass extends AggregateError {
    constructor(errors){
        super(errors, "Timeout reached");
        Object.defineProperty(this, "__NEXT_ERROR_CODE", {
            value: "E7",
            enumerable: false,
            configurable: true
        });
    }
}
class ManualErrorCode extends Error {
    __NEXT_ERROR_CODE = 'Manual';
    constructor(message){
        super(message);
    }
}
"#
);
Quest for Codev2.0.0
/
SIGN IN