diff --git a/.typos.toml b/.typos.toml index fa84e567d59b..d5ac104299a2 100644 --- a/.typos.toml +++ b/.typos.toml @@ -16,6 +16,7 @@ extend-exclude = [ "biome.jsonc", "scripts/test/diff.cjs", "scripts/test/binary-path.cjs", + "crates/next-custom-transforms" ] [default.extend-identifiers] diff --git a/Cargo.lock b/Cargo.lock index 3a21a7ce290f..a12a187332f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -708,8 +708,10 @@ checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -1513,6 +1515,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +[[package]] +name = "easy-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cc9717c61d2908f50d16ebb5677c7e82ea2bdf7cb52f66b30fe079f3212e16" + [[package]] name = "either" version = "1.13.0" @@ -1978,6 +1986,20 @@ dependencies = [ "serde", ] +[[package]] +name = "handlebars" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b" +dependencies = [ + "log", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "handlebars" version = "6.3.0" @@ -2627,6 +2649,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lazy-regex" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60c7310b93682b36b98fa7ea4de998d3463ccbebd94d935d6b48ba5b6ffa7126" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba01db5ef81e17eb10a5e0f2109d1b3a3e29bac3070fdbd7d156bf7dbd206a1" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.95", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2639,6 +2684,49 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" +[[package]] +name = "lexical" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7aefb36fd43fef7003334742cbf77b243fcd36418a1d1bdd480d613a67968f6" +dependencies = [ + "lexical-core", +] + +[[package]] +name = "lexical-core" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cde5de06e8d4c2faabc400238f9ae1c74d5412d03a7bd067645ccbc47070e46" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683b3a5ebd0130b8fb52ba0bdc718cc56815b6a097e28ae5a6997d0ad17dc05f" +dependencies = [ + "lexical-parse-integer", + "lexical-util", + "static_assertions", +] + +[[package]] +name = "lexical-parse-integer" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0994485ed0c312f6d965766754ea177d07f9c00c9b82a5ee62ed5b47945ee9" +dependencies = [ + "lexical-util", + "static_assertions", +] + [[package]] name = "lexical-sort" version = "0.3.1" @@ -2648,6 +2736,36 @@ dependencies = [ "any_ascii", ] +[[package]] +name = "lexical-util" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5255b9ff16ff898710eb9eb63cb39248ea8a5bb036bea8085b1a767ff6c4e3fc" +dependencies = [ + "static_assertions", +] + +[[package]] +name = "lexical-write-float" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accabaa1c4581f05a3923d1b4cfd124c329352288b7b9da09e766b0668116862" +dependencies = [ + "lexical-util", + "lexical-write-integer", + "static_assertions", +] + +[[package]] +name = "lexical-write-integer" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b6f3d1f4422866b68192d62f77bc5c700bee84f3069f2469d7bc8c77852446" +dependencies = [ + "lexical-util", + "static_assertions", +] + [[package]] name = "libc" version = "0.2.169" @@ -2994,6 +3112,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "modularize_imports" +version = "0.77.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd346f0b4eb3cd672dea37962fa965c2aceea8ed6944c3bd12627ecd82bc6f61" +dependencies = [ + "convert_case", + "handlebars 5.1.2", + "once_cell", + "regex", + "serde", + "swc_atoms", + "swc_cached", + "swc_common", + "swc_ecma_ast", + "swc_ecma_visit", +] + [[package]] name = "more-asserts" version = "0.2.2" @@ -3083,6 +3219,35 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "next-custom-transforms" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "easy-error", + "either", + "hex", + "indoc", + "modularize_imports", + "once_cell", + "pathdiff", + "preset_env_base", + "react_remove_properties", + "regex", + "remove_console", + "rustc-hash 2.1.0", + "serde", + "serde_json", + "sha1", + "styled_components", + "styled_jsx", + "swc_core", + "swc_emotion", + "swc_relay", + "tracing", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -3817,6 +3982,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "querystring" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9318ead08c799aad12a55a3e78b82e0b6167271ffd1f627b758891282f739187" + [[package]] name = "quote" version = "1.0.38" @@ -3907,6 +4078,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "react_remove_properties" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02fffcdd54b3ae860ffd9ef220cf1efc431f0fbbd618b5c3f95017fcae408aa" +dependencies = [ + "serde", + "swc_atoms", + "swc_cached", + "swc_common", + "swc_ecma_ast", + "swc_ecma_visit", +] + [[package]] name = "redox_syscall" version = "0.5.8" @@ -4024,6 +4209,20 @@ dependencies = [ "toml", ] +[[package]] +name = "remove_console" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86a6305be6ecf5c8e8aed43a3d30bd9ffcba4c0f388cdfc86a3632937013714" +dependencies = [ + "serde", + "swc_atoms", + "swc_cached", + "swc_common", + "swc_ecma_ast", + "swc_ecma_visit", +] + [[package]] name = "rend" version = "0.4.2" @@ -4456,6 +4655,69 @@ dependencies = [ "tokio", ] +[[package]] +name = "rspack_loader_next_app" +version = "0.2.0" +dependencies = [ + "async-recursion", + "async-trait", + "base64-simd 0.8.0", + "futures", + "json", + "lazy-regex", + "regex", + "rspack_ast", + "rspack_cacheable", + "rspack_core", + "rspack_error", + "rspack_loader_runner", + "rspack_paths", + "rspack_plugin_javascript", + "rspack_swc_plugin_import", + "rspack_util", + "serde", + "serde-querystring", + "serde_json", + "sugar_path", + "tokio", +] + +[[package]] +name = "rspack_loader_next_swc" +version = "0.2.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "cargo_toml", + "either", + "indoc", + "jsonc-parser 0.26.2", + "modularize_imports", + "next-custom-transforms", + "once_cell", + "preset_env_base", + "regex", + "rspack_ast", + "rspack_cacheable", + "rspack_core", + "rspack_error", + "rspack_loader_runner", + "rspack_paths", + "rspack_plugin_javascript", + "rspack_swc_plugin_import", + "rspack_util", + "rustc-hash 2.1.0", + "serde", + "serde_json", + "stacker", + "sugar_path", + "swc", + "swc_config", + "swc_core", + "url", +] + [[package]] name = "rspack_loader_preact_refresh" version = "0.2.0" @@ -4487,6 +4749,7 @@ dependencies = [ "anymap3", "async-trait", "derive_more 1.0.0", + "json", "once_cell", "regex", "rspack_cacheable", @@ -4576,6 +4839,7 @@ dependencies = [ name = "rspack_napi" version = "0.2.0" dependencies = [ + "json", "napi", "oneshot", "rspack_error", @@ -4619,6 +4883,8 @@ dependencies = [ "rspack_hook", "rspack_ids", "rspack_loader_lightningcss", + "rspack_loader_next_app", + "rspack_loader_next_swc", "rspack_loader_preact_refresh", "rspack_loader_react_refresh", "rspack_loader_runner", @@ -4650,6 +4916,7 @@ dependencies = [ "rspack_plugin_limit_chunk_count", "rspack_plugin_merge_duplicate_chunks", "rspack_plugin_mf", + "rspack_plugin_next_flight_client_entry", "rspack_plugin_no_emit_on_errors", "rspack_plugin_progress", "rspack_plugin_real_content_hash", @@ -5117,6 +5384,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "rspack_plugin_next_flight_client_entry" +version = "0.2.0" +dependencies = [ + "async-trait", + "derive_more 1.0.0", + "form_urlencoded", + "futures", + "indexmap 2.7.1", + "lazy-regex", + "querystring", + "regex", + "rspack_collections", + "rspack_core", + "rspack_error", + "rspack_hook", + "rspack_paths", + "rspack_plugin_javascript", + "rspack_util", + "rustc-hash 2.1.0", + "serde", + "serde_json", + "sugar_path", + "tracing", +] + [[package]] name = "rspack_plugin_no_emit_on_errors" version = "0.2.0" @@ -5472,7 +5765,7 @@ name = "rspack_swc_plugin_import" version = "0.2.0" dependencies = [ "cow-utils", - "handlebars", + "handlebars 6.3.0", "heck 0.5.0", "rustc-hash 2.1.0", "serde", @@ -5730,6 +6023,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-querystring" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce88f8e75d0920545b35b78775e0927137337401f65b01326ce299734885fd21" +dependencies = [ + "lexical", + "serde", +] + [[package]] name = "serde-wasm-bindgen" version = "0.4.5" @@ -6120,6 +6423,56 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "styled_components" +version = "0.105.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9f16ff198516c69753be147093eb4f13cc1b151b54d89be1b223895a852724" +dependencies = [ + "Inflector", + "once_cell", + "regex", + "rustc-hash 2.1.0", + "serde", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_utils", + "swc_ecma_visit", + "tracing", +] + +[[package]] +name = "styled_jsx" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74eb6cae7158d17dd0d58d43a797014ebe14f4f1c3275cec6da40217faa960a4" +dependencies = [ + "anyhow", + "lightningcss", + "parcel_selectors", + "preset_env_base", + "rustc-hash 2.1.0", + "serde", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_css_codegen", + "swc_css_compat", + "swc_css_minifier", + "swc_css_parser", + "swc_css_prefixer", + "swc_css_visit", + "swc_ecma_ast", + "swc_ecma_minifier", + "swc_ecma_parser", + "swc_ecma_transforms_base", + "swc_ecma_utils", + "swc_ecma_visit", + "swc_plugin_macro", + "tracing", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6342,9 +6695,12 @@ dependencies = [ "swc", "swc_allocator", "swc_atoms", + "swc_cached", "swc_common", "swc_ecma_ast", "swc_ecma_codegen", + "swc_ecma_loader", + "swc_ecma_minifier", "swc_ecma_parser", "swc_ecma_preset_env", "swc_ecma_quote_macros", @@ -6361,6 +6717,139 @@ dependencies = [ "vergen", ] +[[package]] +name = "swc_css_ast" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0993d78718d6e9e1820bb674961d8644128254d30b83dcf28389005d531024" +dependencies = [ + "is-macro", + "string_enum", + "swc_atoms", + "swc_common", +] + +[[package]] +name = "swc_css_codegen" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb1259e6cbcaa400fc0aef00f8fb5533b0939081454f289143d3c236eac846d9" +dependencies = [ + "auto_impl", + "bitflags 2.6.0", + "rustc-hash 2.1.0", + "serde", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_css_codegen_macros", + "swc_css_utils", +] + +[[package]] +name = "swc_css_codegen_macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50abd25b3b79f18423cdf99b0d11dee24e64496be3b8abe18c10a2c40bd6c91f" +dependencies = [ + "proc-macro2", + "quote", + "swc_macros_common", + "syn 2.0.95", +] + +[[package]] +name = "swc_css_compat" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71f9c13bba7ff9b499a62e8c55174f0628be1ad0ea44723173ee86bfd86b1" +dependencies = [ + "bitflags 2.6.0", + "once_cell", + "serde", + "serde_json", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_css_utils", + "swc_css_visit", +] + +[[package]] +name = "swc_css_minifier" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a70a915cf0fe27f0d979c349f0ab25fce782a341466b03a68224d77206dc78" +dependencies = [ + "rustc-hash 2.1.0", + "serde", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_css_utils", + "swc_css_visit", +] + +[[package]] +name = "swc_css_parser" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e37088852126afc7381765b4c04851202a24921a294dbfac4019d8791b13a33" +dependencies = [ + "lexical", + "serde", + "swc_atoms", + "swc_common", + "swc_css_ast", +] + +[[package]] +name = "swc_css_prefixer" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe1f8c265eeb9732363f7451443bfe422c3204a6bd1b47ea918fec99ad866f18" +dependencies = [ + "once_cell", + "preset_env_base", + "rustc-hash 2.1.0", + "serde", + "serde_json", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_css_utils", + "swc_css_visit", +] + +[[package]] +name = "swc_css_utils" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bf241d1531bf55ca1a7cb5cdb96232e7b119c3908592c742b985b0abe270cc3" +dependencies = [ + "once_cell", + "rustc-hash 2.1.0", + "serde", + "serde_json", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_css_visit", +] + +[[package]] +name = "swc_css_visit" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8645f20a9202e53c40c5410b71a9cdb44e1eb814dd931e3fda451def8a727eea" +dependencies = [ + "serde", + "swc_atoms", + "swc_common", + "swc_css_ast", + "swc_visit", +] + [[package]] name = "swc_ecma_ast" version = "7.0.0" @@ -7054,6 +7543,31 @@ dependencies = [ "tracing", ] +[[package]] +name = "swc_emotion" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcaeb62e49080aec49bc7bd763fa66179028fc8064a48917f32d4de9fc8adcc" +dependencies = [ + "base64 0.22.1", + "byteorder", + "once_cell", + "radix_fmt", + "regex", + "rustc-hash 2.1.0", + "serde", + "sourcemap", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_codegen", + "swc_ecma_transforms", + "swc_ecma_utils", + "swc_ecma_visit", + "swc_trace_macro", + "tracing", +] + [[package]] name = "swc_eq_ignore_macros" version = "1.0.0" @@ -7241,6 +7755,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "swc_plugin_macro" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0917ccfdcd3fa6cf41bdacef2388702a3b274f9ea708d930e1e8db37c7c3e1c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.95", +] + [[package]] name = "swc_plugin_proxy" version = "7.0.0" @@ -7285,6 +7810,24 @@ dependencies = [ "wasmer-wasix", ] +[[package]] +name = "swc_relay" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560c0d4fa79849bad1be806a7d92b99ae0859d7a84a1d1f65ce02a099d2a6c5c" +dependencies = [ + "once_cell", + "regex", + "serde", + "serde_json", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_ecma_utils", + "swc_ecma_visit", + "tracing", +] + [[package]] name = "swc_timer" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index da14f6dbcf3b..86212f86b2e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,8 +109,7 @@ pnp = { version = "0.9.0" } rspack_dojang = { version = "0.1.11" } - -# all rspack workspace dependencies +next-custom-transforms = { path = "crates/next-custom-transforms" } rspack = { version = "0.2.0", path = "crates/rspack" } rspack_allocator = { version = "0.2.0", path = "crates/rspack_allocator" } rspack_ast = { version = "0.2.0", path = "crates/rspack_ast" } @@ -132,6 +131,8 @@ rspack_identifier = { version = "0.2.0", path = "crates/rsp rspack_ids = { version = "0.2.0", path = "crates/rspack_ids" } rspack_loader = { version = "0.2.0", path = "crates/rspack_loader" } rspack_loader_lightningcss = { version = "0.2.0", path = "crates/rspack_loader_lightningcss" } +rspack_loader_next_app = { version = "0.2.0", path = "crates/rspack_loader_next_app" } +rspack_loader_next_swc = { version = "0.2.0", path = "crates/rspack_loader_next_swc" } rspack_loader_preact_refresh = { version = "0.2.0", path = "crates/rspack_loader_preact_refresh" } rspack_loader_react_refresh = { version = "0.2.0", path = "crates/rspack_loader_react_refresh" } rspack_loader_runner = { version = "0.2.0", path = "crates/rspack_loader_runner" } @@ -168,6 +169,7 @@ rspack_plugin_merge = { version = "0.2.0", path = "crates/rsp rspack_plugin_merge_duplicate_chunks = { version = "0.2.0", path = "crates/rspack_plugin_merge_duplicate_chunks" } rspack_plugin_mf = { version = "0.2.0", path = "crates/rspack_plugin_mf" } rspack_plugin_mini_css_extract = { version = "0.2.0", path = "crates/rspack_plugin_mini_css_extract" } +rspack_plugin_next_flight_client_entry = { path = "crates/rspack_plugin_next_flight_client_entry" } rspack_plugin_no_emit_on_errors = { version = "0.2.0", path = "crates/rspack_plugin_no_emit_on_errors" } rspack_plugin_progress = { version = "0.2.0", path = "crates/rspack_plugin_progress" } rspack_plugin_real_content_hash = { version = "0.2.0", path = "crates/rspack_plugin_real_content_hash" } diff --git a/crates/next-custom-transforms/Cargo.toml b/crates/next-custom-transforms/Cargo.toml new file mode 100644 index 000000000000..9c3422476c6a --- /dev/null +++ b/crates/next-custom-transforms/Cargo.toml @@ -0,0 +1,60 @@ +[package] +edition = "2018" +name = "next-custom-transforms" +publish = false +version = "0.0.0" +## FIXME: bypass license check now, but it's not MIT now +license = "MIT" + +[features] +plugin = ["swc_core/plugin_transform_host_native"] + +[lints] +workspace = true + +[dependencies] + +rustc-hash = { workspace = true } + + +anyhow = { workspace = true } +chrono = "0.4" +easy-error = "1.0.0" +either = "1" +hex = "0.4.3" +indoc = { workspace = true } +modularize_imports = { version = "0.77.0" } +once_cell = { workspace = true } +pathdiff = { workspace = true } +regex = "1.5" +serde = { workspace = true } +serde_json = { workspace = true, features = ["preserve_order"] } +sha1 = "0.10.1" +styled_components = { version = "0.105.0" } +styled_jsx = { version = "0.81.0" } +swc_core = { workspace = true, features = [ + "base", + "cached", + "common_concurrent", + "ecma_ast", + "ecma_loader_lru", + "ecma_loader_node", + "ecma_minifier", + "ecma_parser", + "ecma_parser_typescript", + "ecma_preset_env", + "ecma_quote", + "ecma_transforms", + "ecma_transforms_optimization", + "ecma_transforms_react", + "ecma_transforms_typescript", + "ecma_utils", + "ecma_visit", +] } +swc_emotion = { version = "0.81.0" } +swc_relay = { version = "0.51.0" } +tracing = { version = "0.1.37" } + +preset_env_base = "2.0.1" +react_remove_properties = "0.31.0" +remove_console = "0.32.0" diff --git a/crates/next-custom-transforms/src/chain_transforms.rs b/crates/next-custom-transforms/src/chain_transforms.rs new file mode 100644 index 000000000000..b7ba03f5e108 --- /dev/null +++ b/crates/next-custom-transforms/src/chain_transforms.rs @@ -0,0 +1,437 @@ +use std::{cell::RefCell, path::PathBuf, rc::Rc, sync::Arc}; + +use either::Either; +use modularize_imports; +use preset_env_base::query::targets_to_versions; +use rustc_hash::FxHashSet; +use serde::Deserialize; +use swc_core::{ + atoms::Atom, + common::{ + comments::{Comments, NoopComments}, + pass::Optional, + FileName, Mark, SourceFile, SourceMap, SyntaxContext, + }, + ecma::{ + ast::{fn_pass, noop_pass, EsVersion, Pass}, + parser::parse_file_as_module, + visit::visit_mut_pass, + }, +}; + +use crate::{ + linter::linter, + transforms::{ + cjs_finder::contains_cjs, + dynamic::{next_dynamic, NextDynamicMode}, + fonts::next_font_loaders, + lint_codemod_comments::lint_codemod_comments, + react_server_components, + }, +}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransformOptions { + #[serde(flatten)] + pub swc: swc_core::base::config::Options, + + #[serde(default)] + pub disable_next_ssg: bool, + + #[serde(default)] + pub disable_page_config: bool, + + #[serde(default)] + pub pages_dir: Option, + + #[serde(default)] + pub app_dir: Option, + + #[serde(default)] + pub is_page_file: bool, + + #[serde(default)] + pub is_development: bool, + + #[serde(default)] + pub is_server_compiler: bool, + + #[serde(default)] + pub prefer_esm: bool, + + #[serde(default)] + pub server_components: Option, + + #[serde(default)] + pub styled_jsx: BoolOr, + + #[serde(default)] + pub styled_components: Option, + + #[serde(default)] + pub remove_console: Option, + + #[serde(default)] + pub react_remove_properties: Option, + + #[serde(default)] + #[cfg(not(target_arch = "wasm32"))] + pub relay: Option, + + #[allow(unused)] + #[serde(default)] + #[cfg(target_arch = "wasm32")] + /// Accept any value + pub relay: Option, + + #[serde(default)] + pub shake_exports: Option, + + #[serde(default)] + pub emotion: Option, + + #[serde(default)] + pub modularize_imports: Option, + + #[serde(default)] + pub auto_modularize_imports: Option, + + #[serde(default)] + pub optimize_barrel_exports: Option, + + #[serde(default)] + pub font_loaders: Option, + + #[serde(default)] + pub server_actions: Option, + + #[serde(default)] + pub cjs_require_optimizer: Option, + + #[serde(default)] + pub optimize_server_react: Option, + + #[serde(default)] + pub debug_function_name: bool, + + #[serde(default)] + pub lint_codemod_comments: bool, +} + +pub fn custom_before_pass<'a, C>( + cm: Arc, + file: Arc, + opts: &'a TransformOptions, + comments: C, + eliminated_packages: Rc>>, + unresolved_mark: Mark, +) -> impl Pass + 'a +where + C: Clone + Comments + 'a, +{ + let file_path_str = file.name.to_string(); + + #[cfg(target_arch = "wasm32")] + let relay_plugin = noop_pass(); + + #[cfg(not(target_arch = "wasm32"))] + let relay_plugin = { + if let Some(config) = &opts.relay { + Either::Left(swc_relay::relay( + Arc::new(config.clone()), + (*file.name).clone(), + std::env::current_dir().unwrap(), + opts.pages_dir.clone(), + None, + )) + } else { + Either::Right(noop_pass()) + } + }; + + let target_browsers = opts + .swc + .config + .env + .as_ref() + .map(|env| targets_to_versions(env.targets.clone()).expect("failed to parse env.targets")) + .unwrap_or_default(); + + let styled_jsx = { + let cm = cm.clone(); + let file = file.clone(); + + fn_pass(move |program| { + if let Some(config) = opts.styled_jsx.to_option() { + program.mutate(styled_jsx::visitor::styled_jsx( + cm.clone(), + &file.name, + &styled_jsx::visitor::Config { + use_lightningcss: config.use_lightningcss, + browsers: *target_browsers, + }, + &styled_jsx::visitor::NativeConfig { process_css: None }, + )) + } + }) + }; + + let styled_components = { + let file = file.clone(); + + fn_pass(move |program| { + if let Some(config) = &opts.styled_components { + program.mutate(styled_components::styled_components( + Some(&file_path_str), + file.src_hash, + config, + NoopComments, + )) + } + }) + }; + + let emotion = { + let cm = cm.clone(); + let file = file.clone(); + let comments = comments.clone(); + + fn_pass(move |program| { + if let Some(config) = opts.emotion.as_ref() { + if !config.enabled.unwrap_or(false) { + return; + } + if let FileName::Real(path) = &*file.name { + program.mutate(swc_emotion::emotion( + config, + path, + file.src_hash as u32, + cm.clone(), + comments.clone(), + )); + } + } + }) + }; + + let modularize_imports = fn_pass(move |program| { + if let Some(config) = opts.modularize_imports.as_ref() { + program.mutate(modularize_imports::modularize_imports(config)); + } + }); + + ( + ( + crate::transforms::disallow_re_export_all_in_page::disallow_re_export_all_in_page( + opts.is_page_file, + ), + match &opts.server_components { + Some(config) if config.truthy() => { + Either::Left(react_server_components::server_components( + file.name.clone(), + config.clone(), + comments.clone(), + opts.app_dir.clone(), + )) + } + _ => Either::Right(noop_pass()), + }, + styled_jsx, + styled_components, + Optional::new( + crate::transforms::next_ssg::next_ssg(eliminated_packages), + !opts.disable_next_ssg, + ), + crate::transforms::amp_attributes::amp_attributes(), + next_dynamic( + opts.is_development, + opts.is_server_compiler, + match &opts.server_components { + Some(config) if config.truthy() => match config { + // Always enable the Server Components mode for both + // server and client layers. + react_server_components::Config::WithOptions(config) => config.is_react_server_layer, + _ => false, + }, + _ => false, + }, + opts.prefer_esm, + NextDynamicMode::Webpack, + file.name.clone(), + opts.pages_dir.clone().or_else(|| opts.app_dir.clone()), + ), + Optional::new( + crate::transforms::page_config::page_config(opts.is_development, opts.is_page_file), + !opts.disable_page_config, + ), + relay_plugin, + match &opts.remove_console { + Some(config) if config.truthy() => Either::Left(remove_console::remove_console( + config.clone(), + SyntaxContext::empty().apply_mark(unresolved_mark), + )), + _ => Either::Right(noop_pass()), + }, + match &opts.react_remove_properties { + Some(config) if config.truthy() => Either::Left( + react_remove_properties::react_remove_properties(config.clone()), + ), + _ => Either::Right(noop_pass()), + }, + match &opts.shake_exports { + Some(config) => Either::Left(crate::transforms::shake_exports::shake_exports( + config.clone(), + )), + None => Either::Right(noop_pass()), + }, + ), + ( + match &opts.auto_modularize_imports { + Some(config) => Either::Left( + crate::transforms::named_import_transform::named_import_transform(config.clone()), + ), + None => Either::Right(noop_pass()), + }, + match &opts.optimize_barrel_exports { + Some(config) => Either::Left(crate::transforms::optimize_barrel::optimize_barrel( + config.clone(), + )), + _ => Either::Right(noop_pass()), + }, + match &opts.optimize_server_react { + Some(config) => Either::Left( + crate::transforms::optimize_server_react::optimize_server_react(config.clone()), + ), + _ => Either::Right(noop_pass()), + }, + emotion, + modularize_imports, + match &opts.font_loaders { + Some(config) => Either::Left(next_font_loaders(config.clone())), + None => Either::Right(noop_pass()), + }, + match &opts.server_actions { + Some(config) => Either::Left(crate::transforms::server_actions::server_actions( + &file.name, + config.clone(), + comments.clone(), + )), + None => Either::Right(noop_pass()), + }, + match &opts.cjs_require_optimizer { + Some(config) => Either::Left(visit_mut_pass( + crate::transforms::cjs_optimizer::cjs_optimizer( + config.clone(), + SyntaxContext::empty().apply_mark(unresolved_mark), + ), + )), + None => Either::Right(noop_pass()), + }, + Optional::new( + crate::transforms::debug_fn_name::debug_fn_name(), + opts.debug_function_name, + ), + visit_mut_pass(crate::transforms::pure::pure_magic(comments.clone())), + Optional::new( + linter(lint_codemod_comments(comments)), + opts.lint_codemod_comments, + ), + ), + ) +} + +impl TransformOptions { + pub fn patch(mut self, fm: &SourceFile) -> Self { + self.swc.swcrc = false; + + let should_enable_commonjs = self.swc.config.module.is_none() + && (fm.src.contains("module.exports") + || fm.src.contains("exports.") + || fm.src.contains("__esModule")) + && { + let syntax = self.swc.config.jsc.syntax.unwrap_or_default(); + let target = self.swc.config.jsc.target.unwrap_or_else(EsVersion::latest); + + parse_file_as_module(fm, syntax, target, None, &mut vec![]) + .map(|m| contains_cjs(&m)) + .unwrap_or_default() + }; + + if should_enable_commonjs { + self.swc.config.module = + Some(serde_json::from_str(r#"{ "type": "commonjs", "ignoreDynamic": true }"#).unwrap()); + } + + self + } +} + +/// Defaults to false + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum BoolOr { + Bool(bool), + Data(T), +} + +impl Default for BoolOr { + fn default() -> Self { + BoolOr::Bool(false) + } +} + +impl BoolOr { + pub fn to_option(&self) -> Option + where + T: Default + Clone, + { + match self { + BoolOr::Bool(false) => None, + BoolOr::Bool(true) => Some(Default::default()), + BoolOr::Data(v) => Some(v.clone()), + } + } +} + +impl<'de, T> Deserialize<'de> for BoolOr +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Deser { + Bool(bool), + Obj(T), + EmptyObject(EmptyStruct), + } + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct EmptyStruct {} + + use serde::__private::de; + + let content = de::Content::deserialize(deserializer)?; + + let deserializer = de::ContentRefDeserializer::::new(&content); + + let res = Deser::deserialize(deserializer); + + match res { + Ok(v) => Ok(match v { + Deser::Bool(v) => BoolOr::Bool(v), + Deser::Obj(v) => BoolOr::Data(v), + Deser::EmptyObject(_) => BoolOr::Bool(true), + }), + Err(..) => { + let d = de::ContentDeserializer::::new(content); + Ok(BoolOr::Data(T::deserialize(d)?)) + } + } + } +} diff --git a/crates/next-custom-transforms/src/lib.rs b/crates/next-custom-transforms/src/lib.rs new file mode 100644 index 000000000000..fa2c99a578a4 --- /dev/null +++ b/crates/next-custom-transforms/src/lib.rs @@ -0,0 +1,37 @@ +/* +Copyright (c) 2017 The swc Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +*/ + +#![recursion_limit = "2048"] +#![deny(clippy::all)] +#![feature(box_patterns)] +#![feature(arbitrary_self_types)] +#![feature(arbitrary_self_types_pointers)] + +pub mod chain_transforms; +mod linter; +pub mod transforms; diff --git a/crates/next-custom-transforms/src/linter.rs b/crates/next-custom-transforms/src/linter.rs new file mode 100644 index 000000000000..b19ac9c781f8 --- /dev/null +++ b/crates/next-custom-transforms/src/linter.rs @@ -0,0 +1,11 @@ +use swc_core::ecma::{ + ast::Pass, + visit::{visit_pass, Visit}, +}; + +pub fn linter(visitor: V) -> impl Pass +where + V: Visit, +{ + visit_pass(visitor) +} diff --git a/crates/next-custom-transforms/src/transforms/amp_attributes.rs b/crates/next-custom-transforms/src/transforms/amp_attributes.rs new file mode 100644 index 000000000000..01a0476cb112 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/amp_attributes.rs @@ -0,0 +1,60 @@ +use swc_core::ecma::{ + ast::{ + Ident, IdentName, JSXAttr, JSXAttrName, JSXAttrOrSpread, JSXElementName, JSXOpeningElement, + Pass, + }, + atoms::JsWord, + visit::{fold_pass, Fold}, +}; + +pub fn amp_attributes() -> impl Pass { + fold_pass(AmpAttributePatcher::default()) +} + +#[derive(Debug, Default)] +struct AmpAttributePatcher {} + +impl Fold for AmpAttributePatcher { + fn fold_jsx_opening_element(&mut self, node: JSXOpeningElement) -> JSXOpeningElement { + let JSXOpeningElement { + name, + mut attrs, + span, + self_closing, + type_args, + } = node; + let n = name.clone(); + + if let JSXElementName::Ident(Ident { sym, .. }) = name { + if sym.starts_with("amp-") { + for i in &mut attrs { + if let JSXAttrOrSpread::JSXAttr(JSXAttr { + name: JSXAttrName::Ident(IdentName { sym, span: s }), + span, + value, + }) = &i + { + if sym as &str == "className" { + *i = JSXAttrOrSpread::JSXAttr(JSXAttr { + name: JSXAttrName::Ident(IdentName { + sym: JsWord::from("class"), + span: *s, + }), + span: *span, + value: value.clone(), + }) + } + } + } + } + } + + JSXOpeningElement { + name: n, + attrs, + span, + self_closing, + type_args, + } + } +} diff --git a/crates/next-custom-transforms/src/transforms/cjs_finder.rs b/crates/next-custom-transforms/src/transforms/cjs_finder.rs new file mode 100644 index 000000000000..fd2950268099 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/cjs_finder.rs @@ -0,0 +1,150 @@ +use swc_core::ecma::{ + ast::*, + visit::{Visit, VisitWith}, +}; + +pub fn contains_cjs(m: &Module) -> bool { + let mut v = CjsFinder::default(); + m.visit_with(&mut v); + v.found && !v.is_esm +} + +#[derive(Copy, Clone, Default)] +struct CjsFinder { + found: bool, + is_esm: bool, + ignore_module: bool, + ignore_exports: bool, +} + +impl CjsFinder { + /// If the given pattern contains `module` as a parameter, we don't need to + /// recurse into it because `module` is shadowed. + fn adjust_state<'a, I>(&mut self, iter: I) + where + I: Iterator, + { + iter.for_each(|p| { + if let Pat::Ident(i) = p { + if &*i.id.sym == "module" { + self.ignore_module = true; + } + if &*i.id.sym == "exports" { + self.ignore_exports = true; + } + } + }) + } +} + +/// This visitor implementation supports typescript, because the api of `swc` +/// does not support changing configuration based on content of the file. +impl Visit for CjsFinder { + fn visit_arrow_expr(&mut self, n: &ArrowExpr) { + let old_ignore_module = self.ignore_module; + let old_ignore_exports = self.ignore_exports; + + self.adjust_state(n.params.iter()); + + n.visit_children_with(self); + + self.ignore_module = old_ignore_module; + self.ignore_exports = old_ignore_exports; + } + + // Detect `Object.defineProperty(exports, "__esModule", ...)` + // Note that `Object.defineProperty(module.exports, ...)` will be handled by + // `visit_member_expr`. + fn visit_call_expr(&mut self, e: &CallExpr) { + if !self.ignore_exports { + if let Callee::Expr(expr) = &e.callee { + if let Expr::Member(member_expr) = &**expr { + if let (Expr::Ident(obj), MemberProp::Ident(prop)) = + (&*member_expr.obj, &member_expr.prop) + { + if &*obj.sym == "Object" && &*prop.sym == "defineProperty" { + if let Some(ExprOrSpread { expr: expr0, .. }) = e.args.first() { + if let Expr::Ident(arg0) = &**expr0 { + if &*arg0.sym == "exports" { + if let Some(ExprOrSpread { expr: expr1, .. }) = e.args.get(1) { + if let Expr::Lit(Lit::Str(arg1)) = &**expr1 { + if &*arg1.value == "__esModule" { + self.found = true; + return; + } + } + } + } + } + } + } + } + } + } + } + + e.callee.visit_with(self); + } + + fn visit_class_method(&mut self, n: &ClassMethod) { + let old_ignore_module = self.ignore_module; + let old_ignore_exports = self.ignore_exports; + + self.adjust_state(n.function.params.iter().map(|v| &v.pat)); + + n.visit_children_with(self); + + self.ignore_module = old_ignore_module; + self.ignore_exports = old_ignore_exports; + } + + fn visit_function(&mut self, n: &Function) { + let old_ignore_module = self.ignore_module; + let old_ignore_exports = self.ignore_exports; + + self.adjust_state(n.params.iter().map(|v| &v.pat)); + + n.visit_children_with(self); + + self.ignore_module = old_ignore_module; + self.ignore_exports = old_ignore_exports; + } + + fn visit_member_expr(&mut self, e: &MemberExpr) { + if let Expr::Ident(obj) = &*e.obj { + if let MemberProp::Ident(prop) = &e.prop { + // Detect `module.exports` and `exports.__esModule` + if (!self.ignore_module && &*obj.sym == "module" && &*prop.sym == "exports") + || (!self.ignore_exports && &*obj.sym == "exports") + { + self.found = true; + return; + } + } + } + + e.obj.visit_with(self); + e.prop.visit_with(self); + } + + fn visit_method_prop(&mut self, n: &MethodProp) { + let old_ignore_module = self.ignore_module; + let old_ignore_exports = self.ignore_exports; + + self.adjust_state(n.function.params.iter().map(|v| &v.pat)); + + n.visit_children_with(self); + + self.ignore_module = old_ignore_module; + self.ignore_exports = old_ignore_exports; + } + + fn visit_module_decl(&mut self, n: &ModuleDecl) { + match n { + ModuleDecl::Import(_) => {} + _ => { + self.is_esm = true; + } + } + } +} diff --git a/crates/next-custom-transforms/src/transforms/cjs_optimizer.rs b/crates/next-custom-transforms/src/transforms/cjs_optimizer.rs new file mode 100644 index 000000000000..90760ce1bdb5 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/cjs_optimizer.rs @@ -0,0 +1,295 @@ +use rustc_hash::{FxHashMap, FxHashSet}; +use serde::Deserialize; +use swc_core::{ + common::{util::take::Take, SyntaxContext, DUMMY_SP}, + ecma::{ + ast::{ + CallExpr, Callee, Decl, Expr, Id, Ident, IdentName, Lit, MemberExpr, MemberProp, Module, + ModuleItem, Pat, Script, Stmt, VarDecl, VarDeclKind, VarDeclarator, + }, + atoms::{Atom, JsWord}, + utils::{prepend_stmts, private_ident, ExprFactory, IdentRenamer}, + visit::{noop_visit_mut_type, noop_visit_type, Visit, VisitMut, VisitMutWith, VisitWith}, + }, +}; + +pub fn cjs_optimizer(config: Config, unresolved_ctxt: SyntaxContext) -> CjsOptimizer { + CjsOptimizer { + data: State::default(), + packages: config.packages, + unresolved_ctxt, + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Config { + pub packages: FxHashMap, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageConfig { + pub transforms: FxHashMap, +} + +pub struct CjsOptimizer { + data: State, + packages: FxHashMap, + unresolved_ctxt: SyntaxContext, +} + +#[derive(Debug, Default)] +struct State { + /// List of `require` calls **which should be replaced**. + /// + /// `(identifier): (module_record)` + imports: FxHashMap, + + /// `(module_specifier, property): (identifier)` + replaced: FxHashMap<(Atom, JsWord), Id>, + + extra_stmts: Vec, + + rename_map: FxHashMap, + + /// Ignored identifiers for `obj` of [MemberExpr]. + ignored: FxHashSet, + + is_prepass: bool, +} + +#[derive(Debug)] +struct ImportRecord { + module_specifier: Atom, +} + +impl CjsOptimizer { + fn should_rewrite(&self, module_specifier: &str) -> Option<&FxHashMap> { + self.packages.get(module_specifier).map(|v| &v.transforms) + } +} + +impl VisitMut for CjsOptimizer { + noop_visit_mut_type!(); + + fn visit_mut_module_items(&mut self, stmts: &mut Vec) { + self.data.is_prepass = true; + stmts.visit_mut_children_with(self); + self.data.is_prepass = false; + stmts.visit_mut_children_with(self); + } + + fn visit_mut_expr(&mut self, e: &mut Expr) { + e.visit_mut_children_with(self); + + if let Expr::Member(n) = e { + if let MemberProp::Ident(prop) = &n.prop { + if let Expr::Ident(obj) = &*n.obj { + let key = obj.to_id(); + if self.data.ignored.contains(&key) { + return; + } + + if let Some(record) = self.data.imports.get(&key) { + let mut replaced = false; + + let new_id = self + .data + .replaced + .entry((record.module_specifier.clone(), prop.sym.clone())) + .or_insert_with(|| private_ident!(prop.sym.clone()).to_id()) + .clone(); + + if let Some(map) = self.should_rewrite(&record.module_specifier) { + if let Some(renamed) = map.get(&prop.sym) { + replaced = true; + if !self.data.is_prepass { + // Transform as `require('foo').bar` + let var = VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(new_id.clone().into()), + init: Some(Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Ident::new("require".into(), DUMMY_SP, self.unresolved_ctxt) + .as_callee(), + args: vec![Expr::Lit(Lit::Str(renamed.clone().into())).as_arg()], + ..Default::default() + })), + prop: MemberProp::Ident(IdentName::new(prop.sym.clone(), DUMMY_SP)), + }))), + definite: false, + }; + + if !self.data.extra_stmts.iter().any(|s| { + if let Stmt::Decl(Decl::Var(v)) = &s { + v.decls.iter().any(|d| d.name == var.name) + } else { + false + } + }) { + self + .data + .extra_stmts + .push(Stmt::Decl(Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Const, + decls: vec![var], + ..Default::default() + })))); + } + + *e = Expr::Ident(new_id.into()); + } + } + } + + if !replaced { + self.data.ignored.insert(key); + } + } + } + } + } + } + + fn visit_mut_module(&mut self, n: &mut Module) { + n.visit_children_with(&mut Analyzer { + data: &mut self.data, + in_member_or_var: false, + }); + + n.visit_mut_children_with(self); + + prepend_stmts( + &mut n.body, + self.data.extra_stmts.drain(..).map(ModuleItem::Stmt), + ); + + n.visit_mut_children_with(&mut IdentRenamer::new(&self.data.rename_map)); + } + + fn visit_mut_script(&mut self, n: &mut Script) { + n.visit_children_with(&mut Analyzer { + data: &mut self.data, + in_member_or_var: false, + }); + + n.visit_mut_children_with(self); + + prepend_stmts(&mut n.body, self.data.extra_stmts.drain(..)); + + n.visit_mut_children_with(&mut IdentRenamer::new(&self.data.rename_map)); + } + + fn visit_mut_stmt(&mut self, n: &mut Stmt) { + n.visit_mut_children_with(self); + + if let Stmt::Decl(Decl::Var(v)) = n { + if v.decls.is_empty() { + n.take(); + } + } + } + + fn visit_mut_var_declarator(&mut self, n: &mut VarDeclarator) { + n.visit_mut_children_with(self); + + // Find `require('foo')` + if let Some(Expr::Call(CallExpr { + callee: Callee::Expr(callee), + args, + .. + })) = n.init.as_deref() + { + if let Expr::Ident(ident) = &**callee { + if ident.ctxt == self.unresolved_ctxt && ident.sym == *"require" { + if let Some(arg) = args.first() { + if let Expr::Lit(Lit::Str(v)) = &*arg.expr { + // TODO: Config + + if let Pat::Ident(name) = &n.name { + if self.should_rewrite(&v.value).is_some() { + let key = name.to_id(); + + if !self.data.is_prepass { + if !self.data.ignored.contains(&key) { + // Drop variable declarator. + n.name.take(); + } + } else { + self.data.imports.insert( + key, + ImportRecord { + module_specifier: v.value.clone(), + }, + ); + } + } + } + } + } + } + } + } + } + + fn visit_mut_var_declarators(&mut self, n: &mut Vec) { + n.visit_mut_children_with(self); + + // We make `name` invalid if we should drop it. + n.retain(|v| !v.name.is_invalid()); + } +} + +struct Analyzer<'a> { + in_member_or_var: bool, + data: &'a mut State, +} + +impl Visit for Analyzer<'_> { + noop_visit_type!(); + + fn visit_var_declarator(&mut self, n: &VarDeclarator) { + let mut safe_to_ignore = false; + + // Ignore the require itself (foo = require('foo')) + if let Some(Expr::Call(CallExpr { + callee: Callee::Expr(callee), + .. + })) = n.init.as_deref() + { + if let Expr::Ident(ident) = &**callee { + if ident.sym == *"require" { + safe_to_ignore = true; + } + } + } + + if safe_to_ignore { + self.in_member_or_var = true; + n.visit_children_with(self); + self.in_member_or_var = false; + } else { + n.visit_children_with(self); + } + } + + fn visit_member_expr(&mut self, e: &MemberExpr) { + self.in_member_or_var = true; + e.visit_children_with(self); + self.in_member_or_var = false; + + if let (Expr::Ident(obj), MemberProp::Computed(..)) = (&*e.obj, &e.prop) { + self.data.ignored.insert(obj.to_id()); + } + } + + fn visit_ident(&mut self, i: &Ident) { + i.visit_children_with(self); + if !self.in_member_or_var { + self.data.ignored.insert(i.to_id()); + } + } +} diff --git a/crates/next-custom-transforms/src/transforms/debug_fn_name.rs b/crates/next-custom-transforms/src/transforms/debug_fn_name.rs new file mode 100644 index 000000000000..ee68ab662293 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/debug_fn_name.rs @@ -0,0 +1,211 @@ +use std::fmt::Write; + +use swc_core::{ + atoms::Atom, + common::{util::take::Take, DUMMY_SP}, + ecma::{ + ast::{ + CallExpr, Callee, ExportDefaultDecl, ExportDefaultExpr, Expr, FnDecl, FnExpr, KeyValueProp, + MemberProp, ObjectLit, Pass, PropOrSpread, VarDeclarator, + }, + utils::ExprFactory, + visit::{visit_mut_pass, VisitMut, VisitMutWith}, + }, +}; + +pub fn debug_fn_name() -> impl VisitMut + Pass { + visit_mut_pass(DebugFnName::default()) +} + +#[derive(Default)] +struct DebugFnName { + path: String, + in_target: bool, + in_var_target: bool, + in_default_export: bool, +} + +impl VisitMut for DebugFnName { + fn visit_mut_call_expr(&mut self, n: &mut CallExpr) { + if self.in_var_target || (self.path.is_empty() && !self.in_default_export) { + n.visit_mut_children_with(self); + return; + } + + if let Some(target) = is_target_callee(&n.callee) { + let old_in_target = self.in_target; + self.in_target = true; + let orig_len = self.path.len(); + if !self.path.is_empty() { + self.path.push('.'); + } + self.path.push_str(&target); + + n.visit_mut_children_with(self); + + self.path.truncate(orig_len); + self.in_target = old_in_target; + } else { + n.visit_mut_children_with(self); + } + } + + fn visit_mut_export_default_expr(&mut self, n: &mut ExportDefaultExpr) { + let old_in_default_export = self.in_default_export; + self.in_default_export = true; + + n.visit_mut_children_with(self); + + self.in_default_export = old_in_default_export; + } + + fn visit_mut_export_default_decl(&mut self, n: &mut ExportDefaultDecl) { + let old_in_default_export = self.in_default_export; + self.in_default_export = true; + + n.visit_mut_children_with(self); + + self.in_default_export = old_in_default_export; + } + + fn visit_mut_expr(&mut self, n: &mut Expr) { + n.visit_mut_children_with(self); + + if self.in_target { + match n { + Expr::Arrow(..) | Expr::Fn(FnExpr { ident: None, .. }) => { + // useLayoutEffect(() => ...); + // + // becomes + // + // + // useLayoutEffect({'MyComponent.useLayoutEffect': () => + // ...}['MyComponent.useLayoutEffect']); + + let orig = n.take(); + let key = Atom::from(&*self.path); + + *n = Expr::Object(ObjectLit { + span: DUMMY_SP, + props: vec![PropOrSpread::Prop(Box::new( + swc_core::ecma::ast::Prop::KeyValue(KeyValueProp { + key: swc_core::ecma::ast::PropName::Str(key.clone().into()), + value: Box::new(orig), + }), + ))], + }) + .computed_member(key) + .into(); + } + + _ => {} + } + } + } + + fn visit_mut_fn_decl(&mut self, n: &mut FnDecl) { + let orig_len = self.path.len(); + if !self.path.is_empty() { + self.path.push('.'); + } + self.path.push_str(n.ident.sym.as_str()); + + n.visit_mut_children_with(self); + + self.path.truncate(orig_len); + } + + fn visit_mut_fn_expr(&mut self, n: &mut FnExpr) { + if let Some(ident) = &n.ident { + let orig_len = self.path.len(); + if !self.path.is_empty() { + self.path.push('.'); + } + self.path.push_str(ident.sym.as_str()); + + n.visit_mut_children_with(self); + + self.path.truncate(orig_len); + return; + } + + n.visit_mut_children_with(self); + } + + fn visit_mut_var_declarator(&mut self, n: &mut VarDeclarator) { + if let Some(Expr::Call(call)) = n.init.as_deref() { + let name = is_target_callee(&call.callee).and_then(|target| { + let name = n.name.as_ident()?; + + Some((name.sym.clone(), target)) + }); + + if let Some((name, target)) = name { + let old_in_var_target = self.in_var_target; + self.in_var_target = true; + + let old_in_target = self.in_target; + self.in_target = true; + let orig_len = self.path.len(); + if !self.path.is_empty() { + self.path.push('.'); + } + let _ = write!(self.path, "{target}[{name}]"); + + n.visit_mut_children_with(self); + + self.path.truncate(orig_len); + self.in_target = old_in_target; + self.in_var_target = old_in_var_target; + return; + } + } + + if let Some(Expr::Arrow(..) | Expr::Fn(FnExpr { ident: None, .. }) | Expr::Call(..)) = + n.init.as_deref() + { + let name = n.name.as_ident(); + + if let Some(name) = name { + let orig_len = self.path.len(); + if !self.path.is_empty() { + self.path.push('.'); + } + self.path.push_str(name.sym.as_str()); + + n.visit_mut_children_with(self); + + self.path.truncate(orig_len); + return; + } + } + + n.visit_mut_children_with(self); + } +} + +fn is_target_callee(e: &Callee) -> Option { + match e { + Callee::Expr(e) => match &**e { + Expr::Ident(i) => { + if i.sym.starts_with("use") { + Some(i.sym.clone()) + } else { + None + } + } + Expr::Member(me) => match &me.prop { + MemberProp::Ident(i) => { + if i.sym.starts_with("use") { + Some(i.sym.clone()) + } else { + None + } + } + _ => None, + }, + _ => None, + }, + _ => None, + } +} diff --git a/crates/next-custom-transforms/src/transforms/disallow_re_export_all_in_page.rs b/crates/next-custom-transforms/src/transforms/disallow_re_export_all_in_page.rs new file mode 100644 index 000000000000..37fb3bb4cc6c --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/disallow_re_export_all_in_page.rs @@ -0,0 +1,33 @@ +use swc_core::{ + common::{errors::HANDLER, pass::Optional}, + ecma::ast::{ModuleDecl, ModuleItem, Pass, Program}, +}; + +pub fn disallow_re_export_all_in_page(is_page_file: bool) -> impl Pass { + Optional::new(DisallowReExportAllInPage, is_page_file) +} + +struct DisallowReExportAllInPage; + +impl Pass for DisallowReExportAllInPage { + fn process(&mut self, program: &mut Program) { + let Program::Module(m) = program else { + return; + }; + + for item in m.body.iter_mut() { + let ModuleItem::ModuleDecl(ModuleDecl::ExportAll(e)) = item else { + continue; + }; + + HANDLER.with(|handler| { + handler + .struct_span_err( + e.span, + "Using `export * from '...'` in a page is disallowed. Please use `export { default } from '...'` instead.\nRead more: https://nextjs.org/docs/messages/export-all-in-page", + ) + .emit() + }); + } + } +} diff --git a/crates/next-custom-transforms/src/transforms/dynamic.rs b/crates/next-custom-transforms/src/transforms/dynamic.rs new file mode 100644 index 000000000000..0cfbe65395ab --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/dynamic.rs @@ -0,0 +1,584 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use pathdiff::diff_paths; +use swc_core::{ + atoms::Atom, + common::{errors::HANDLER, FileName, Span, DUMMY_SP}, + ecma::{ + ast::{ + op, ArrayLit, ArrowExpr, BinExpr, BlockStmt, BlockStmtOrExpr, Bool, CallExpr, Callee, Expr, + ExprOrSpread, ExprStmt, Id, Ident, IdentName, ImportDecl, ImportNamedSpecifier, + ImportSpecifier, KeyValueProp, Lit, ModuleDecl, ModuleItem, ObjectLit, Pass, Prop, PropName, + PropOrSpread, Stmt, Str, Tpl, UnaryExpr, UnaryOp, + }, + utils::{private_ident, quote_ident, ExprFactory}, + visit::{fold_pass, Fold, FoldWith, VisitMut, VisitMutWith}, + }, + quote, +}; + +/// Creates a SWC visitor to transform `next/dynamic` calls to have the +/// corresponding `loadableGenerated` property. +/// +/// **NOTE** We do not use `NextDynamicMode::Turbopack` yet. It isn't compatible +/// with current loadable manifest, which causes hydration errors. +pub fn next_dynamic( + is_development: bool, + is_server_compiler: bool, + is_react_server_layer: bool, + prefer_esm: bool, + mode: NextDynamicMode, + filename: Arc, + pages_or_app_dir: Option, +) -> impl Pass { + fold_pass(NextDynamicPatcher { + is_development, + is_server_compiler, + is_react_server_layer, + prefer_esm, + pages_or_app_dir, + filename, + dynamic_bindings: vec![], + is_next_dynamic_first_arg: false, + dynamically_imported_specifier: None, + state: match mode { + NextDynamicMode::Webpack => NextDynamicPatcherState::Webpack, + NextDynamicMode::Turbopack { + dynamic_client_transition_name, + dynamic_transition_name, + } => NextDynamicPatcherState::Turbopack { + dynamic_client_transition_name, + dynamic_transition_name, + imports: vec![], + }, + }, + }) +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum NextDynamicMode { + /// In Webpack mode, each `dynamic()` call will generate a key composed + /// from: + /// 1. The current module's path relative to the pages directory; + /// 2. The relative imported module id. + /// + /// This key is of the form: + /// {currentModulePath} -> {relativeImportedModulePath} + /// + /// It corresponds to an entry in the React Loadable Manifest generated by + /// the React Loadable Webpack plugin. + Webpack, + /// In Turbopack mode: + /// * each dynamic import is amended with a transition to `dynamic_transition_name` + /// * the ident of the client module (via `dynamic_client_transition_name`) is added to the + /// metadata + Turbopack { + dynamic_client_transition_name: String, + dynamic_transition_name: String, + }, +} + +#[derive(Debug)] +struct NextDynamicPatcher { + is_development: bool, + is_server_compiler: bool, + is_react_server_layer: bool, + prefer_esm: bool, + pages_or_app_dir: Option, + filename: Arc, + dynamic_bindings: Vec, + is_next_dynamic_first_arg: bool, + dynamically_imported_specifier: Option<(Atom, Span)>, + state: NextDynamicPatcherState, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +enum NextDynamicPatcherState { + Webpack, + /// In Turbo mode, contains a list of modules that need to be imported with + /// the given transition under a particular ident. + #[allow(unused)] + Turbopack { + dynamic_client_transition_name: String, + dynamic_transition_name: String, + imports: Vec, + }, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +enum TurbopackImport { + // TODO do we need more variants? server vs client vs dev vs prod? + Import { id_ident: Ident, specifier: Atom }, +} + +impl Fold for NextDynamicPatcher { + fn fold_module_items(&mut self, mut items: Vec) -> Vec { + items = items.fold_children_with(self); + + self.maybe_add_dynamically_imported_specifier(&mut items); + + items + } + + fn fold_import_decl(&mut self, decl: ImportDecl) -> ImportDecl { + let ImportDecl { + ref src, + ref specifiers, + .. + } = decl; + if &src.value == "next/dynamic" { + for specifier in specifiers { + if let ImportSpecifier::Default(default_specifier) = specifier { + self.dynamic_bindings.push(default_specifier.local.to_id()); + } + } + } + + decl + } + + fn fold_call_expr(&mut self, expr: CallExpr) -> CallExpr { + if self.is_next_dynamic_first_arg { + if let Callee::Import(..) = &expr.callee { + match &*expr.args[0].expr { + Expr::Lit(Lit::Str(Str { value, span, .. })) => { + self.dynamically_imported_specifier = Some((value.clone(), *span)); + } + Expr::Tpl(Tpl { exprs, quasis, .. }) if exprs.is_empty() => { + self.dynamically_imported_specifier = Some((quasis[0].raw.clone(), quasis[0].span)); + } + _ => {} + } + } + return expr.fold_children_with(self); + } + let mut expr = expr.fold_children_with(self); + if let Callee::Expr(i) = &expr.callee { + if let Expr::Ident(identifier) = &**i { + if self.dynamic_bindings.contains(&identifier.to_id()) { + if expr.args.is_empty() { + HANDLER.with(|handler| { + handler + .struct_span_err( + identifier.span, + "next/dynamic requires at least one argument", + ) + .emit() + }); + return expr; + } else if expr.args.len() > 2 { + HANDLER.with(|handler| { + handler + .struct_span_err(identifier.span, "next/dynamic only accepts 2 arguments") + .emit() + }); + return expr; + } + if expr.args.len() == 2 { + match &*expr.args[1].expr { + Expr::Object(_) => {} + _ => { + HANDLER.with(|handler| { + handler + .struct_span_err( + identifier.span, + "next/dynamic options must be an object literal.\nRead more: https://nextjs.org/docs/messages/invalid-dynamic-options-type", + ) + .emit(); + }); + return expr; + } + } + } + + self.is_next_dynamic_first_arg = true; + expr.args[0].expr = expr.args[0].expr.clone().fold_with(self); + self.is_next_dynamic_first_arg = false; + + let Some((dynamically_imported_specifier, dynamically_imported_specifier_span)) = + self.dynamically_imported_specifier.take() + else { + return expr; + }; + + let project_dir = match self.pages_or_app_dir.as_deref() { + Some(pages_or_app) => pages_or_app.parent(), + _ => None, + }; + + let generated = Box::new(Expr::Object(ObjectLit { + span: DUMMY_SP, + props: match &mut self.state { + NextDynamicPatcherState::Webpack => { + // dev client or server: + // loadableGenerated: { + // modules: + // ["/project/src/file-being-transformed.js -> " + + // '../components/hello'] } + // + // prod client + // loadableGenerated: { + // webpack: () => [require.resolveWeak('../components/hello')], + if self.is_development || self.is_server_compiler { + module_id_options(quote!( + "$left + $right" as Expr, + left: Expr = format!( + "{} -> ", + rel_filename(project_dir, &self.filename) + ) + .into(), + right: Expr = dynamically_imported_specifier.clone().into(), + )) + } else { + webpack_options(quote!( + "require.resolveWeak($id)" as Expr, + id: Expr = dynamically_imported_specifier.clone().into() + )) + } + } + + NextDynamicPatcherState::Turbopack { imports, .. } => { + // loadableGenerated: { modules: [ + // ".../client.js [app-client] (ecmascript, next/dynamic entry)" + // ]} + let id_ident = private_ident!(dynamically_imported_specifier_span, "id"); + + imports.push(TurbopackImport::Import { + id_ident: id_ident.clone(), + specifier: dynamically_imported_specifier.clone(), + }); + + module_id_options(Expr::Ident(id_ident)) + } + }, + })); + + let mut props = vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(IdentName::new("loadableGenerated".into(), DUMMY_SP)), + value: generated, + })))]; + + let mut has_ssr_false = false; + + if expr.args.len() == 2 { + if let Expr::Object(ObjectLit { + props: options_props, + .. + }) = &*expr.args[1].expr + { + for prop in options_props.iter() { + if let Some(KeyValueProp { key, value }) = match prop { + PropOrSpread::Prop(prop) => match &**prop { + Prop::KeyValue(key_value_prop) => Some(key_value_prop), + _ => None, + }, + _ => None, + } { + if let Some(IdentName { sym, span: _ }) = match key { + PropName::Ident(ident) => Some(ident), + _ => None, + } { + if sym == "ssr" { + if let Some(Lit::Bool(Bool { + value: false, + span: _, + })) = value.as_lit() + { + has_ssr_false = true + } + } + } + } + } + props.extend(options_props.iter().cloned()); + } + } + + match &self.state { + NextDynamicPatcherState::Webpack => { + // Only use `require.resolveWebpack` to decouple modules for webpack, + // turbopack doesn't need this + + // When it's not prefering to picking up ESM (in the pages router), we + // don't need to do it as it doesn't need to enter the non-ssr module. + // + // Also transforming it to `require.resolveWeak` doesn't work with ESM + // imports ( i.e. require.resolveWeak(esm asset)). + if has_ssr_false + && self.is_server_compiler + && !self.is_react_server_layer + && self.prefer_esm + { + // if it's server components SSR layer + // Transform 1st argument `expr.args[0]` aka the module loader from: + // dynamic(() => import('./client-mod'), { ssr: false }))` + // into: + // dynamic(async () => { + // require.resolveWeak('./client-mod') + // }, { ssr: false }))` + + let require_resolve_weak_expr = Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("require.resolveWeak").as_callee(), + args: vec![ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: dynamically_imported_specifier.clone(), + raw: None, + }))), + }], + ..Default::default() + }); + + let side_effect_free_loader_arg = Expr::Arrow(ArrowExpr { + span: DUMMY_SP, + params: vec![], + body: Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt { + span: DUMMY_SP, + stmts: vec![Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(exec_expr_when_resolve_weak_available( + &require_resolve_weak_expr, + )), + })], + ..Default::default() + })), + is_async: true, + is_generator: false, + ..Default::default() + }); + + expr.args[0] = side_effect_free_loader_arg.as_arg(); + } + } + NextDynamicPatcherState::Turbopack { + dynamic_transition_name, + .. + } => { + // Add `{with:{turbopack-transition: ...}}` to the dynamic import + let mut visitor = DynamicImportTransitionAdder { + transition_name: dynamic_transition_name, + }; + expr.args[0].visit_mut_with(&mut visitor); + } + } + + let second_arg = ExprOrSpread { + spread: None, + expr: Box::new(Expr::Object(ObjectLit { + span: DUMMY_SP, + props, + })), + }; + + if expr.args.len() == 2 { + expr.args[1] = second_arg; + } else { + expr.args.push(second_arg) + } + } + } + } + expr + } +} + +struct DynamicImportTransitionAdder<'a> { + transition_name: &'a str, +} +// Add `{with:{turbopack-transition: }}` to any dynamic imports +impl VisitMut for DynamicImportTransitionAdder<'_> { + fn visit_mut_call_expr(&mut self, expr: &mut CallExpr) { + if let Callee::Import(..) = &expr.callee { + let options = ExprOrSpread { + expr: Box::new( + ObjectLit { + span: DUMMY_SP, + props: vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(IdentName::new("with".into(), DUMMY_SP)), + value: with_transition(self.transition_name).into(), + })))], + } + .into(), + ), + spread: None, + }; + + match expr.args.get_mut(1) { + Some(arg) => *arg = options, + None => expr.args.push(options), + } + } else { + expr.visit_mut_children_with(self); + } + } +} + +fn module_id_options(module_id: Expr) -> Vec { + vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(IdentName::new("modules".into(), DUMMY_SP)), + value: Box::new(Expr::Array(ArrayLit { + elems: vec![Some(ExprOrSpread { + expr: Box::new(module_id), + spread: None, + })], + span: DUMMY_SP, + })), + })))] +} + +fn webpack_options(module_id: Expr) -> Vec { + vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(IdentName::new("webpack".into(), DUMMY_SP)), + value: Box::new(Expr::Arrow(ArrowExpr { + params: vec![], + body: Box::new(BlockStmtOrExpr::Expr(Box::new(Expr::Array(ArrayLit { + elems: vec![Some(ExprOrSpread { + expr: Box::new(module_id), + spread: None, + })], + span: DUMMY_SP, + })))), + is_async: false, + is_generator: false, + span: DUMMY_SP, + ..Default::default() + })), + })))] +} + +impl NextDynamicPatcher { + fn maybe_add_dynamically_imported_specifier(&mut self, items: &mut Vec) { + let NextDynamicPatcherState::Turbopack { + dynamic_client_transition_name, + imports, + .. + } = &mut self.state + else { + return; + }; + + let mut new_items = Vec::with_capacity(imports.len()); + + for import in std::mem::take(imports) { + match import { + TurbopackImport::Import { + id_ident, + specifier, + } => { + // Turbopack will automatically transform the imported `__turbopack_module_id__` + // identifier into the imported module's id. + new_items.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: id_ident, + imported: Some( + Ident::new( + "__turbopack_module_id__".into(), + DUMMY_SP, + Default::default(), + ) + .into(), + ), + is_type_only: false, + })], + src: Box::new(specifier.into()), + type_only: false, + with: Some(with_transition_chunking_type( + dynamic_client_transition_name, + "none", + )), + phase: Default::default(), + }))); + } + } + } + + new_items.append(items); + + std::mem::swap(&mut new_items, items) + } +} + +fn exec_expr_when_resolve_weak_available(expr: &Expr) -> Expr { + let undefined_str_literal = Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: "undefined".into(), + raw: None, + })); + + let typeof_expr = Expr::Unary(UnaryExpr { + span: DUMMY_SP, + op: UnaryOp::TypeOf, // 'typeof' operator + arg: Box::new(Expr::Ident(Ident { + sym: quote_ident!("require.resolveWeak").sym, + ..Default::default() + })), + }); + + // typeof require.resolveWeak !== 'undefined' && + Expr::Bin(BinExpr { + span: DUMMY_SP, + left: Box::new(Expr::Bin(BinExpr { + span: DUMMY_SP, + op: op!("!=="), + left: Box::new(typeof_expr), + right: Box::new(undefined_str_literal), + })), + op: op!("&&"), + right: Box::new(expr.clone()), + }) +} + +fn rel_filename(base: Option<&Path>, file: &FileName) -> String { + let base = match base { + Some(v) => v, + None => return file.to_string(), + }; + + let file = match file { + FileName::Real(v) => v, + _ => { + return file.to_string(); + } + }; + + let rel_path = diff_paths(file, base); + + let rel_path = match rel_path { + Some(v) => v, + None => return file.display().to_string(), + }; + + rel_path.display().to_string() +} + +fn with_transition(transition_name: &str) -> ObjectLit { + with_clause(&[("turbopack-transition", transition_name)]) +} + +fn with_transition_chunking_type(transition_name: &str, chunking_type: &str) -> Box { + Box::new(with_clause(&[ + ("turbopack-transition", transition_name), + ("turbopack-chunking-type", chunking_type), + ])) +} + +fn with_clause<'a>(entries: impl IntoIterator) -> ObjectLit { + ObjectLit { + span: DUMMY_SP, + props: entries.into_iter().map(|(k, v)| with_prop(k, v)).collect(), + } +} + +fn with_prop(key: &str, value: &str) -> PropOrSpread { + PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Str(key.into()), + value: Box::new(Expr::Lit(value.into())), + }))) +} diff --git a/crates/next-custom-transforms/src/transforms/fonts/find_functions_outside_module_scope.rs b/crates/next-custom-transforms/src/transforms/fonts/find_functions_outside_module_scope.rs new file mode 100644 index 000000000000..1d162992461f --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/fonts/find_functions_outside_module_scope.rs @@ -0,0 +1,32 @@ +use swc_core::{ + common::errors::HANDLER, + ecma::{ + ast::*, + visit::{noop_visit_type, Visit}, + }, +}; +pub struct FindFunctionsOutsideModuleScope<'a> { + pub state: &'a super::State, +} + +impl Visit for FindFunctionsOutsideModuleScope<'_> { + noop_visit_type!(); + + fn visit_ident(&mut self, ident: &Ident) { + if self.state.font_functions.contains_key(&ident.to_id()) + && !self + .state + .font_functions_in_allowed_scope + .contains(&ident.span.lo) + { + HANDLER.with(|handler| { + handler + .struct_span_err( + ident.span, + "Font loaders must be called and assigned to a const in the module scope", + ) + .emit() + }); + } + } +} diff --git a/crates/next-custom-transforms/src/transforms/fonts/font_functions_collector.rs b/crates/next-custom-transforms/src/transforms/fonts/font_functions_collector.rs new file mode 100644 index 000000000000..77ac7682cd38 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/fonts/font_functions_collector.rs @@ -0,0 +1,66 @@ +use swc_core::{ + common::errors::HANDLER, + ecma::{ + ast::*, + atoms::JsWord, + visit::{noop_visit_type, Visit}, + }, +}; + +pub struct FontFunctionsCollector<'a> { + pub font_loaders: &'a [JsWord], + pub state: &'a mut super::State, +} + +impl Visit for FontFunctionsCollector<'_> { + noop_visit_type!(); + + fn visit_import_decl(&mut self, import_decl: &ImportDecl) { + if self.font_loaders.contains(&import_decl.src.value) { + self + .state + .removeable_module_items + .insert(import_decl.span.lo); + for specifier in &import_decl.specifiers { + let (local, function_name) = match specifier { + ImportSpecifier::Named(ImportNamedSpecifier { + local, imported, .. + }) => { + let function_name = if let Some(ModuleExportName::Ident(ident)) = imported { + ident.sym.clone() + } else { + local.sym.clone() + }; + + (local, Some(function_name)) + } + ImportSpecifier::Default(ImportDefaultSpecifier { local, .. }) => (local, None), + ImportSpecifier::Namespace(_) => { + HANDLER.with(|handler| { + handler + .struct_span_err( + import_decl.span, + "Font loaders can't have namespace imports", + ) + .emit() + }); + continue; + } + }; + + self + .state + .font_functions_in_allowed_scope + .insert(local.span.lo); + + self.state.font_functions.insert( + local.to_id(), + super::FontFunction { + loader: import_decl.src.value.clone(), + function_name, + }, + ); + } + } + } +} diff --git a/crates/next-custom-transforms/src/transforms/fonts/font_imports_generator.rs b/crates/next-custom-transforms/src/transforms/fonts/font_imports_generator.rs new file mode 100644 index 000000000000..632b6daa561f --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/fonts/font_imports_generator.rs @@ -0,0 +1,277 @@ +use serde_json::Value; +use swc_core::{ + common::{errors::HANDLER, Spanned, DUMMY_SP}, + ecma::{ + ast::*, + atoms::JsWord, + visit::{noop_visit_type, Visit}, + }, +}; + +pub struct FontImportsGenerator<'a> { + pub state: &'a mut super::State, + pub relative_path: &'a str, +} + +impl FontImportsGenerator<'_> { + fn check_call_expr( + &mut self, + call_expr: &CallExpr, + variable_name: &Result, + ) -> Option { + if let Callee::Expr(callee_expr) = &call_expr.callee { + if let Expr::Ident(ident) = &**callee_expr { + if let Some(font_function) = self.state.font_functions.get(&ident.to_id()) { + self + .state + .font_functions_in_allowed_scope + .insert(ident.span.lo); + + let json: Result, ()> = call_expr + .args + .iter() + .map(|expr_or_spread| { + if let Some(span) = expr_or_spread.spread { + HANDLER.with(|handler| { + handler + .struct_span_err(span, "Font loaders don't accept spreads") + .emit() + }); + } + + expr_to_json(&expr_or_spread.expr) + }) + .collect(); + + if let Ok(json) = json { + let function_name = match &font_function.function_name { + Some(function) => String::from(&**function), + None => String::new(), + }; + let mut query_json_values = serde_json::Map::new(); + query_json_values.insert( + String::from("path"), + Value::String(self.relative_path.to_string()), + ); + query_json_values.insert(String::from("import"), Value::String(function_name)); + query_json_values.insert(String::from("arguments"), Value::Array(json)); + if let Ok(ident) = variable_name { + query_json_values.insert( + String::from("variableName"), + Value::String(ident.sym.to_string()), + ); + } + + let query_json = Value::Object(query_json_values); + + return Some(ImportDecl { + src: Box::new(Str { + value: JsWord::from(format!( + "{}/target.css?{}", + font_function.loader, query_json + )), + raw: None, + span: DUMMY_SP, + }), + specifiers: vec![], + type_only: false, + with: None, + span: DUMMY_SP, + phase: Default::default(), + }); + } + } + } + } + + None + } + + fn check_var_decl(&mut self, var_decl: &VarDecl) -> Option { + if let Some(decl) = var_decl.decls.first() { + let ident = match &decl.name { + Pat::Ident(ident) => Ok(ident.id.clone()), + pattern => Err(pattern), + }; + if let Some(expr) = &decl.init { + if let Expr::Call(call_expr) = &**expr { + let import_decl = self.check_call_expr(call_expr, &ident); + + if let Some(mut import_decl) = import_decl { + match var_decl.kind { + VarDeclKind::Const => {} + _ => { + HANDLER.with(|handler| { + handler + .struct_span_err( + var_decl.span, + "Font loader calls must be assigned to a const", + ) + .emit() + }); + } + } + + match ident { + Ok(ident) => { + import_decl.specifiers = vec![ImportSpecifier::Default(ImportDefaultSpecifier { + span: DUMMY_SP, + local: ident.clone(), + })]; + + self + .state + .font_imports + .push(ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl))); + + return Some(ident); + } + Err(pattern) => { + HANDLER.with(|handler| { + handler + .struct_span_err( + pattern.span(), + "Font loader calls must be assigned to an identifier", + ) + .emit() + }); + } + } + } + } + } + } + None + } +} + +impl Visit for FontImportsGenerator<'_> { + noop_visit_type!(); + + fn visit_module_item(&mut self, item: &ModuleItem) { + match item { + ModuleItem::Stmt(Stmt::Decl(Decl::Var(var_decl))) => { + if self.check_var_decl(var_decl).is_some() { + self.state.removeable_module_items.insert(var_decl.span.lo); + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export_decl)) => { + if let Decl::Var(var_decl) = &export_decl.decl { + if let Some(ident) = self.check_var_decl(var_decl) { + self + .state + .removeable_module_items + .insert(export_decl.span.lo); + + self + .state + .font_exports + .push(ModuleItem::ModuleDecl(ModuleDecl::ExportNamed( + NamedExport { + span: DUMMY_SP, + specifiers: vec![ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(ident), + span: DUMMY_SP, + exported: None, + is_type_only: false, + })], + src: None, + type_only: false, + with: None, + }, + ))); + } + } + } + _ => {} + } + } +} + +fn object_lit_to_json(object_lit: &ObjectLit) -> Value { + let mut values = serde_json::Map::new(); + for prop in &object_lit.props { + match prop { + PropOrSpread::Prop(prop) => match &**prop { + Prop::KeyValue(key_val) => { + let key = match &key_val.key { + PropName::Ident(ident) => Ok(String::from(&*ident.sym)), + key => { + HANDLER.with(|handler| { + handler + .struct_span_err(key.span(), "Unexpected object key type") + .emit() + }); + Err(()) + } + }; + let val = expr_to_json(&key_val.value); + if let (Ok(key), Ok(val)) = (key, val) { + values.insert(key, val); + } + } + key => HANDLER.with(|handler| { + handler.struct_span_err(key.span(), "Unexpected key").emit(); + }), + }, + PropOrSpread::Spread(spread_span) => HANDLER.with(|handler| { + handler + .struct_span_err(spread_span.dot3_token, "Unexpected spread") + .emit(); + }), + } + } + + Value::Object(values) +} + +fn expr_to_json(expr: &Expr) -> Result { + match expr { + Expr::Lit(Lit::Str(str)) => Ok(Value::String(String::from(&*str.value))), + Expr::Lit(Lit::Bool(Bool { value, .. })) => Ok(Value::Bool(*value)), + Expr::Lit(Lit::Num(Number { value, .. })) => { + Ok(Value::Number(serde_json::Number::from_f64(*value).unwrap())) + } + Expr::Object(object_lit) => Ok(object_lit_to_json(object_lit)), + Expr::Array(ArrayLit { + elems, + span: array_span, + .. + }) => { + let elements: Result, ()> = elems + .iter() + .map(|e| { + if let Some(expr) = e { + match expr.spread { + Some(spread_span) => HANDLER.with(|handler| { + handler + .struct_span_err(spread_span, "Unexpected spread") + .emit(); + Err(()) + }), + None => expr_to_json(&expr.expr), + } + } else { + HANDLER.with(|handler| { + handler + .struct_span_err(*array_span, "Unexpected empty value in array") + .emit(); + Err(()) + }) + } + }) + .collect(); + + elements.map(Value::Array) + } + lit => HANDLER.with(|handler| { + handler + .struct_span_err( + lit.span(), + "Font loader values must be explicitly written literals.", + ) + .emit(); + Err(()) + }), + } +} diff --git a/crates/next-custom-transforms/src/transforms/fonts/mod.rs b/crates/next-custom-transforms/src/transforms/fonts/mod.rs new file mode 100644 index 000000000000..f29b64a14c59 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/fonts/mod.rs @@ -0,0 +1,95 @@ +use rustc_hash::{FxHashMap, FxHashSet}; +use serde::Deserialize; +use swc_core::{ + common::{BytePos, Spanned}, + ecma::{ + ast::{Id, ModuleItem, Pass}, + atoms::JsWord, + visit::{noop_visit_mut_type, visit_mut_pass, VisitMut, VisitWith}, + }, +}; + +mod find_functions_outside_module_scope; +mod font_functions_collector; +mod font_imports_generator; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Config { + pub font_loaders: Vec, + pub relative_file_path_from_root: JsWord, +} + +pub fn next_font_loaders(config: Config) -> impl Pass + VisitMut { + visit_mut_pass(NextFontLoaders { + config, + state: State { + ..Default::default() + }, + }) +} + +#[derive(Debug)] +pub struct FontFunction { + loader: JsWord, + function_name: Option, +} +#[derive(Debug, Default)] +pub struct State { + font_functions: FxHashMap, + removeable_module_items: FxHashSet, + font_imports: Vec, + font_exports: Vec, + font_functions_in_allowed_scope: FxHashSet, +} + +struct NextFontLoaders { + config: Config, + state: State, +} + +impl VisitMut for NextFontLoaders { + noop_visit_mut_type!(); + + fn visit_mut_module_items(&mut self, items: &mut Vec) { + // Find imported functions from font loaders + let mut functions_collector = font_functions_collector::FontFunctionsCollector { + font_loaders: &self.config.font_loaders, + state: &mut self.state, + }; + items.visit_with(&mut functions_collector); + + if !self.state.removeable_module_items.is_empty() { + // Generate imports from font function calls + let mut import_generator = font_imports_generator::FontImportsGenerator { + state: &mut self.state, + relative_path: &self.config.relative_file_path_from_root, + }; + items.visit_with(&mut import_generator); + + // Find font function refs in wrong scope + let mut wrong_scope = + find_functions_outside_module_scope::FindFunctionsOutsideModuleScope { state: &self.state }; + items.visit_with(&mut wrong_scope); + + fn is_removable(ctx: &NextFontLoaders, item: &ModuleItem) -> bool { + ctx.state.removeable_module_items.contains(&item.span_lo()) + } + + let first_removable_index = items + .iter() + .position(|item| is_removable(self, item)) + .unwrap(); + + // Remove marked module items + items.retain(|item| !is_removable(self, item)); + + // Add font imports and exports + items.splice( + first_removable_index..first_removable_index, + std::mem::take(&mut self.state.font_imports), + ); + items.append(&mut self.state.font_exports); + } + } +} diff --git a/crates/next-custom-transforms/src/transforms/import_analyzer.rs b/crates/next-custom-transforms/src/transforms/import_analyzer.rs new file mode 100644 index 000000000000..098f0980a665 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/import_analyzer.rs @@ -0,0 +1,106 @@ +use rustc_hash::{FxHashMap, FxHashSet}; +use swc_core::{ + atoms::JsWord, + ecma::{ + ast::{ + Expr, Id, ImportDecl, ImportNamedSpecifier, ImportSpecifier, MemberExpr, MemberProp, Module, + ModuleExportName, + }, + visit::{noop_visit_type, Visit, VisitWith}, + }, +}; + +#[derive(Debug, Default)] +pub(crate) struct ImportMap { + /// Map from module name to (module path, exported symbol) + imports: FxHashMap, + + namespace_imports: FxHashMap, + + imported_modules: FxHashSet, +} + +#[allow(unused)] +impl ImportMap { + pub fn is_module_imported(&mut self, module: &JsWord) -> bool { + self.imported_modules.contains(module) + } + + /// Returns true if `e` is an import of `orig_name` from `module`. + pub fn is_import(&self, e: &Expr, module: &str, orig_name: &str) -> bool { + match e { + Expr::Ident(i) => { + if let Some((i_src, i_sym)) = self.imports.get(&i.to_id()) { + i_src == module && i_sym == orig_name + } else { + false + } + } + + Expr::Member(MemberExpr { + obj: box Expr::Ident(obj), + prop: MemberProp::Ident(prop), + .. + }) => { + if let Some(obj_src) = self.namespace_imports.get(&obj.to_id()) { + obj_src == module && prop.sym == *orig_name + } else { + false + } + } + + _ => false, + } + } + + pub fn analyze(m: &Module) -> Self { + let mut data = ImportMap::default(); + + m.visit_with(&mut Analyzer { data: &mut data }); + + data + } +} + +struct Analyzer<'a> { + data: &'a mut ImportMap, +} + +impl Visit for Analyzer<'_> { + noop_visit_type!(); + + fn visit_import_decl(&mut self, import: &ImportDecl) { + self.data.imported_modules.insert(import.src.value.clone()); + + for s in &import.specifiers { + let (local, orig_sym) = match s { + ImportSpecifier::Named(ImportNamedSpecifier { + local, imported, .. + }) => match imported { + Some(imported) => (local.to_id(), orig_name(imported)), + _ => (local.to_id(), local.sym.clone()), + }, + ImportSpecifier::Default(s) => (s.local.to_id(), "default".into()), + ImportSpecifier::Namespace(s) => { + self + .data + .namespace_imports + .insert(s.local.to_id(), import.src.value.clone()); + continue; + } + }; + + self + .data + .imports + .insert(local, (import.src.value.clone(), orig_sym)); + } + } +} + +fn orig_name(n: &ModuleExportName) -> JsWord { + match n { + ModuleExportName::Ident(v) => v.sym.clone(), + ModuleExportName::Str(v) => v.value.clone(), + } +} diff --git a/crates/next-custom-transforms/src/transforms/lint_codemod_comments.rs b/crates/next-custom-transforms/src/transforms/lint_codemod_comments.rs new file mode 100644 index 000000000000..fd6bf663bb63 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/lint_codemod_comments.rs @@ -0,0 +1,83 @@ +use swc_core::{ + common::{ + comments::{Comment, Comments}, + errors::HANDLER, + }, + ecma::{utils::swc_common::Span, visit::Visit}, +}; + +struct LintErrorComment +where + C: Comments, +{ + comments: C, +} + +pub fn lint_codemod_comments(comments: C) -> impl Visit +where + C: Comments, +{ + LintErrorComment { comments } +} + +// declare a const of comment prefix +const COMMENT_ERROR_PREFIX: &str = "@next-codemod-error"; +const COMMENT_BYPASS_PREFIX: &str = "@next-codemod-ignore"; + +impl LintErrorComment +where + C: Comments, +{ + fn lint(&self, comment: &Comment, is_leading: bool) { + let trimmed_text = comment.text.trim(); + // if comment contains @next/codemod comment "@next-codemod-error", + // report an error from the linter to fail the build + if trimmed_text.contains(COMMENT_ERROR_PREFIX) { + let span = if is_leading { + comment + .span + .with_lo(comment.span.lo() - swc_core::common::BytePos(1)) + } else { + comment + .span + .with_hi(comment.span.hi() + swc_core::common::BytePos(1)) + }; + let action = trimmed_text.replace(COMMENT_ERROR_PREFIX, ""); + let err_message = format!( + "You have an unresolved @next/codemod comment \"{}\" that needs review.\nAfter \ + review, either remove the comment if you made the necessary changes or replace \ + \"{}\" with \"{}\" to bypass the build error if no action at this line can be \ + taken.\n", + action.trim(), + COMMENT_ERROR_PREFIX, + COMMENT_BYPASS_PREFIX + ); + report(span, &err_message); + } + } +} + +fn report(span: Span, msg: &str) { + HANDLER.with(|handler| { + handler.struct_span_err(span, msg).emit(); + }) +} + +impl Visit for LintErrorComment +where + C: Comments, +{ + fn visit_span(&mut self, s: &Span) { + self.comments.with_leading(s.lo, |comments| { + for c in comments { + self.lint(c, true); + } + }); + + self.comments.with_trailing(s.hi, |comments| { + for c in comments { + self.lint(c, false); + } + }); + } +} diff --git a/crates/next-custom-transforms/src/transforms/middleware_dynamic.rs b/crates/next-custom-transforms/src/transforms/middleware_dynamic.rs new file mode 100644 index 000000000000..eb86dbe4973d --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/middleware_dynamic.rs @@ -0,0 +1,110 @@ +use swc_core::{ + ecma::{ + ast::*, + visit::{VisitMut, VisitMutWith}, + }, + quote, +}; + +enum WrappedExpr { + Eval, + WasmCompile, + WasmInstantiate, +} + +/// Replaces call / expr to dynamic evaluation in the give code to +/// wrapped expression (__next_eval__, __next_webassembly_compile__,..) to raise +/// corresponding error. +/// +/// This transform is specific to edge runtime which are not allowed to +/// call certain dynamic evaluation (eval, webassembly.instantiate, etc) +/// +/// check middleware-plugin for corresponding webpack side transform. +pub fn next_middleware_dynamic() -> MiddlewareDynamic { + MiddlewareDynamic {} +} + +pub struct MiddlewareDynamic {} + +impl VisitMut for MiddlewareDynamic { + fn visit_mut_expr(&mut self, expr: &mut Expr) { + let mut should_wrap = None; + + expr.visit_mut_children_with(self); + + if let Expr::Call(call_expr) = &expr { + let callee = &call_expr.callee; + if let Callee::Expr(callee) = callee { + // `eval('some')`, or `Function('some')` + if let Expr::Ident(ident) = &**callee { + if ident.sym == "eval" || ident.sym == "Function" { + should_wrap = Some(WrappedExpr::Eval); + } + } + + if let Expr::Member(MemberExpr { + obj, + prop: MemberProp::Ident(prop_ident), + .. + }) = &**callee + { + if let Expr::Ident(ident) = &**obj { + // `global.eval('some')` + if ident.sym == "global" && prop_ident.sym == "eval" { + should_wrap = Some(WrappedExpr::Eval); + } + + // `WebAssembly.compile('some')` & `WebAssembly.instantiate('some')` + if ident.sym == "WebAssembly" { + if prop_ident.sym == "compile" { + should_wrap = Some(WrappedExpr::WasmCompile); + } else if prop_ident.sym == "instantiate" { + should_wrap = Some(WrappedExpr::WasmInstantiate); + } + } + } + + if let Expr::Member(MemberExpr { + obj, + prop: MemberProp::Ident(member_prop_ident), + .. + }) = &**obj + { + if let Expr::Ident(ident) = &**obj { + // `global.WebAssembly.compile('some')` & + // `global.WebAssembly.instantiate('some')` + if ident.sym == "global" && member_prop_ident.sym == "WebAssembly" { + if prop_ident.sym == "compile" { + should_wrap = Some(WrappedExpr::WasmCompile); + } else if prop_ident.sym == "instantiate" { + should_wrap = Some(WrappedExpr::WasmInstantiate); + } + } + } + } + } + } + + match should_wrap { + Some(WrappedExpr::Eval) => { + *expr = quote!("__next_eval__(function() { return $orig_call });" as Expr, orig_call: Expr = Expr::Call(call_expr.clone())); + } + Some(WrappedExpr::WasmCompile) => { + *expr = quote!("__next_webassembly_compile__(function() { return $orig_call });" as Expr, orig_call: Expr = Expr::Call(call_expr.clone())); + } + Some(WrappedExpr::WasmInstantiate) => { + *expr = quote!("__next_webassembly_instantiate__(function() { return $orig_call });" as Expr, orig_call: Expr = Expr::Call(call_expr.clone())); + } + None => {} + } + } + + if let Expr::New(NewExpr { callee, .. }) = &expr { + if let Expr::Ident(ident) = &**callee { + if ident.sym == "Function" { + *expr = quote!("__next_eval__(function() { return $orig_call });" as Expr, orig_call: Expr = expr.clone()); + } + } + } + } +} diff --git a/crates/next-custom-transforms/src/transforms/mod.rs b/crates/next-custom-transforms/src/transforms/mod.rs new file mode 100644 index 000000000000..71618dd378b2 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/mod.rs @@ -0,0 +1,24 @@ +pub mod amp_attributes; +pub mod cjs_finder; +pub mod cjs_optimizer; +pub mod debug_fn_name; +pub mod disallow_re_export_all_in_page; +pub mod dynamic; +pub mod fonts; +pub mod import_analyzer; +pub mod lint_codemod_comments; +pub mod middleware_dynamic; +pub mod next_ssg; +pub mod optimize_barrel; +pub mod optimize_server_react; +pub mod page_config; +pub mod page_static_info; +pub mod pure; +pub mod react_server_components; +pub mod server_actions; +pub mod shake_exports; +pub mod strip_page_exports; +pub mod warn_for_edge_runtime; + +//[TODO] PACK-1564: need to decide reuse vs. turbopack specific +pub mod named_import_transform; diff --git a/crates/next-custom-transforms/src/transforms/named_import_transform.rs b/crates/next-custom-transforms/src/transforms/named_import_transform.rs new file mode 100644 index 000000000000..ec5fb014b902 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/named_import_transform.rs @@ -0,0 +1,93 @@ +use std::collections::HashSet; + +use serde::Deserialize; +use swc_core::{ + common::DUMMY_SP, + ecma::{ + ast::*, + visit::{fold_pass, Fold}, + }, +}; + +#[derive(Clone, Debug, Deserialize)] +pub struct Config { + pub packages: Vec, +} + +pub fn named_import_transform(config: Config) -> impl Pass { + fold_pass(NamedImportTransform { + packages: config.packages, + }) +} + +#[derive(Debug, Default)] +struct NamedImportTransform { + packages: Vec, +} + +/// TODO: Implement this as a [Pass] instead of a full visitor ([Fold]) +impl Fold for NamedImportTransform { + fn fold_import_decl(&mut self, decl: ImportDecl) -> ImportDecl { + // Match named imports and check if it's included in the packages + let src_value = decl.src.value.clone(); + + if self.packages.iter().any(|p| src_value == *p) { + let mut specifier_names = HashSet::new(); + + // Skip the transform if the default or namespace import is present + let mut skip_transform = false; + + for specifier in &decl.specifiers { + match specifier { + ImportSpecifier::Named(specifier) => { + // Add the import name as string to the set + if let Some(imported) = &specifier.imported { + match imported { + ModuleExportName::Ident(ident) => { + specifier_names.insert(ident.sym.to_string()); + } + ModuleExportName::Str(str_) => { + specifier_names.insert(str_.value.to_string()); + } + } + } else { + specifier_names.insert(specifier.local.sym.to_string()); + } + } + ImportSpecifier::Default(_) => { + skip_transform = true; + break; + } + ImportSpecifier::Namespace(_) => { + skip_transform = true; + break; + } + } + } + + if !skip_transform { + let mut names = specifier_names.into_iter().collect::>(); + // Sort the names to make sure the order is consistent + names.sort(); + + let new_src = format!( + "__barrel_optimize__?names={}!=!{}", + names.join(","), + src_value + ); + + // Create a new import declaration, keep everything the same except the source + let mut new_decl = decl.clone(); + new_decl.src = Box::new(Str { + span: DUMMY_SP, + value: new_src.into(), + raw: None, + }); + + return new_decl; + } + } + + decl + } +} diff --git a/crates/next-custom-transforms/src/transforms/next_ssg.rs b/crates/next-custom-transforms/src/transforms/next_ssg.rs new file mode 100644 index 000000000000..80f2edb10771 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/next_ssg.rs @@ -0,0 +1,694 @@ +use std::{cell::RefCell, mem::take, rc::Rc}; + +use easy_error::{bail, Error}; +use rustc_hash::FxHashSet; +use swc_core::{ + atoms::Atom, + common::{ + errors::HANDLER, + pass::{Repeat, Repeated}, + DUMMY_SP, + }, + ecma::{ + ast::*, + visit::{fold_pass, noop_fold_type, Fold, FoldWith}, + }, +}; + +static SSG_EXPORTS: &[&str; 3] = &["getStaticProps", "getStaticPaths", "getServerSideProps"]; + +/// Note: This paths requires running `resolver` **before** running this. +pub fn next_ssg(eliminated_packages: Rc>>) -> impl Pass { + fold_pass(Repeat::new(NextSsg { + state: State { + eliminated_packages, + ..Default::default() + }, + in_lhs_of_var: false, + })) +} + +/// State of the transforms. Shared by the analyzer and the transform. +#[derive(Debug, Default)] +struct State { + /// Identifiers referenced by non-data function codes. + /// + /// Cleared before running each pass, because we drop ast nodes between the + /// passes. + refs_from_other: FxHashSet, + + /// Identifiers referenced by data functions or derivatives. + /// + /// Preserved between runs, because we should remember derivatives of data + /// functions as the data function itself is already removed. + refs_from_data_fn: FxHashSet, + + cur_declaring: FxHashSet, + + is_prerenderer: bool, + is_server_props: bool, + done: bool, + + should_run_again: bool, + + /// Track the import packages which are eliminated in the + /// `getServerSideProps` + pub eliminated_packages: Rc>>, +} + +impl State { + #[allow(clippy::wrong_self_convention)] + fn is_data_identifier(&mut self, i: &Ident) -> Result { + if SSG_EXPORTS.contains(&&*i.sym) { + if &*i.sym == "getServerSideProps" { + if self.is_prerenderer { + HANDLER.with(|handler| { + handler + .struct_span_err( + i.span, + "You can not use getStaticProps or getStaticPaths with \ + getServerSideProps. To use SSG, please remove getServerSideProps", + ) + .emit() + }); + bail!("both ssg and ssr functions present"); + } + + self.is_server_props = true; + } else { + if self.is_server_props { + HANDLER.with(|handler| { + handler + .struct_span_err( + i.span, + "You can not use getStaticProps or getStaticPaths with \ + getServerSideProps. To use SSG, please remove getServerSideProps", + ) + .emit() + }); + bail!("both ssg and ssr functions present"); + } + + self.is_prerenderer = true; + } + + Ok(true) + } else { + Ok(false) + } + } +} + +struct Analyzer<'a> { + state: &'a mut State, + in_lhs_of_var: bool, + in_data_fn: bool, +} + +impl Analyzer<'_> { + fn add_ref(&mut self, id: Id) { + tracing::trace!("add_ref({}{:?}, data = {})", id.0, id.1, self.in_data_fn); + if self.in_data_fn { + self.state.refs_from_data_fn.insert(id); + } else { + if self.state.cur_declaring.contains(&id) { + return; + } + + self.state.refs_from_other.insert(id); + } + } +} + +impl Fold for Analyzer<'_> { + // This is important for reducing binary sizes. + noop_fold_type!(); + + fn fold_binding_ident(&mut self, i: BindingIdent) -> BindingIdent { + if !self.in_lhs_of_var || self.in_data_fn { + self.add_ref(i.id.to_id()); + } + + i + } + + fn fold_export_named_specifier(&mut self, s: ExportNamedSpecifier) -> ExportNamedSpecifier { + if let ModuleExportName::Ident(id) = &s.orig { + if !SSG_EXPORTS.contains(&&*id.sym) { + self.add_ref(id.to_id()); + } + } + + s + } + + fn fold_export_decl(&mut self, s: ExportDecl) -> ExportDecl { + if let Decl::Var(d) = &s.decl { + if d.decls.is_empty() { + return s; + } + + if let Pat::Ident(id) = &d.decls[0].name { + if !SSG_EXPORTS.contains(&&*id.id.sym) { + self.add_ref(id.to_id()); + } + } + } + + s.fold_children_with(self) + } + + fn fold_expr(&mut self, e: Expr) -> Expr { + let e = e.fold_children_with(self); + + if let Expr::Ident(i) = &e { + self.add_ref(i.to_id()); + } + + e + } + + fn fold_jsx_element(&mut self, jsx: JSXElement) -> JSXElement { + fn get_leftmost_id_member_expr(e: &JSXMemberExpr) -> Id { + match &e.obj { + JSXObject::Ident(i) => i.to_id(), + JSXObject::JSXMemberExpr(e) => get_leftmost_id_member_expr(e), + } + } + + match &jsx.opening.name { + JSXElementName::Ident(i) => { + self.add_ref(i.to_id()); + } + JSXElementName::JSXMemberExpr(e) => { + self.add_ref(get_leftmost_id_member_expr(e)); + } + _ => {} + } + + jsx.fold_children_with(self) + } + + fn fold_fn_decl(&mut self, f: FnDecl) -> FnDecl { + let old_in_data = self.in_data_fn; + + self.state.cur_declaring.insert(f.ident.to_id()); + + if let Ok(is_data_identifier) = self.state.is_data_identifier(&f.ident) { + self.in_data_fn |= is_data_identifier; + } else { + return f; + } + tracing::trace!( + "ssg: Handling `{}{:?}`; in_data_fn = {:?}", + f.ident.sym, + f.ident.ctxt, + self.in_data_fn + ); + + let f = f.fold_children_with(self); + + self.state.cur_declaring.remove(&f.ident.to_id()); + + self.in_data_fn = old_in_data; + + f + } + + fn fold_fn_expr(&mut self, f: FnExpr) -> FnExpr { + let f = f.fold_children_with(self); + + if let Some(id) = &f.ident { + self.add_ref(id.to_id()); + } + + f + } + + /// Drops [ExportDecl] if all specifiers are removed. + fn fold_module_item(&mut self, s: ModuleItem) -> ModuleItem { + match s { + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(e)) if !e.specifiers.is_empty() => { + let e = e.fold_with(self); + + if e.specifiers.is_empty() { + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })); + } + + return ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(e)); + } + _ => {} + }; + + // Visit children to ensure that all references is added to the scope. + let s = s.fold_children_with(self); + + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(e)) = &s { + match &e.decl { + Decl::Fn(f) => { + // Drop getStaticProps. + if let Ok(is_data_identifier) = self.state.is_data_identifier(&f.ident) { + if is_data_identifier { + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })); + } + } else { + return s; + } + } + + Decl::Var(d) => { + if d.decls.is_empty() { + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })); + } + } + _ => {} + } + } + + s + } + + fn fold_named_export(&mut self, mut n: NamedExport) -> NamedExport { + if n.src.is_some() { + n.specifiers = n.specifiers.fold_with(self); + } + + n + } + + fn fold_prop(&mut self, p: Prop) -> Prop { + let p = p.fold_children_with(self); + + if let Prop::Shorthand(i) = &p { + self.add_ref(i.to_id()); + } + + p + } + + fn fold_var_declarator(&mut self, mut v: VarDeclarator) -> VarDeclarator { + let old_in_data = self.in_data_fn; + + if let Pat::Ident(name) = &v.name { + if let Ok(is_data_identifier) = self.state.is_data_identifier(&name.id) { + if is_data_identifier { + self.in_data_fn = true; + } + } else { + return v; + } + } + + let old_in_lhs_of_var = self.in_lhs_of_var; + + self.in_lhs_of_var = true; + v.name = v.name.fold_with(self); + + self.in_lhs_of_var = false; + v.init = v.init.fold_with(self); + + self.in_lhs_of_var = old_in_lhs_of_var; + + self.in_data_fn = old_in_data; + + v + } +} + +/// Actual implementation of the transform. +struct NextSsg { + pub state: State, + in_lhs_of_var: bool, +} + +impl NextSsg { + fn should_remove(&self, id: Id) -> bool { + self.state.refs_from_data_fn.contains(&id) && !self.state.refs_from_other.contains(&id) + } + + /// Mark identifiers in `n` as a candidate for removal. + fn mark_as_candidate(&mut self, n: N) -> N + where + N: for<'aa> FoldWith>, + { + tracing::debug!("mark_as_candidate"); + + // Analyzer never change `in_data_fn` to false, so all identifiers in `n` will + // be marked as referenced from a data function. + let mut v = Analyzer { + state: &mut self.state, + in_lhs_of_var: false, + in_data_fn: true, + }; + + let n = n.fold_with(&mut v); + self.state.should_run_again = true; + n + } +} + +impl Repeated for NextSsg { + fn changed(&self) -> bool { + self.state.should_run_again + } + + fn reset(&mut self) { + self.state.refs_from_other.clear(); + self.state.cur_declaring.clear(); + self.state.should_run_again = false; + } +} + +/// `VisitMut` is faster than [Fold], but we use [Fold] because it's much easier +/// to read. +/// +/// Note: We don't implement `fold_script` because next.js doesn't use it. +impl Fold for NextSsg { + // This is important for reducing binary sizes. + noop_fold_type!(); + + fn fold_import_decl(&mut self, mut i: ImportDecl) -> ImportDecl { + // Imports for side effects. + if i.specifiers.is_empty() { + return i; + } + + let import_src = &i.src.value; + + i.specifiers.retain(|s| match s { + ImportSpecifier::Named(ImportNamedSpecifier { local, .. }) + | ImportSpecifier::Default(ImportDefaultSpecifier { local, .. }) + | ImportSpecifier::Namespace(ImportStarAsSpecifier { local, .. }) => { + if self.should_remove(local.to_id()) { + if self.state.is_server_props + // filter out non-packages import + // third part packages must start with `a-z` or `@` + && import_src.starts_with(|c: char| c.is_ascii_lowercase() || c == '@') + { + self + .state + .eliminated_packages + .borrow_mut() + .insert(import_src.to_string().into()); + } + tracing::trace!( + "Dropping import `{}{:?}` because it should be removed", + local.sym, + local.ctxt + ); + + self.state.should_run_again = true; + false + } else { + true + } + } + }); + + i + } + + fn fold_module(&mut self, mut m: Module) -> Module { + tracing::info!("ssg: Start"); + { + // Fill the state. + let mut v = Analyzer { + state: &mut self.state, + in_lhs_of_var: false, + in_data_fn: false, + }; + m = m.fold_with(&mut v); + } + + // TODO: Use better detection logic + // if !self.state.is_prerenderer && !self.state.is_server_props { + // return m; + // } + + m.fold_children_with(self) + } + + fn fold_module_item(&mut self, i: ModuleItem) -> ModuleItem { + if let ModuleItem::ModuleDecl(ModuleDecl::Import(i)) = i { + let is_for_side_effect = i.specifiers.is_empty(); + let i = i.fold_with(self); + + if !is_for_side_effect && i.specifiers.is_empty() { + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })); + } + + return ModuleItem::ModuleDecl(ModuleDecl::Import(i)); + } + + let i = i.fold_children_with(self); + + match &i { + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(e)) if e.specifiers.is_empty() => { + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })) + } + _ => {} + } + + i + } + + fn fold_module_items(&mut self, mut items: Vec) -> Vec { + items = items.fold_children_with(self); + + // Drop nodes. + items.retain(|s| !matches!(s, ModuleItem::Stmt(Stmt::Empty(..)))); + + if !self.state.done + && !self.state.should_run_again + && (self.state.is_prerenderer || self.state.is_server_props) + { + self.state.done = true; + + if items.iter().any(|s| s.is_module_decl()) { + let mut var = Some(VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident( + IdentName::new( + if self.state.is_prerenderer { + "__N_SSG".into() + } else { + "__N_SSP".into() + }, + DUMMY_SP, + ) + .into(), + ), + init: Some(Box::new(Expr::Lit(Lit::Bool(Bool { + span: DUMMY_SP, + value: true, + })))), + definite: Default::default(), + }); + + let mut new = Vec::with_capacity(items.len() + 1); + for item in take(&mut items) { + if let ModuleItem::ModuleDecl( + ModuleDecl::ExportNamed(..) + | ModuleDecl::ExportDecl(..) + | ModuleDecl::ExportDefaultDecl(..) + | ModuleDecl::ExportDefaultExpr(..), + ) = &item + { + if let Some(var) = var.take() { + new.push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![var], + ..Default::default() + })), + }))) + } + } + + new.push(item); + } + + return new; + } + } + + items + } + + fn fold_named_export(&mut self, mut n: NamedExport) -> NamedExport { + n.specifiers = n.specifiers.fold_with(self); + + n.specifiers.retain(|s| { + let preserve = match s { + ExportSpecifier::Namespace(ExportNamespaceSpecifier { + name: ModuleExportName::Ident(exported), + .. + }) + | ExportSpecifier::Default(ExportDefaultSpecifier { exported, .. }) + | ExportSpecifier::Named(ExportNamedSpecifier { + exported: Some(ModuleExportName::Ident(exported)), + .. + }) => self + .state + .is_data_identifier(exported) + .map(|is_data_identifier| !is_data_identifier), + ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(orig), + .. + }) => self + .state + .is_data_identifier(orig) + .map(|is_data_identifier| !is_data_identifier), + + _ => Ok(true), + }; + + match preserve { + Ok(false) => { + tracing::trace!("Dropping a export specifier because it's a data identifier"); + + if let ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(orig), + .. + }) = s + { + self.state.should_run_again = true; + self.state.refs_from_data_fn.insert(orig.to_id()); + } + + false + } + Ok(true) => true, + Err(_) => false, + } + }); + + n + } + + /// This methods returns [Pat::Invalid] if the pattern should be removed. + fn fold_pat(&mut self, mut p: Pat) -> Pat { + p = p.fold_children_with(self); + + if self.in_lhs_of_var { + match &mut p { + Pat::Ident(name) => { + if self.should_remove(name.id.to_id()) { + self.state.should_run_again = true; + tracing::trace!( + "Dropping var `{}{:?}` because it should be removed", + name.id.sym, + name.id.ctxt + ); + + return Pat::Invalid(Invalid { span: DUMMY_SP }); + } + } + Pat::Array(arr) => { + if !arr.elems.is_empty() { + arr.elems.retain(|e| !matches!(e, Some(Pat::Invalid(..)))); + + if arr.elems.is_empty() { + return Pat::Invalid(Invalid { span: DUMMY_SP }); + } + } + } + Pat::Object(obj) => { + if !obj.props.is_empty() { + obj.props = take(&mut obj.props) + .into_iter() + .filter_map(|prop| match prop { + ObjectPatProp::KeyValue(prop) => { + if prop.value.is_invalid() { + None + } else { + Some(ObjectPatProp::KeyValue(prop)) + } + } + ObjectPatProp::Assign(prop) => { + if self.should_remove(prop.key.to_id()) { + self.mark_as_candidate(prop.value); + + None + } else { + Some(ObjectPatProp::Assign(prop)) + } + } + ObjectPatProp::Rest(prop) => { + if prop.arg.is_invalid() { + None + } else { + Some(ObjectPatProp::Rest(prop)) + } + } + }) + .collect(); + + if obj.props.is_empty() { + return Pat::Invalid(Invalid { span: DUMMY_SP }); + } + } + } + Pat::Rest(rest) => { + if rest.arg.is_invalid() { + return Pat::Invalid(Invalid { span: DUMMY_SP }); + } + } + _ => {} + } + } + + p + } + + #[allow(clippy::single_match)] + fn fold_stmt(&mut self, mut s: Stmt) -> Stmt { + match s { + Stmt::Decl(Decl::Fn(f)) => { + if self.should_remove(f.ident.to_id()) { + self.mark_as_candidate(f.function); + return Stmt::Empty(EmptyStmt { span: DUMMY_SP }); + } + + s = Stmt::Decl(Decl::Fn(f)); + } + _ => {} + } + + let s = s.fold_children_with(self); + match s { + Stmt::Decl(Decl::Var(v)) if v.decls.is_empty() => { + return Stmt::Empty(EmptyStmt { span: DUMMY_SP }); + } + _ => {} + } + + s + } + + /// This method make `name` of [VarDeclarator] to [Pat::Invalid] if it + /// should be removed. + fn fold_var_declarator(&mut self, mut d: VarDeclarator) -> VarDeclarator { + let old = self.in_lhs_of_var; + self.in_lhs_of_var = true; + let name = d.name.fold_with(self); + + self.in_lhs_of_var = false; + if name.is_invalid() { + d.init = self.mark_as_candidate(d.init); + } + let init = d.init.fold_with(self); + self.in_lhs_of_var = old; + + VarDeclarator { name, init, ..d } + } + + fn fold_var_declarators(&mut self, mut decls: Vec) -> Vec { + decls = decls.fold_children_with(self); + decls.retain(|d| !d.name.is_invalid()); + + decls + } +} diff --git a/crates/next-custom-transforms/src/transforms/optimize_barrel.rs b/crates/next-custom-transforms/src/transforms/optimize_barrel.rs new file mode 100644 index 000000000000..156d412ca191 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/optimize_barrel.rs @@ -0,0 +1,360 @@ +use std::collections::HashMap; + +use serde::Deserialize; +use swc_core::{ + common::DUMMY_SP, + ecma::{ + ast::*, + utils::private_ident, + visit::{fold_pass, Fold}, + }, +}; + +#[derive(Clone, Debug, Deserialize)] +pub struct Config { + pub wildcard: bool, +} + +pub fn optimize_barrel(config: Config) -> impl Pass { + fold_pass(OptimizeBarrel { + wildcard: config.wildcard, + }) +} + +#[derive(Debug, Default)] +struct OptimizeBarrel { + wildcard: bool, +} + +impl Fold for OptimizeBarrel { + fn fold_module_items(&mut self, items: Vec) -> Vec { + // One pre-pass to find all the local idents that we are referencing, so we can + // handle the case of `import foo from 'a'; export { foo };` correctly. + + // Map of "local ident" -> ("source module", "orig ident") + let mut local_idents = HashMap::new(); + for item in &items { + if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = item { + for spec in &import_decl.specifiers { + let src = import_decl.src.value.to_string(); + match spec { + ImportSpecifier::Named(s) => { + local_idents.insert( + s.local.sym.to_string(), + ( + src.clone(), + match &s.imported { + Some(n) => match &n { + ModuleExportName::Ident(n) => n.sym.to_string(), + ModuleExportName::Str(n) => n.value.to_string(), + }, + None => s.local.sym.to_string(), + }, + ), + ); + } + ImportSpecifier::Namespace(s) => { + local_idents.insert(s.local.sym.to_string(), (src.clone(), "*".to_string())); + } + ImportSpecifier::Default(s) => { + local_idents.insert( + s.local.sym.to_string(), + (src.clone(), "default".to_string()), + ); + } + } + } + } + } + + // The second pass to rebuild the module items. + let mut new_items = vec![]; + + // Exported meta information. + let mut export_map = vec![]; + let mut export_wildcards = vec![]; + + // We only apply this optimization to barrel files. Here we consider + // a barrel file to be a file that only exports from other modules. + + // Besides that, lit expressions are allowed as well ("use client", etc.). + let mut allowed_directives = true; + let mut directives = vec![]; + + let mut is_barrel = true; + for item in &items { + match item { + ModuleItem::ModuleDecl(decl) => { + allowed_directives = false; + match decl { + ModuleDecl::Import(_) => {} + // export { foo } from './foo'; + ModuleDecl::ExportNamed(export_named) => { + for spec in &export_named.specifiers { + match spec { + ExportSpecifier::Namespace(s) => { + let name_str = match &s.name { + ModuleExportName::Ident(n) => n.sym.to_string(), + ModuleExportName::Str(n) => n.value.to_string(), + }; + if let Some(src) = &export_named.src { + export_map.push((name_str.clone(), src.value.to_string(), "*".to_string())); + } else if self.wildcard { + export_map.push((name_str.clone(), "".into(), "*".to_string())); + } else { + is_barrel = false; + break; + } + } + ExportSpecifier::Named(s) => { + let orig_str = match &s.orig { + ModuleExportName::Ident(n) => n.sym.to_string(), + ModuleExportName::Str(n) => n.value.to_string(), + }; + let name_str = match &s.exported { + Some(n) => match &n { + ModuleExportName::Ident(n) => n.sym.to_string(), + ModuleExportName::Str(n) => n.value.to_string(), + }, + None => orig_str.clone(), + }; + + if let Some(src) = &export_named.src { + export_map.push((name_str.clone(), src.value.to_string(), orig_str.clone())); + } else if let Some((src, orig)) = local_idents.get(&orig_str) { + export_map.push((name_str.clone(), src.clone(), orig.clone())); + } else if self.wildcard { + export_map.push((name_str.clone(), "".into(), orig_str.clone())); + } else { + is_barrel = false; + break; + } + } + _ => { + if !self.wildcard { + is_barrel = false; + break; + } + } + } + } + } + ModuleDecl::ExportAll(export_all) => { + export_wildcards.push(export_all.src.value.to_string()); + } + ModuleDecl::ExportDecl(export_decl) => { + // Export declarations are not allowed in barrel files. + if !self.wildcard { + is_barrel = false; + break; + } + + match &export_decl.decl { + Decl::Class(class) => { + export_map.push((class.ident.sym.to_string(), "".into(), "".into())); + } + Decl::Fn(func) => { + export_map.push((func.ident.sym.to_string(), "".into(), "".into())); + } + Decl::Var(var) => { + let ids = collect_idents_in_var_decls(&var.decls); + for id in ids { + export_map.push((id, "".into(), "".into())); + } + } + _ => {} + } + } + _ => { + if !self.wildcard { + // Other expressions are not allowed in barrel files. + is_barrel = false; + break; + } + } + } + } + ModuleItem::Stmt(stmt) => match stmt { + Stmt::Expr(expr) => match &*expr.expr { + Expr::Lit(l) => { + if let Lit::Str(s) = l { + if allowed_directives && s.value.starts_with("use ") { + directives.push(s.value.to_string()); + } + } else { + allowed_directives = false; + } + } + _ => { + allowed_directives = false; + if !self.wildcard { + is_barrel = false; + break; + } + } + }, + _ => { + allowed_directives = false; + if !self.wildcard { + is_barrel = false; + break; + } + } + }, + } + } + + // If the file is not a barrel file, we export nothing. + if !is_barrel { + new_items = vec![]; + } else { + // Otherwise we export the meta information. + new_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Const, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(BindingIdent { + id: private_ident!("__next_private_export_map__"), + type_ann: None, + }), + init: Some(Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: serde_json::to_string(&export_map).unwrap().into(), + raw: None, + })))), + definite: false, + }], + ..Default::default() + })), + }))); + + // Push "export *" statements for each wildcard export. + for src in export_wildcards { + new_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportAll(ExportAll { + span: DUMMY_SP, + src: Box::new(Str { + span: DUMMY_SP, + value: format!("__barrel_optimize__?names=__PLACEHOLDER__!=!{src}").into(), + raw: None, + }), + with: None, + type_only: false, + }))); + } + + // Push directives. + if !directives.is_empty() { + new_items.push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Const, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(BindingIdent { + id: private_ident!("__next_private_directive_list__"), + type_ann: None, + }), + init: Some(Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: serde_json::to_string(&directives).unwrap().into(), + raw: None, + })))), + definite: false, + }], + ..Default::default() + })), + }))); + } + } + + new_items + } +} + +fn collect_idents_in_array_pat(elems: &[Option]) -> Vec { + let mut ids = Vec::new(); + + for elem in elems.iter().flatten() { + match elem { + Pat::Ident(ident) => { + ids.push(ident.sym.to_string()); + } + Pat::Array(array) => { + ids.extend(collect_idents_in_array_pat(&array.elems)); + } + Pat::Object(object) => { + ids.extend(collect_idents_in_object_pat(&object.props)); + } + Pat::Rest(rest) => { + if let Pat::Ident(ident) = &*rest.arg { + ids.push(ident.sym.to_string()); + } + } + _ => {} + } + } + + ids +} + +fn collect_idents_in_object_pat(props: &[ObjectPatProp]) -> Vec { + let mut ids = Vec::new(); + + for prop in props { + match prop { + ObjectPatProp::KeyValue(KeyValuePatProp { key, value }) => { + if let PropName::Ident(ident) = key { + ids.push(ident.sym.to_string()); + } + + match &**value { + Pat::Ident(ident) => { + ids.push(ident.sym.to_string()); + } + Pat::Array(array) => { + ids.extend(collect_idents_in_array_pat(&array.elems)); + } + Pat::Object(object) => { + ids.extend(collect_idents_in_object_pat(&object.props)); + } + _ => {} + } + } + ObjectPatProp::Assign(AssignPatProp { key, .. }) => { + ids.push(key.to_string()); + } + ObjectPatProp::Rest(RestPat { arg, .. }) => { + if let Pat::Ident(ident) = &**arg { + ids.push(ident.sym.to_string()); + } + } + } + } + + ids +} + +fn collect_idents_in_var_decls(decls: &[VarDeclarator]) -> Vec { + let mut ids = Vec::new(); + + for decl in decls { + match &decl.name { + Pat::Ident(ident) => { + ids.push(ident.sym.to_string()); + } + Pat::Array(array) => { + ids.extend(collect_idents_in_array_pat(&array.elems)); + } + Pat::Object(object) => { + ids.extend(collect_idents_in_object_pat(&object.props)); + } + _ => {} + } + } + + ids +} diff --git a/crates/next-custom-transforms/src/transforms/optimize_server_react.rs b/crates/next-custom-transforms/src/transforms/optimize_server_react.rs new file mode 100644 index 000000000000..7e3f77868253 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/optimize_server_react.rs @@ -0,0 +1,226 @@ +// This transform optimizes React code for the server bundle, in particular: +// - Removes `useEffect` and `useLayoutEffect` calls +// - Refactors `useState` calls (under the `optimize_use_state` flag) + +use serde::Deserialize; +use swc_core::{ + common::DUMMY_SP, + ecma::{ + ast::*, + visit::{fold_pass, Fold, FoldWith}, + }, +}; + +#[derive(Clone, Debug, Deserialize)] +pub struct Config { + pub optimize_use_state: bool, +} + +pub fn optimize_server_react(config: Config) -> impl Pass { + fold_pass(OptimizeServerReact { + optimize_use_state: config.optimize_use_state, + ..Default::default() + }) +} + +#[derive(Debug, Default)] +struct OptimizeServerReact { + optimize_use_state: bool, + react_ident: Option, + use_state_ident: Option, + use_effect_ident: Option, + use_layout_effect_ident: Option, +} + +fn effect_has_side_effect_deps(call: &CallExpr) -> bool { + if call.args.len() != 2 { + return false; + } + + // We can't optimize if the effect has a function call as a dependency: + // useEffect(() => {}, x()) + if let box Expr::Call(_) = &call.args[1].expr { + return true; + } + + // As well as: + // useEffect(() => {}, [x()]) + if let box Expr::Array(arr) = &call.args[1].expr { + for elem in arr.elems.iter().flatten() { + if let ExprOrSpread { + expr: box Expr::Call(_), + .. + } = elem + { + return true; + } + } + } + + false +} + +fn wrap_expr_with_env_prod_condition(call: CallExpr) -> Expr { + // Wrap the call expression with the condition + // turn it into `process.env.__NEXT_PRIVATE_MINIMIZE_MACRO_FALSE && `. + // And `process.env.__NEXT_PRIVATE_MINIMIZE_MACRO_FALSE` will be treated as `false` in + // minification. In this way the expression and dependencies are still available in + // compilation during bundling, but will be removed in the final DEC. + Expr::Bin(BinExpr { + span: DUMMY_SP, + left: Box::new(Expr::Member(MemberExpr { + obj: (Box::new(Expr::Member(MemberExpr { + obj: (Box::new(Expr::Ident(Ident { + sym: "process".into(), + span: DUMMY_SP, + ..Default::default() + }))), + prop: MemberProp::Ident(IdentName { + sym: "env".into(), + span: DUMMY_SP, + }), + span: DUMMY_SP, + }))), + prop: (MemberProp::Ident(IdentName { + sym: "__NEXT_PRIVATE_MINIMIZE_MACRO_FALSE".into(), + span: DUMMY_SP, + })), + span: DUMMY_SP, + })), + op: op!("&&"), + right: Box::new(Expr::Call(call)), + }) +} + +impl Fold for OptimizeServerReact { + fn fold_module_items(&mut self, items: Vec) -> Vec { + let mut new_items = vec![]; + + for item in items { + new_items.push(item.clone().fold_with(self)); + + if let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = &item { + if import_decl.src.value != "react" { + continue; + } + for specifier in &import_decl.specifiers { + if let ImportSpecifier::Named(named_import) = specifier { + let name = match &named_import.imported { + Some(n) => match &n { + ModuleExportName::Ident(n) => n.sym.to_string(), + ModuleExportName::Str(n) => n.value.to_string(), + }, + None => named_import.local.sym.to_string(), + }; + + if name == "useState" { + self.use_state_ident = Some(named_import.local.to_id()); + } else if name == "useEffect" { + self.use_effect_ident = Some(named_import.local.to_id()); + } else if name == "useLayoutEffect" { + self.use_layout_effect_ident = Some(named_import.local.to_id()); + } + } else if let ImportSpecifier::Default(default_import) = specifier { + self.react_ident = Some(default_import.local.to_id()); + } + } + } + } + + new_items + } + + fn fold_expr(&mut self, expr: Expr) -> Expr { + if let Expr::Call(call) = &expr { + if let Callee::Expr(box Expr::Ident(f)) = &call.callee { + // Mark `useEffect` as DCE'able + if let Some(use_effect_ident) = &self.use_effect_ident { + if &f.to_id() == use_effect_ident && !effect_has_side_effect_deps(call) { + // return Expr::Lit(Lit::Null(Null { span: DUMMY_SP })); + return wrap_expr_with_env_prod_condition(call.clone()); + } + } + // Mark `useLayoutEffect` as DCE'able + if let Some(use_layout_effect_ident) = &self.use_layout_effect_ident { + if &f.to_id() == use_layout_effect_ident && !effect_has_side_effect_deps(call) { + return wrap_expr_with_env_prod_condition(call.clone()); + } + } + } else if let Some(react_ident) = &self.react_ident { + if let Callee::Expr(box Expr::Member(member)) = &call.callee { + if let box Expr::Ident(f) = &member.obj { + if &f.to_id() == react_ident { + if let MemberProp::Ident(i) = &member.prop { + // Mark `React.useEffect` and `React.useLayoutEffect` as DCE'able + // calls in production + if i.sym == "useEffect" || i.sym == "useLayoutEffect" { + return wrap_expr_with_env_prod_condition(call.clone()); + } + } + } + } + } + } + } + + expr.fold_children_with(self) + } + + // const [state, setState] = useState(x); + // const [state, setState] = React.useState(x); + fn fold_var_declarator(&mut self, decl: VarDeclarator) -> VarDeclarator { + if !self.optimize_use_state { + return decl; + } + + if let Pat::Array(array_pat) = &decl.name { + if array_pat.elems.len() == 2 { + if let Some(box Expr::Call(call)) = &decl.init { + if let Callee::Expr(box Expr::Ident(f)) = &call.callee { + if let Some(use_state_ident) = &self.use_state_ident { + if &f.to_id() == use_state_ident && call.args.len() == 1 { + // We do the optimization only if the arg is a literal or a + // type that we can + // be sure is not a function (e.g. {} or [] lit). + // This is because useState allows a function as the + // initialiser. + match &call.args[0].expr { + box Expr::Lit(_) | box Expr::Object(_) | box Expr::Array(_) => { + // const [state, setState] = [x, () => {}]; + return VarDeclarator { + definite: false, + name: decl.name.clone(), + init: Some(Box::new(Expr::Array(ArrayLit { + elems: vec![ + Some(call.args[0].expr.clone().into()), + Some( + Expr::Arrow(ArrowExpr { + span: DUMMY_SP, + body: Box::new(BlockStmtOrExpr::Expr(Box::new(Expr::Lit( + Lit::Null(Null { span: DUMMY_SP }), + )))), + is_async: false, + is_generator: false, + params: vec![], + ..Default::default() + }) + .into(), + ), + ], + span: DUMMY_SP, + }))), + span: DUMMY_SP, + }; + } + _ => {} + } + } + } + } + } + } + } + + decl.fold_children_with(self) + } +} diff --git a/crates/next-custom-transforms/src/transforms/page_config.rs b/crates/next-custom-transforms/src/transforms/page_config.rs new file mode 100644 index 000000000000..34f36a9f356b --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/page_config.rs @@ -0,0 +1,163 @@ +use chrono::Utc; +use swc_core::{ + common::{errors::HANDLER, Span, DUMMY_SP}, + ecma::{ + ast::*, + visit::{fold_pass, Fold, FoldWith}, + }, +}; + +pub fn page_config(is_development: bool, is_page_file: bool) -> impl Pass { + fold_pass(PageConfig { + is_development, + is_page_file, + ..Default::default() + }) +} + +pub fn page_config_test() -> impl Pass { + fold_pass(PageConfig { + in_test: true, + is_page_file: true, + ..Default::default() + }) +} + +#[derive(Debug, Default)] +struct PageConfig { + drop_bundle: bool, + in_test: bool, + is_development: bool, + is_page_file: bool, +} + +const STRING_LITERAL_DROP_BUNDLE: &str = "__NEXT_DROP_CLIENT_FILE__"; +const CONFIG_KEY: &str = "config"; + +/// TODO: Implement this as a [Pass] instead of a full visitor ([Fold]) +impl Fold for PageConfig { + fn fold_module_items(&mut self, items: Vec) -> Vec { + let mut new_items = vec![]; + for item in items { + new_items.push(item.fold_with(self)); + if !self.is_development && self.drop_bundle { + let timestamp = match self.in_test { + true => String::from("mock_timestamp"), + false => Utc::now().timestamp().to_string(), + }; + return vec![ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(VarDecl { + decls: vec![VarDeclarator { + name: Pat::Ident(BindingIdent { + id: Ident { + sym: STRING_LITERAL_DROP_BUNDLE.into(), + ..Default::default() + }, + type_ann: None, + }), + init: Some(Box::new(Expr::Lit(Lit::Str(Str { + value: format!("{STRING_LITERAL_DROP_BUNDLE} {timestamp}").into(), + span: DUMMY_SP, + raw: None, + })))), + span: DUMMY_SP, + definite: false, + }], + span: DUMMY_SP, + kind: VarDeclKind::Const, + ..Default::default() + }))))]; + } + } + + new_items + } + + fn fold_export_decl(&mut self, export: ExportDecl) -> ExportDecl { + if let Decl::Var(var_decl) = &export.decl { + for decl in &var_decl.decls { + let mut is_config = false; + if let Pat::Ident(ident) = &decl.name { + if ident.id.sym == CONFIG_KEY { + is_config = true; + } + } + + if is_config { + if let Some(expr) = &decl.init { + if let Expr::Object(obj) = &**expr { + for prop in &obj.props { + if let PropOrSpread::Prop(prop) = prop { + if let Prop::KeyValue(kv) = &**prop { + match &kv.key { + PropName::Ident(ident) => { + if &ident.sym == "amp" { + if let Expr::Lit(Lit::Bool(Bool { value, .. })) = &*kv.value { + if *value && self.is_page_file { + self.drop_bundle = true; + } + } else if let Expr::Lit(Lit::Str(_)) = &*kv.value { + // Do not replace + // bundle + } else { + self.handle_error("Invalid value found.", export.span); + } + } + } + _ => { + self.handle_error("Invalid property found.", export.span); + } + } + } else { + self.handle_error("Invalid property or value.", export.span); + } + } else { + self.handle_error("Property spread is not allowed.", export.span); + } + } + } else { + self.handle_error("Expected config to be an object.", export.span); + } + } else { + self.handle_error("Expected config to be an object.", export.span); + } + } + } + } + export + } + + fn fold_export_named_specifier( + &mut self, + specifier: ExportNamedSpecifier, + ) -> ExportNamedSpecifier { + match &specifier.exported { + Some(ident) => { + if let ModuleExportName::Ident(ident) = ident { + if ident.sym == CONFIG_KEY { + self.handle_error("Config cannot be re-exported.", specifier.span) + } + } + } + None => { + if let ModuleExportName::Ident(ident) = &specifier.orig { + if ident.sym == CONFIG_KEY { + self.handle_error("Config cannot be re-exported.", specifier.span) + } + } + } + } + specifier + } +} + +impl PageConfig { + fn handle_error(&mut self, details: &str, span: Span) { + if self.is_page_file { + let message = format!( + "Invalid page config export found. {details} \ + See: https://nextjs.org/docs/messages/invalid-page-config" + ); + HANDLER.with(|handler| handler.struct_span_err(span, &message).emit()); + } + } +} diff --git a/crates/next-custom-transforms/src/transforms/page_static_info/collect_exported_const_visitor.rs b/crates/next-custom-transforms/src/transforms/page_static_info/collect_exported_const_visitor.rs new file mode 100644 index 000000000000..20188176997c --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/page_static_info/collect_exported_const_visitor.rs @@ -0,0 +1,207 @@ +use std::collections::{HashMap, HashSet}; + +use serde_json::{Map, Number, Value}; +use swc_core::{ + common::{Mark, SyntaxContext}, + ecma::{ + ast::{ + BindingIdent, Decl, ExportDecl, Expr, Lit, ModuleDecl, ModuleItem, Pat, Prop, PropName, + PropOrSpread, VarDecl, VarDeclKind, VarDeclarator, + }, + utils::{ExprCtx, ExprExt}, + visit::{Visit, VisitWith}, + }, +}; + +/// The values extracted for the corresponding AST node. +/// refer extract_expored_const_values for the supported value types. +/// Undefined / null is treated as None. +pub enum Const { + Value(Value), + Unsupported(String), +} + +pub(crate) struct CollectExportedConstVisitor { + pub properties: HashMap>, + expr_ctx: ExprCtx, +} + +impl CollectExportedConstVisitor { + pub fn new(properties_to_extract: HashSet) -> Self { + Self { + properties: properties_to_extract + .into_iter() + .map(|p| (p, None)) + .collect(), + expr_ctx: ExprCtx { + unresolved_ctxt: SyntaxContext::empty().apply_mark(Mark::new()), + is_unresolved_ref_safe: false, + in_strict: false, + remaining_depth: 4, + }, + } + } +} + +impl Visit for CollectExportedConstVisitor { + fn visit_module_items(&mut self, module_items: &[ModuleItem]) { + for module_item in module_items { + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + decl: Decl::Var(decl), + .. + })) = module_item + { + let VarDecl { kind, decls, .. } = &**decl; + if kind == &VarDeclKind::Const { + for decl in decls { + if let VarDeclarator { + name: Pat::Ident(BindingIdent { id, .. }), + init: Some(init), + .. + } = decl + { + let id = id.sym.as_ref(); + if let Some(prop) = self.properties.get_mut(id) { + *prop = extract_value(&self.expr_ctx, init, id.to_string()); + }; + } + } + } + } + } + + module_items.visit_children_with(self); + } +} + +/// Coerece the actual value of the given ast node. +fn extract_value(ctx: &ExprCtx, init: &Expr, id: String) -> Option { + match init { + init if init.is_undefined(*ctx) => Some(Const::Value(Value::Null)), + Expr::Ident(ident) => Some(Const::Unsupported(format!( + "Unknown identifier \"{}\" at \"{}\".", + ident.sym, id + ))), + Expr::Lit(lit) => match lit { + Lit::Num(num) => Some(Const::Value(Value::Number( + Number::from_f64(num.value).expect("Should able to convert f64 to Number"), + ))), + Lit::Null(_) => Some(Const::Value(Value::Null)), + Lit::Str(s) => Some(Const::Value(Value::String(s.value.to_string()))), + Lit::Bool(b) => Some(Const::Value(Value::Bool(b.value))), + Lit::Regex(r) => Some(Const::Value(Value::String(format!( + "/{}/{}", + r.exp, r.flags + )))), + _ => Some(Const::Unsupported("Unsupported Literal".to_string())), + }, + Expr::Array(arr) => { + let mut a = vec![]; + + for elem in &arr.elems { + match elem { + Some(elem) => { + if elem.spread.is_some() { + return Some(Const::Unsupported(format!( + "Unsupported spread operator in the Array Expression at \"{id}\"" + ))); + } + + match extract_value(ctx, &elem.expr, id.clone()) { + Some(Const::Value(value)) => a.push(value), + Some(Const::Unsupported(message)) => { + return Some(Const::Unsupported(format!( + "Unsupported value in the Array Expression: {message}" + ))) + } + _ => { + return Some(Const::Unsupported( + "Unsupported value in the Array Expression".to_string(), + )) + } + } + } + None => { + a.push(Value::Null); + } + } + } + + Some(Const::Value(Value::Array(a))) + } + Expr::Object(obj) => { + let mut o = Map::new(); + + for prop in &obj.props { + let (key, value) = match prop { + PropOrSpread::Prop(box Prop::KeyValue(kv)) => ( + match &kv.key { + PropName::Ident(i) => i.sym.as_ref(), + PropName::Str(s) => s.value.as_ref(), + _ => { + return Some(Const::Unsupported(format!( + "Unsupported key type in the Object Expression at \"{id}\"" + ))) + } + }, + &kv.value, + ), + _ => { + return Some(Const::Unsupported(format!( + "Unsupported spread operator in the Object Expression at \"{id}\"" + ))) + } + }; + let new_value = extract_value(ctx, value, format!("{id}.{key}")); + if let Some(Const::Unsupported(msg)) = new_value { + return Some(Const::Unsupported(msg)); + } + + if let Some(Const::Value(value)) = new_value { + o.insert(key.to_string(), value); + } + } + + Some(Const::Value(Value::Object(o))) + } + Expr::Tpl(tpl) => { + // [TODO] should we add support for `${'e'}d${'g'}'e'`? + if !tpl.exprs.is_empty() { + Some(Const::Unsupported(format!( + "Unsupported template literal with expressions at \"{id}\"." + ))) + } else { + Some( + tpl + .quasis + .first() + .map(|q| { + // When TemplateLiteral has 0 expressions, the length of quasis is + // always 1. Because when parsing + // TemplateLiteral, the parser yields the first quasi, + // then the first expression, then the next quasi, then the next + // expression, etc., until the last quasi. + // Thus if there is no expression, the parser ends at the frst and also + // last quasis + // + // A "cooked" interpretation where backslashes have special meaning, + // while a "raw" interpretation where + // backslashes do not have special meaning https://exploringjs.com/impatient-js/ch_template-literals.html#template-strings-cooked-vs-raw + let cooked = q.cooked.as_ref(); + let raw = q.raw.as_ref(); + + Const::Value(Value::String( + cooked.map(|c| c.to_string()).unwrap_or(raw.to_string()), + )) + }) + .unwrap_or(Const::Unsupported(format!( + "Unsupported node type at \"{id}\"" + ))), + ) + } + } + _ => Some(Const::Unsupported(format!( + "Unsupported node type at \"{id}\"" + ))), + } +} diff --git a/crates/next-custom-transforms/src/transforms/page_static_info/collect_exports_visitor.rs b/crates/next-custom-transforms/src/transforms/page_static_info/collect_exports_visitor.rs new file mode 100644 index 000000000000..7e76df4705c0 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/page_static_info/collect_exports_visitor.rs @@ -0,0 +1,182 @@ +use std::{collections::HashSet, sync::LazyLock}; + +use swc_core::ecma::{ + ast::{ + Decl, ExportDecl, ExportNamedSpecifier, ExportSpecifier, Expr, ExprOrSpread, ExprStmt, Lit, + ModuleExportName, ModuleItem, NamedExport, Pat, Stmt, Str, VarDeclarator, + }, + visit::{Visit, VisitWith}, +}; + +use super::{ExportInfo, ExportInfoWarning}; + +static EXPORTS_SET: LazyLock> = LazyLock::new(|| { + HashSet::from([ + "getStaticProps", + "getServerSideProps", + "generateImageMetadata", + "generateSitemaps", + "generateStaticParams", + ]) +}); + +pub(crate) struct CollectExportsVisitor { + pub export_info: Option, +} + +impl CollectExportsVisitor { + pub fn new() -> Self { + Self { + export_info: Default::default(), + } + } +} + +impl Visit for CollectExportsVisitor { + fn visit_module_items(&mut self, stmts: &[swc_core::ecma::ast::ModuleItem]) { + let mut is_directive = true; + + for stmt in stmts { + if let ModuleItem::Stmt(Stmt::Expr(ExprStmt { + expr: box Expr::Lit(Lit::Str(Str { value, .. })), + .. + })) = stmt + { + if is_directive { + if value == "use server" { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.directives.insert("server".to_string()); + } + if value == "use client" { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.directives.insert("client".to_string()); + } + } + } else { + is_directive = false; + } + + stmt.visit_children_with(self); + } + } + + fn visit_export_decl(&mut self, export_decl: &ExportDecl) { + match &export_decl.decl { + Decl::Var(box var_decl) => { + if let Some(VarDeclarator { + name: Pat::Ident(name), + .. + }) = var_decl.decls.first() + { + if EXPORTS_SET.contains(&name.sym.as_str()) { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.ssg = name.sym == "getStaticProps"; + export_info.ssr = name.sym == "getServerSideProps"; + export_info.generate_image_metadata = Some(name.sym == "generateImageMetadata"); + export_info.generate_sitemaps = Some(name.sym == "generateSitemaps"); + export_info.generate_static_params = name.sym == "generateStaticParams"; + } + } + + for decl in &var_decl.decls { + if let Pat::Ident(id) = &decl.name { + if id.sym == "runtime" { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.runtime = decl.init.as_ref().and_then(|init| { + if let Expr::Lit(Lit::Str(Str { value, .. })) = &**init { + Some(value.to_string()) + } else { + None + } + }) + } else if id.sym == "preferredRegion" { + if let Some(init) = &decl.init { + if let Expr::Array(arr) = &**init { + for expr in arr.elems.iter().flatten() { + if let ExprOrSpread { + expr: box Expr::Lit(Lit::Str(Str { value, .. })), + .. + } = expr + { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.preferred_region.push(value.to_string()); + } + } + } else if let Expr::Lit(Lit::Str(Str { value, .. })) = &**init { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.preferred_region.push(value.to_string()); + } + } + } else { + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.extra_properties.insert(id.sym.to_string()); + } + } + } + } + Decl::Fn(fn_decl) => { + let id = &fn_decl.ident; + + let export_info = self.export_info.get_or_insert(Default::default()); + export_info.ssg = id.sym == "getStaticProps"; + export_info.ssr = id.sym == "getServerSideProps"; + export_info.generate_image_metadata = Some(id.sym == "generateImageMetadata"); + export_info.generate_sitemaps = Some(id.sym == "generateSitemaps"); + export_info.generate_static_params = id.sym == "generateStaticParams"; + } + _ => {} + } + + export_decl.visit_children_with(self); + } + + fn visit_named_export(&mut self, named_export: &NamedExport) { + for specifier in &named_export.specifiers { + if let ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(value), + .. + }) = specifier + { + let export_info = self.export_info.get_or_insert(Default::default()); + + if !export_info.ssg && value.sym == "getStaticProps" { + export_info.ssg = true; + } + + if !export_info.ssr && value.sym == "getServerSideProps" { + export_info.ssr = true; + } + + if !export_info.generate_image_metadata.unwrap_or_default() + && value.sym == "generateImageMetadata" + { + export_info.generate_image_metadata = Some(true); + } + + if !export_info.generate_sitemaps.unwrap_or_default() && value.sym == "generateSitemaps" { + export_info.generate_sitemaps = Some(true); + } + + if !export_info.generate_static_params && value.sym == "generateStaticParams" { + export_info.generate_static_params = true; + } + + if export_info.runtime.is_none() && value.sym == "runtime" { + export_info.warnings.push(ExportInfoWarning::new( + value.sym.to_string(), + "it was not assigned to a string literal".to_string(), + )); + } + + if export_info.preferred_region.is_empty() && value.sym == "preferredRegion" { + export_info.warnings.push(ExportInfoWarning::new( + value.sym.to_string(), + "it was not assigned to a string literal or an array of string literals".to_string(), + )); + } + } + } + + named_export.visit_children_with(self); + } +} diff --git a/crates/next-custom-transforms/src/transforms/page_static_info/mod.rs b/crates/next-custom-transforms/src/transforms/page_static_info/mod.rs new file mode 100644 index 000000000000..ae3d51924637 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/page_static_info/mod.rs @@ -0,0 +1,377 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::Result; +pub use collect_exported_const_visitor::Const; +use collect_exports_visitor::CollectExportsVisitor; +use once_cell::sync::Lazy; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use swc_core::{ + base::SwcComments, + common::GLOBALS, + ecma::{ast::Program, visit::VisitWith}, +}; + +pub mod collect_exported_const_visitor; +pub mod collect_exports_visitor; + +#[derive(Debug, Default)] +pub struct MiddlewareConfig {} + +#[derive(Debug)] +pub enum Amp { + Boolean(bool), + Hybrid, +} + +#[derive(Debug, Default)] +pub struct PageStaticInfo { + // [TODO] next-core have NextRuntime type, but the order of dependency won't allow to import + // Since this value is being passed into JS context anyway, we can just use string for now. + pub runtime: Option, // 'nodejs' | 'experimental-edge' | 'edge' + pub preferred_region: Vec, + pub ssg: Option, + pub ssr: Option, + pub rsc: Option, // 'server' | 'client' + pub generate_static_params: Option, + pub middleware: Option, + pub amp: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportInfoWarning { + pub key: String, + pub message: String, +} + +impl ExportInfoWarning { + pub fn new(key: String, message: String) -> Self { + Self { key, message } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportInfo { + pub ssr: bool, + pub ssg: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub preferred_region: Vec, + pub generate_image_metadata: Option, + pub generate_sitemaps: Option, + pub generate_static_params: bool, + pub extra_properties: HashSet, + pub directives: HashSet, + /// extra properties to bubble up warning messages from visitor, + /// since this isn't a failure to abort the process. + pub warnings: Vec, +} + +/// Collects static page export information for the next.js from given source's +/// AST. This is being used for some places like detecting page +/// is a dynamic route or not, or building a PageStaticInfo object. +pub fn collect_exports(program: &Program) -> Result> { + let mut collect_export_visitor = CollectExportsVisitor::new(); + program.visit_with(&mut collect_export_visitor); + + Ok(collect_export_visitor.export_info) +} + +static CLIENT_MODULE_LABEL: Lazy = Lazy::new(|| { + Regex::new(" __next_internal_client_entry_do_not_use__ ([^ ]*) (cjs|auto) ").unwrap() +}); +static ACTION_MODULE_LABEL: Lazy = + Lazy::new(|| Regex::new(r#" __next_internal_action_entry_do_not_use__ (\{[^}]+\}) "#).unwrap()); + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RscModuleInfo { + #[serde(rename = "type")] + pub module_type: String, + pub actions: Option>, + pub is_client_ref: bool, + pub client_refs: Option>, + pub client_entry_type: Option, +} + +impl RscModuleInfo { + pub fn new(module_type: String) -> Self { + Self { + module_type, + actions: None, + is_client_ref: false, + client_refs: None, + client_entry_type: None, + } + } +} + +/// Parse comments from the given source code and collect the RSC module info. +/// This doesn't use visitor, only read comments to parse necessary information. +pub fn collect_rsc_module_info( + comments: &SwcComments, + is_react_server_layer: bool, +) -> RscModuleInfo { + let mut captured = None; + + for comment in comments.leading.iter() { + let parsed = comment.iter().find_map(|c| { + let actions_json = ACTION_MODULE_LABEL.captures(&c.text); + let client_info_match = CLIENT_MODULE_LABEL.captures(&c.text); + + if actions_json.is_none() && client_info_match.is_none() { + return None; + } + + let actions = if let Some(actions_json) = actions_json { + if let Ok(serde_json::Value::Object(map)) = + serde_json::from_str::(&actions_json[1]) + { + Some( + map + .iter() + // values for the action json should be a string + .map(|(_, v)| v.as_str().unwrap_or_default().to_string()) + .collect::>(), + ) + } else { + None + } + } else { + None + }; + + let is_client_ref = client_info_match.is_some(); + let client_info = client_info_match.map(|client_info_match| { + ( + client_info_match[1] + .split(',') + .map(|s| s.to_string()) + .collect::>(), + client_info_match[2].to_string(), + ) + }); + + Some((actions, is_client_ref, client_info)) + }); + + if captured.is_none() { + captured = parsed; + break; + } + } + + match captured { + Some((actions, is_client_ref, client_info)) => { + if !is_react_server_layer { + let mut module_info = RscModuleInfo::new("client".to_string()); + module_info.actions = actions; + module_info.is_client_ref = is_client_ref; + module_info + } else { + let mut module_info = RscModuleInfo::new(if client_info.is_some() { + "client".to_string() + } else { + "server".to_string() + }); + module_info.actions = actions; + module_info.is_client_ref = is_client_ref; + if let Some((client_refs, client_entry_type)) = client_info { + module_info.client_refs = Some(client_refs); + module_info.client_entry_type = Some(client_entry_type); + } + + module_info + } + } + None => RscModuleInfo::new(if !is_react_server_layer { + "client".to_string() + } else { + "server".to_string() + }), + } +} + +/// Extracts the value of an exported const variable named `exportedName` +/// (e.g. "export const config = { runtime: 'edge' }") from swc's AST. +/// The value must be one of +/// - string +/// - boolean +/// - number +/// - null +/// - undefined +/// - array containing values listed in this list +/// - object containing values listed in this list +/// +/// Returns a map of the extracted values, or either contains corresponding +/// error. +pub fn extract_exported_const_values( + source_ast: &Program, + properties_to_extract: HashSet, +) -> HashMap> { + GLOBALS.set(&Default::default(), || { + let mut visitor = + collect_exported_const_visitor::CollectExportedConstVisitor::new(properties_to_extract); + + source_ast.visit_with(&mut visitor); + + visitor.properties + }) +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, sync::Arc}; + + use anyhow::Result; + use swc_core::{ + base::{ + config::{IsModule, ParseOptions}, + try_with_handler, Compiler, HandlerOpts, SwcComments, + }, + common::{errors::ColorConfig, FilePathMapping, SourceMap, GLOBALS}, + ecma::{ + ast::Program, + parser::{EsSyntax, Syntax, TsSyntax}, + }, + }; + + use super::{collect_rsc_module_info, RscModuleInfo}; + + fn build_ast_from_source(contents: &str, file_path: &str) -> Result<(Program, SwcComments)> { + GLOBALS.set(&Default::default(), || { + let c = Compiler::new(Arc::new(SourceMap::new(FilePathMapping::empty()))); + + let options = ParseOptions { + is_module: IsModule::Unknown, + syntax: if file_path.ends_with(".ts") || file_path.ends_with(".tsx") { + Syntax::Typescript(TsSyntax { + tsx: true, + decorators: true, + ..Default::default() + }) + } else { + Syntax::Es(EsSyntax { + jsx: true, + decorators: true, + ..Default::default() + }) + }, + ..Default::default() + }; + + let fm = c.cm.new_source_file( + swc_core::common::FileName::Real(PathBuf::from(file_path.to_string())).into(), + contents.to_string(), + ); + + let comments = c.comments().clone(); + + try_with_handler( + c.cm.clone(), + HandlerOpts { + color: ColorConfig::Never, + skip_filename: false, + }, + |handler| { + c.parse_js( + fm, + handler, + options.target, + options.syntax, + options.is_module, + Some(&comments), + ) + }, + ) + .map(|p| (p, comments)) + }) + } + + #[test] + fn should_parse_server_info() { + let input = r#"export default function Page() { + return

app-edge-ssr

+ } + + export const runtime = 'edge' + export const maxDuration = 4 + "#; + + let (_, comments) = build_ast_from_source(input, "some-file.js") + .expect("Should able to parse test fixture input"); + + let module_info = collect_rsc_module_info(&comments, true); + let expected = RscModuleInfo { + module_type: "server".to_string(), + actions: None, + is_client_ref: false, + client_refs: None, + client_entry_type: None, + }; + + assert_eq!(module_info, expected); + } + + #[test] + fn should_parse_actions_json() { + let input = r#" + /* __next_internal_action_entry_do_not_use__ {"ab21efdafbe611287bc25c0462b1e0510d13e48b":"foo"} */ import { createActionProxy } from "private-next-rsc-action-proxy"; + import { encryptActionBoundArgs, decryptActionBoundArgs } from "private-next-rsc-action-encryption"; + export function foo() {} + import { ensureServerEntryExports } from "private-next-rsc-action-validate"; + ensureServerEntryExports([ + foo + ]); + createActionProxy("ab21efdafbe611287bc25c0462b1e0510d13e48b", foo); + "#; + + let (_, comments) = build_ast_from_source(input, "some-file.js") + .expect("Should able to parse test fixture input"); + + let module_info = collect_rsc_module_info(&comments, true); + let expected = RscModuleInfo { + module_type: "server".to_string(), + actions: Some(vec!["foo".to_string()]), + is_client_ref: false, + client_refs: None, + client_entry_type: None, + }; + + assert_eq!(module_info, expected); + } + + #[test] + fn should_parse_client_refs() { + let input = r#" + // This is a comment. + /* __next_internal_client_entry_do_not_use__ default,a,b,c,*,f auto */ const { createProxy } = require("private-next-rsc-mod-ref-proxy"); + module.exports = createProxy("/some-project/src/some-file.js"); + "#; + + let (_, comments) = build_ast_from_source(input, "some-file.js") + .expect("Should able to parse test fixture input"); + + let module_info = collect_rsc_module_info(&comments, true); + + let expected = RscModuleInfo { + module_type: "client".to_string(), + actions: None, + is_client_ref: true, + client_refs: Some(vec![ + "default".to_string(), + "a".to_string(), + "b".to_string(), + "c".to_string(), + "*".to_string(), + "f".to_string(), + ]), + client_entry_type: Some("auto".to_string()), + }; + + assert_eq!(module_info, expected); + } +} diff --git a/crates/next-custom-transforms/src/transforms/pure.rs b/crates/next-custom-transforms/src/transforms/pure.rs new file mode 100644 index 000000000000..ca1e837125b0 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/pure.rs @@ -0,0 +1,88 @@ +use swc_core::{ + common::{comments::Comments, errors::HANDLER, util::take::Take, Span, Spanned, DUMMY_SP}, + ecma::{ + ast::{CallExpr, Callee, EmptyStmt, Expr, Module, ModuleDecl, ModuleItem, Stmt}, + visit::{noop_visit_mut_type, VisitMut, VisitMutWith}, + }, +}; + +use crate::transforms::import_analyzer::ImportMap; + +pub fn pure_magic(comments: C) -> PureTransform +where + C: Comments, +{ + PureTransform { + imports: Default::default(), + comments, + } +} + +pub struct PureTransform +where + C: Comments, +{ + imports: ImportMap, + comments: C, +} + +const MODULE: &str = "next/dist/build/swc/helpers"; +const FN_NAME: &str = "__nextjs_pure"; + +impl VisitMut for PureTransform +where + C: Comments, +{ + fn visit_mut_expr(&mut self, e: &mut Expr) { + e.visit_mut_children_with(self); + + if let Expr::Call(CallExpr { + span, + callee: Callee::Expr(callee), + args, + .. + }) = e + { + if !self.imports.is_import(callee, MODULE, FN_NAME) { + return; + } + + if args.len() != 1 { + HANDLER.with(|handler| { + handler + .struct_span_err(*span, "markAsPure() does not support multiple arguments") + .emit(); + }); + return; + } + + *e = *args[0].expr.take(); + + let mut lo = e.span().lo; + if lo.is_dummy() { + lo = Span::dummy_with_cmt().lo; + } + + self.comments.add_pure_comment(lo); + } + } + + fn visit_mut_module(&mut self, m: &mut Module) { + self.imports = ImportMap::analyze(m); + + m.visit_mut_children_with(self); + } + + fn visit_mut_module_item(&mut self, m: &mut ModuleItem) { + if let ModuleItem::ModuleDecl(ModuleDecl::Import(import)) = m { + if import.src.value == MODULE { + *m = ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })); + return; + } + } + + m.visit_mut_children_with(self); + } + + noop_visit_mut_type!(); +} diff --git a/crates/next-custom-transforms/src/transforms/react_server_components.rs b/crates/next-custom-transforms/src/transforms/react_server_components.rs new file mode 100644 index 000000000000..70911365fde2 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/react_server_components.rs @@ -0,0 +1,1016 @@ +use std::{collections::HashMap, path::PathBuf, rc::Rc, sync::Arc}; + +use once_cell::sync::Lazy; +use regex::Regex; +use serde::Deserialize; +use swc_core::{ + common::{ + comments::{Comment, CommentKind, Comments}, + errors::HANDLER, + util::take::Take, + FileName, Span, Spanned, DUMMY_SP, + }, + ecma::{ + ast::*, + atoms::{js_word, JsWord}, + utils::{prepend_stmts, quote_ident, quote_str, ExprFactory}, + visit::{ + noop_visit_mut_type, noop_visit_type, visit_mut_pass, Visit, VisitMut, VisitMutWith, + VisitWith, + }, + }, +}; + +use super::{cjs_finder::contains_cjs, import_analyzer::ImportMap}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum Config { + All(bool), + WithOptions(Options), +} + +impl Config { + pub fn truthy(&self) -> bool { + match self { + Config::All(b) => *b, + Config::WithOptions(_) => true, + } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Options { + pub is_react_server_layer: bool, + pub dynamic_io_enabled: bool, +} + +/// A visitor that transforms given module to use module proxy if it's a React +/// server component. +/// **NOTE** Turbopack uses ClientDirectiveTransformer for the +/// same purpose, so does not run this transform. +struct ReactServerComponents { + is_react_server_layer: bool, + dynamic_io_enabled: bool, + filepath: String, + app_dir: Option, + comments: C, + directive_import_collection: Option<(bool, bool, RcVec, RcVec)>, +} + +#[derive(Clone, Debug)] +struct ModuleImports { + source: (JsWord, Span), + specifiers: Vec<(JsWord, Span)>, +} + +enum RSCErrorKind { + /// When `use client` and `use server` are in the same file. + /// It's not possible to have both directives in the same file. + RedundantDirectives(Span), + NextRscErrServerImport((String, Span)), + NextRscErrClientImport((String, Span)), + NextRscErrClientDirective(Span), + NextRscErrReactApi((String, Span)), + NextRscErrErrorFileServerComponent(Span), + NextRscErrClientMetadataExport((String, Span)), + NextRscErrConflictMetadataExport(Span), + NextRscErrInvalidApi((String, Span)), + NextRscErrDeprecatedApi((String, String, Span)), + NextSsrDynamicFalseNotAllowed(Span), + NextRscErrIncompatibleDynamicIoSegment(Span, String), +} + +enum InvalidExportKind { + General, + DynamicIoSegment, +} + +impl VisitMut for ReactServerComponents { + noop_visit_mut_type!(); + + fn visit_mut_module(&mut self, module: &mut Module) { + // Run the validator first to assert, collect directives and imports. + let mut validator = ReactServerComponentValidator::new( + self.is_react_server_layer, + self.dynamic_io_enabled, + self.filepath.clone(), + self.app_dir.clone(), + ); + + module.visit_with(&mut validator); + self.directive_import_collection = validator.directive_import_collection; + + let is_client_entry = self + .directive_import_collection + .as_ref() + .expect("directive_import_collection must be set") + .0; + + self.remove_top_level_directive(module); + + let is_cjs = contains_cjs(module); + + if self.is_react_server_layer { + if is_client_entry { + self.to_module_ref(module, is_cjs); + return; + } + } else if is_client_entry { + self.prepend_comment_node(module, is_cjs); + } + module.visit_mut_children_with(self) + } +} + +impl ReactServerComponents { + /// removes specific directive from the AST. + fn remove_top_level_directive(&mut self, module: &mut Module) { + let _ = &module.body.retain(|item| { + if let ModuleItem::Stmt(stmt) = item { + if let Some(expr_stmt) = stmt.as_expr() { + if let Expr::Lit(Lit::Str(Str { value, .. })) = &*expr_stmt.expr { + if &**value == "use client" { + // Remove the directive. + return false; + } + } + } + } + true + }); + } + + // Convert the client module to the module reference code and add a special + // comment to the top of the file. + fn to_module_ref(&self, module: &mut Module, is_cjs: bool) { + // Clear all the statements and module declarations. + module.body.clear(); + + let proxy_ident = quote_ident!("createProxy"); + let filepath = quote_str!(&*self.filepath); + + prepend_stmts( + &mut module.body, + vec![ + ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Const, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Object(ObjectPat { + span: DUMMY_SP, + props: vec![ObjectPatProp::Assign(AssignPatProp { + span: DUMMY_SP, + key: proxy_ident.into(), + value: None, + })], + optional: false, + type_ann: None, + }), + init: Some(Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("require").as_callee(), + args: vec![quote_str!("private-next-rsc-mod-ref-proxy").as_arg()], + ..Default::default() + }))), + definite: false, + }], + ..Default::default() + })))), + ModuleItem::Stmt(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Assign(AssignExpr { + span: DUMMY_SP, + left: MemberExpr { + span: DUMMY_SP, + obj: Box::new(Expr::Ident(quote_ident!("module").into())), + prop: MemberProp::Ident(quote_ident!("exports")), + } + .into(), + op: op!("="), + right: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("createProxy").as_callee(), + args: vec![filepath.as_arg()], + ..Default::default() + })), + })), + })), + ] + .into_iter(), + ); + + self.prepend_comment_node(module, is_cjs); + } + + fn prepend_comment_node(&self, module: &Module, is_cjs: bool) { + let export_names = &self + .directive_import_collection + .as_ref() + .expect("directive_import_collection must be set") + .3; + + // Prepend a special comment to the top of the file that contains + // module export names and the detected module type. + self.comments.add_leading( + module.span.lo, + Comment { + span: DUMMY_SP, + kind: CommentKind::Block, + text: format!( + " __next_internal_client_entry_do_not_use__ {} {} ", + export_names.join(","), + if is_cjs { "cjs" } else { "auto" } + ) + .into(), + }, + ); + } +} + +/// Consolidated place to parse, generate error messages for the RSC parsing +/// errors. +fn report_error(app_dir: &Option, filepath: &str, error_kind: RSCErrorKind) { + let (msg, span) = match error_kind { + RSCErrorKind::RedundantDirectives(span) => ( + "It's not possible to have both `use client` and `use server` directives in the \ + same file." + .to_string(), + span, + ), + RSCErrorKind::NextRscErrClientDirective(span) => ( + "The \"use client\" directive must be placed before other expressions. Move it to \ + the top of the file to resolve this issue." + .to_string(), + span, + ), + RSCErrorKind::NextRscErrServerImport((source, span)) => { + let msg = match source.as_str() { + // If importing "react-dom/server", we should show a different error. + "react-dom/server" => "You're importing a component that imports react-dom/server. To fix it, render or return the content directly as a Server Component instead for perf and security.\nLearn more: https://nextjs.org/docs/app/building-your-application/rendering".to_string(), + // If importing "next/router", we should tell them to use "next/navigation". + "next/router" => r#"You have a Server Component that imports next/router. Use next/navigation instead.\nLearn more: https://nextjs.org/docs/app/api-reference/functions/use-router"#.to_string(), + _ => format!(r#"You're importing a component that imports {source}. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.\nLearn more: https://nextjs.org/docs/app/building-your-application/rendering\n\n"#) + }; + + (msg, span) + } + RSCErrorKind::NextRscErrClientImport((source, span)) => { + let is_app_dir = app_dir + .as_ref() + .map(|app_dir| { + if let Some(app_dir) = app_dir.as_os_str().to_str() { + filepath.starts_with(app_dir) + } else { + false + } + }) + .unwrap_or_default(); + + let msg = if !is_app_dir { + format!("You're importing a component that needs \"{source}\". That only works in a Server Component which is not supported in the pages/ directory. Read more: https://nextjs.org/docs/app/building-your-application/rendering/server-components\n\n") + } else { + format!("You're importing a component that needs \"{source}\". That only works in a Server Component but one of its parents is marked with \"use client\", so it's a Client Component.\nLearn more: https://nextjs.org/docs/app/building-your-application/rendering\n\n") + }; + (msg, span) + } + RSCErrorKind::NextRscErrReactApi((source, span)) => { + let msg = if source == "Component" { + "You’re importing a class component. It only works in a Client Component but none of its parents are marked with \"use client\", so they're Server Components by default.\nLearn more: https://nextjs.org/docs/app/building-your-application/rendering/client-components\n\n".to_string() + } else { + format!("You're importing a component that needs `{source}`. This React hook only works in a client component. To fix, mark the file (or its parent) with the `\"use client\"` directive.\n\n Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client\n\n") + }; + + (msg,span) + }, + RSCErrorKind::NextRscErrErrorFileServerComponent(span) => { + ( + format!("{filepath} must be a Client Component. Add the \"use client\" directive the top of the file to resolve this issue.\nLearn more: https://nextjs.org/docs/app/api-reference/directives/use-client\n\n"), + span + ) + }, + RSCErrorKind::NextRscErrClientMetadataExport((source, span)) => { + (format!("You are attempting to export \"{source}\" from a component marked with \"use client\", which is disallowed. Either remove the export, or the \"use client\" directive. Read more: https://nextjs.org/docs/app/api-reference/directives/use-client\n\n"), span) + }, + RSCErrorKind::NextRscErrConflictMetadataExport(span) => ( + "\"metadata\" and \"generateMetadata\" cannot be exported at the same time, please keep one of them. Read more: https://nextjs.org/docs/app/api-reference/file-conventions/metadata\n\n".to_string(), + span + ), + //NEXT_RSC_ERR_INVALID_API + RSCErrorKind::NextRscErrInvalidApi((source, span)) => ( + format!("\"{source}\" is not supported in app/. Read more: https://nextjs.org/docs/app/building-your-application/data-fetching\n\n"), span + ), + RSCErrorKind::NextRscErrDeprecatedApi((source, item, span)) => match (&*source, &*item) { + ("next/server", "ImageResponse") => ( + "ImageResponse moved from \"next/server\" to \"next/og\" since Next.js 14, please \ + import from \"next/og\" instead" + .to_string(), + span, + ), + _ => (format!("\"{source}\" is deprecated."), span), + }, + RSCErrorKind::NextSsrDynamicFalseNotAllowed(span) => ( + "`ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a client component." + .to_string(), + span, + ), + RSCErrorKind::NextRscErrIncompatibleDynamicIoSegment(span, segment) => ( + format!("\"{}\" is not compatible with `nextConfig.experimental.dynamicIO`. Please remove it.", segment), + span, + ), + }; + + HANDLER.with(|handler| handler.struct_span_err(span, msg.as_str()).emit()) +} + +/// Collects top level directives and imports +fn collect_top_level_directives_and_imports( + app_dir: &Option, + filepath: &str, + module: &Module, +) -> (bool, bool, Vec, Vec) { + let mut imports: Vec = vec![]; + let mut finished_directives = false; + let mut is_client_entry = false; + let mut is_action_file = false; + + let mut export_names = vec![]; + + let _ = &module.body.iter().for_each(|item| { + match item { + ModuleItem::Stmt(stmt) => { + if !stmt.is_expr() { + // Not an expression. + finished_directives = true; + } + + match stmt.as_expr() { + Some(expr_stmt) => { + match &*expr_stmt.expr { + Expr::Lit(Lit::Str(Str { value, .. })) => { + if &**value == "use client" { + if !finished_directives { + is_client_entry = true; + + if is_action_file { + report_error( + app_dir, + filepath, + RSCErrorKind::RedundantDirectives(expr_stmt.span), + ); + } + } else { + report_error( + app_dir, + filepath, + RSCErrorKind::NextRscErrClientDirective(expr_stmt.span), + ); + } + } else if &**value == "use server" && !finished_directives { + is_action_file = true; + + if is_client_entry { + report_error( + app_dir, + filepath, + RSCErrorKind::RedundantDirectives(expr_stmt.span), + ); + } + } + } + // Match `ParenthesisExpression` which is some formatting tools + // usually do: ('use client'). In these case we need to throw + // an exception because they are not valid directives. + Expr::Paren(ParenExpr { expr, .. }) => { + finished_directives = true; + if let Expr::Lit(Lit::Str(Str { value, .. })) = &**expr { + if &**value == "use client" { + report_error( + app_dir, + filepath, + RSCErrorKind::NextRscErrClientDirective(expr_stmt.span), + ); + } + } + } + _ => { + // Other expression types. + finished_directives = true; + } + } + } + None => { + // Not an expression. + finished_directives = true; + } + } + } + ModuleItem::ModuleDecl(ModuleDecl::Import( + import @ ImportDecl { + type_only: false, .. + }, + )) => { + let source = import.src.value.clone(); + let specifiers = import + .specifiers + .iter() + .filter(|specifier| { + !matches!( + specifier, + ImportSpecifier::Named(ImportNamedSpecifier { + is_type_only: true, + .. + }) + ) + }) + .map(|specifier| match specifier { + ImportSpecifier::Named(named) => match &named.imported { + Some(imported) => match &imported { + ModuleExportName::Ident(i) => (i.to_id().0, i.span), + ModuleExportName::Str(s) => (s.value.clone(), s.span), + }, + None => (named.local.to_id().0, named.local.span), + }, + ImportSpecifier::Default(d) => (js_word!(""), d.span), + ImportSpecifier::Namespace(n) => ("*".into(), n.span), + }) + .collect(); + + imports.push(ModuleImports { + source: (source, import.span), + specifiers, + }); + + finished_directives = true; + } + // Collect all export names. + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(e)) => { + for specifier in &e.specifiers { + export_names.push(match specifier { + ExportSpecifier::Default(_) => "default".to_string(), + ExportSpecifier::Namespace(_) => "*".to_string(), + ExportSpecifier::Named(named) => match &named.exported { + Some(exported) => match &exported { + ModuleExportName::Ident(i) => i.sym.to_string(), + ModuleExportName::Str(s) => s.value.to_string(), + }, + _ => match &named.orig { + ModuleExportName::Ident(i) => i.sym.to_string(), + ModuleExportName::Str(s) => s.value.to_string(), + }, + }, + }) + } + finished_directives = true; + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { decl, .. })) => { + match decl { + Decl::Class(ClassDecl { ident, .. }) => { + export_names.push(ident.sym.to_string()); + } + Decl::Fn(FnDecl { ident, .. }) => { + export_names.push(ident.sym.to_string()); + } + Decl::Var(var) => { + for decl in &var.decls { + if let Pat::Ident(ident) = &decl.name { + export_names.push(ident.id.sym.to_string()); + } + } + } + _ => {} + } + finished_directives = true; + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(ExportDefaultDecl { + decl: _, .. + })) => { + export_names.push("default".to_string()); + finished_directives = true; + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(ExportDefaultExpr { + expr: _, .. + })) => { + export_names.push("default".to_string()); + finished_directives = true; + } + ModuleItem::ModuleDecl(ModuleDecl::ExportAll(_)) => { + export_names.push("*".to_string()); + } + _ => { + finished_directives = true; + } + } + }); + + (is_client_entry, is_action_file, imports, export_names) +} + +/// A visitor to assert given module file is a valid React server component. +struct ReactServerComponentValidator { + is_react_server_layer: bool, + dynamic_io_enabled: bool, + filepath: String, + app_dir: Option, + invalid_server_imports: Vec, + invalid_server_lib_apis_mapping: HashMap<&'static str, Vec<&'static str>>, + deprecated_apis_mapping: HashMap<&'static str, Vec<&'static str>>, + invalid_client_imports: Vec, + invalid_client_lib_apis_mapping: HashMap<&'static str, Vec<&'static str>>, + pub directive_import_collection: Option<(bool, bool, RcVec, RcVec)>, + imports: ImportMap, +} + +// A type to workaround a clippy warning. +type RcVec = Rc>; + +impl ReactServerComponentValidator { + pub fn new( + is_react_server_layer: bool, + dynamic_io_enabled: bool, + filename: String, + app_dir: Option, + ) -> Self { + Self { + is_react_server_layer, + dynamic_io_enabled, + filepath: filename, + app_dir, + directive_import_collection: None, + // react -> [apis] + // react-dom -> [apis] + // next/navigation -> [apis] + invalid_server_lib_apis_mapping: [ + ( + "react", + vec![ + "Component", + "createContext", + "createFactory", + "PureComponent", + "useDeferredValue", + "useEffect", + "useImperativeHandle", + "useInsertionEffect", + "useLayoutEffect", + "useReducer", + "useRef", + "useState", + "useSyncExternalStore", + "useTransition", + "useOptimistic", + "useActionState", + "experimental_useOptimistic", + ], + ), + ( + "react-dom", + vec![ + "flushSync", + "unstable_batchedUpdates", + "useFormStatus", + "useFormState", + ], + ), + ( + "next/navigation", + vec![ + "useSearchParams", + "usePathname", + "useSelectedLayoutSegment", + "useSelectedLayoutSegments", + "useParams", + "useRouter", + "useServerInsertedHTML", + "ServerInsertedHTMLContext", + ], + ), + ] + .into(), + deprecated_apis_mapping: [("next/server", vec!["ImageResponse"])].into(), + + invalid_server_imports: vec![ + JsWord::from("client-only"), + JsWord::from("react-dom/client"), + JsWord::from("react-dom/server"), + JsWord::from("next/router"), + ], + + invalid_client_imports: vec![JsWord::from("server-only"), JsWord::from("next/headers")], + + invalid_client_lib_apis_mapping: [("next/server", vec!["after"])].into(), + imports: ImportMap::default(), + } + } + + fn is_from_node_modules(&self, filepath: &str) -> bool { + static RE: Lazy = Lazy::new(|| Regex::new(r"node_modules[\\/]").unwrap()); + RE.is_match(filepath) + } + + fn is_callee_next_dynamic(&self, callee: &Callee) -> bool { + match callee { + Callee::Expr(expr) => self.imports.is_import(expr, "next/dynamic", "default"), + _ => false, + } + } + + // Asserts the server lib apis + // e.g. + // assert_invalid_server_lib_apis("react", import) + // assert_invalid_server_lib_apis("react-dom", import) + fn assert_invalid_server_lib_apis(&self, import_source: String, import: &ModuleImports) { + let deprecated_apis = self.deprecated_apis_mapping.get(import_source.as_str()); + if let Some(deprecated_apis) = deprecated_apis { + for specifier in &import.specifiers { + if deprecated_apis.contains(&specifier.0.as_str()) { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrDeprecatedApi(( + import_source.clone(), + specifier.0.to_string(), + specifier.1, + )), + ); + } + } + } + + let invalid_apis = self + .invalid_server_lib_apis_mapping + .get(import_source.as_str()); + if let Some(invalid_apis) = invalid_apis { + for specifier in &import.specifiers { + if invalid_apis.contains(&specifier.0.as_str()) { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrReactApi((specifier.0.to_string(), specifier.1)), + ); + } + } + } + } + + fn assert_server_graph(&self, imports: &[ModuleImports], module: &Module) { + // If the + if self.is_from_node_modules(&self.filepath) { + return; + } + for import in imports { + let source = import.source.0.clone(); + let source_str = source.to_string(); + if self.invalid_server_imports.contains(&source) { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrServerImport((source_str.clone(), import.source.1)), + ); + } + + self.assert_invalid_server_lib_apis(source_str, import); + } + + self.assert_invalid_api(module, false); + self.assert_server_filename(module); + } + + fn assert_server_filename(&self, module: &Module) { + if self.is_from_node_modules(&self.filepath) { + return; + } + static RE: Lazy = + Lazy::new(|| Regex::new(r"[\\/]((global-)?error)\.(ts|js)x?$").unwrap()); + + let is_error_file = RE.is_match(&self.filepath); + + if is_error_file { + if let Some(app_dir) = &self.app_dir { + if let Some(app_dir) = app_dir.to_str() { + if self.filepath.starts_with(app_dir) { + let span = if let Some(first_item) = module.body.first() { + first_item.span() + } else { + module.span + }; + + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrErrorFileServerComponent(span), + ); + } + } + } + } + } + + fn assert_client_graph(&self, imports: &[ModuleImports]) { + if self.is_from_node_modules(&self.filepath) { + return; + } + for import in imports { + let source = &import.source.0; + + if self.invalid_client_imports.contains(source) { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrClientImport((source.to_string(), import.source.1)), + ); + } + + let invalid_apis = self.invalid_client_lib_apis_mapping.get(source.as_str()); + if let Some(invalid_apis) = invalid_apis { + for specifier in &import.specifiers { + if invalid_apis.contains(&specifier.0.as_str()) { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrClientImport((specifier.0.to_string(), specifier.1)), + ); + } + } + } + } + } + + fn assert_invalid_api(&self, module: &Module, is_client_entry: bool) { + if self.is_from_node_modules(&self.filepath) { + return; + } + static RE: Lazy = Lazy::new(|| Regex::new(r"[\\/](page|layout)\.(ts|js)x?$").unwrap()); + let is_layout_or_page = RE.is_match(&self.filepath); + + if is_layout_or_page { + let mut span = DUMMY_SP; + let mut invalid_export_name = String::new(); + let mut invalid_exports: HashMap = HashMap::new(); + + let mut invalid_exports_matcher = |export_name: &str| -> bool { + match export_name { + "getServerSideProps" | "getStaticProps" | "generateMetadata" | "metadata" => { + invalid_exports.insert(export_name.to_string(), InvalidExportKind::General); + true + } + "dynamicParams" | "dynamic" | "fetchCache" | "runtime" | "revalidate" => { + if self.dynamic_io_enabled { + invalid_exports.insert(export_name.to_string(), InvalidExportKind::DynamicIoSegment); + true + } else { + false + } + } + _ => false, + } + }; + + for export in &module.body { + match export { + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(export)) => { + for specifier in &export.specifiers { + if let ExportSpecifier::Named(named) = specifier { + match &named.orig { + ModuleExportName::Ident(i) => { + if invalid_exports_matcher(&i.sym) { + span = named.span; + invalid_export_name = i.sym.to_string(); + } + } + ModuleExportName::Str(s) => { + if invalid_exports_matcher(&s.value) { + span = named.span; + invalid_export_name = s.value.to_string(); + } + } + } + } + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) => match &export.decl { + Decl::Fn(f) => { + if invalid_exports_matcher(&f.ident.sym) { + span = f.ident.span; + invalid_export_name = f.ident.sym.to_string(); + } + } + Decl::Var(v) => { + for decl in &v.decls { + if let Pat::Ident(i) = &decl.name { + if invalid_exports_matcher(&i.sym) { + span = i.span; + invalid_export_name = i.sym.to_string(); + } + } + } + } + _ => {} + }, + _ => {} + } + } + + // Assert invalid metadata and generateMetadata exports. + let has_gm_export = invalid_exports.contains_key("generateMetadata"); + let has_metadata_export = invalid_exports.contains_key("metadata"); + + for (export_name, kind) in &invalid_exports { + match kind { + InvalidExportKind::DynamicIoSegment => { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrIncompatibleDynamicIoSegment(span, export_name.clone()), + ); + } + InvalidExportKind::General => { + // Client entry can't export `generateMetadata` or `metadata`. + if is_client_entry { + if has_gm_export || has_metadata_export { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrClientMetadataExport((invalid_export_name.clone(), span)), + ); + } + } else { + // Server entry can't export `generateMetadata` and `metadata` together. + if has_gm_export && has_metadata_export { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrConflictMetadataExport(span), + ); + } + } + // Assert `getServerSideProps` and `getStaticProps` exports. + if invalid_export_name == "getServerSideProps" + || invalid_export_name == "getStaticProps" + { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrInvalidApi((invalid_export_name.clone(), span)), + ); + } + } + } + } + } + } + + /// ```js + /// import dynamic from 'next/dynamic' + /// + /// dynamic(() => import(...)) // ✅ + /// dynamic(() => import(...), { ssr: true }) // ✅ + /// dynamic(() => import(...), { ssr: false }) // ❌ + /// ``` + fn check_for_next_ssr_false(&self, node: &CallExpr) -> Option<()> { + if !self.is_callee_next_dynamic(&node.callee) { + return None; + } + + let ssr_arg = node.args.get(1)?; + let obj = ssr_arg.expr.as_object()?; + + for prop in obj.props.iter().filter_map(|v| v.as_prop()?.as_key_value()) { + let is_ssr = match &prop.key { + PropName::Ident(IdentName { sym, .. }) => sym == "ssr", + PropName::Str(s) => s.value == "ssr", + _ => false, + }; + + if is_ssr { + let value = prop.value.as_lit()?; + if let Lit::Bool(Bool { value: false, .. }) = value { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextSsrDynamicFalseNotAllowed(node.span), + ); + } + } + } + + None + } +} + +impl Visit for ReactServerComponentValidator { + noop_visit_type!(); + + // coerce parsed script to run validation for the context, which is still + // required even if file is empty + fn visit_script(&mut self, script: &swc_core::ecma::ast::Script) { + if script.body.is_empty() { + self.visit_module(&Module::dummy()); + } + } + + fn visit_call_expr(&mut self, node: &CallExpr) { + node.visit_children_with(self); + + if self.is_react_server_layer { + self.check_for_next_ssr_false(node); + } + } + + fn visit_module(&mut self, module: &Module) { + self.imports = ImportMap::analyze(module); + + let (is_client_entry, is_action_file, imports, export_names) = + collect_top_level_directives_and_imports(&self.app_dir, &self.filepath, module); + let imports = Rc::new(imports); + let export_names = Rc::new(export_names); + + self.directive_import_collection = Some(( + is_client_entry, + is_action_file, + imports.clone(), + export_names, + )); + + if self.is_react_server_layer { + if is_client_entry { + return; + } else { + // Only assert server graph if file's bundle target is "server", e.g. + // * server components pages + // * pages bundles on SSR layer + // * middleware + // * app/pages api routes + self.assert_server_graph(&imports, module); + } + } else { + // Only assert client graph if the file is not an action file, + // and bundle target is "client" e.g. + // * client components pages + // * pages bundles on browser layer + if !is_action_file { + self.assert_client_graph(&imports); + self.assert_invalid_api(module, true); + } + } + + module.visit_children_with(self); + } +} + +/// Returns a visitor to assert react server components without any transform. +/// This is for the Turbopack which have its own transform phase for the server +/// components proxy. +/// +/// This also returns a visitor instead of fold and performs better than running +/// whole transform as a folder. +pub fn server_components_assert( + filename: FileName, + config: Config, + app_dir: Option, +) -> impl Visit { + let is_react_server_layer: bool = match &config { + Config::WithOptions(x) => x.is_react_server_layer, + _ => false, + }; + let dynamic_io_enabled: bool = match &config { + Config::WithOptions(x) => x.dynamic_io_enabled, + _ => false, + }; + let filename = match filename { + FileName::Custom(path) => format!("<{path}>"), + _ => filename.to_string(), + }; + ReactServerComponentValidator::new(is_react_server_layer, dynamic_io_enabled, filename, app_dir) +} + +/// Runs react server component transform for the module proxy, as well as +/// running assertion. +pub fn server_components( + filename: Arc, + config: Config, + comments: C, + app_dir: Option, +) -> impl Pass + VisitMut { + let is_react_server_layer: bool = match &config { + Config::WithOptions(x) => x.is_react_server_layer, + _ => false, + }; + let dynamic_io_enabled: bool = match &config { + Config::WithOptions(x) => x.dynamic_io_enabled, + _ => false, + }; + visit_mut_pass(ReactServerComponents { + is_react_server_layer, + dynamic_io_enabled, + comments, + filepath: match &*filename { + FileName::Custom(path) => format!("<{path}>"), + _ => filename.to_string(), + }, + app_dir, + directive_import_collection: None, + }) +} diff --git a/crates/next-custom-transforms/src/transforms/server_actions.rs b/crates/next-custom-transforms/src/transforms/server_actions.rs new file mode 100644 index 000000000000..ae914cb4f8ce --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/server_actions.rs @@ -0,0 +1,2999 @@ +use std::{ + collections::{BTreeMap, HashSet}, + convert::{TryFrom, TryInto}, + mem::{replace, take}, + sync::Arc, +}; + +use hex::encode as hex_encode; +use indoc::formatdoc; +use rustc_hash::FxHashSet; +use serde::Deserialize; +use sha1::{Digest, Sha1}; +use swc_core::{ + common::{ + comments::{Comment, CommentKind, Comments}, + errors::HANDLER, + util::take::Take, + BytePos, FileName, Mark, Span, SyntaxContext, DUMMY_SP, + }, + ecma::{ + ast::*, + atoms::JsWord, + utils::{private_ident, quote_ident, ExprFactory}, + visit::{noop_visit_mut_type, visit_mut_pass, VisitMut, VisitMutWith}, + }, +}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Config { + pub is_react_server_layer: bool, + pub dynamic_io_enabled: bool, + pub hash_salt: String, + pub cache_kinds: FxHashSet>, +} + +#[derive(Clone, Debug)] +enum Directive { + UseServer, + UseCache { cache_kind: Arc }, +} + +#[derive(Clone, Debug)] +enum DirectiveLocation { + Module, + FunctionBody, +} + +#[derive(Clone, Debug)] +enum ThisStatus { + Allowed, + Forbidden { directive: Directive }, +} + +#[derive(Clone, Debug)] +enum ServerActionsErrorKind { + ExportedSyncFunction { + span: Span, + in_action_file: bool, + }, + ForbiddenExpression { + span: Span, + expr: String, + directive: Directive, + }, + InlineSyncFunction { + span: Span, + directive: Directive, + }, + InlineUseCacheInClassInstanceMethod { + span: Span, + }, + InlineUseCacheInClientComponent { + span: Span, + }, + InlineUseServerInClassInstanceMethod { + span: Span, + }, + InlineUseServerInClientComponent { + span: Span, + }, + MisplacedDirective { + span: Span, + directive: String, + location: DirectiveLocation, + }, + MisplacedWrappedDirective { + span: Span, + directive: String, + location: DirectiveLocation, + }, + MisspelledDirective { + span: Span, + directive: String, + expected_directive: String, + }, + MultipleDirectives { + span: Span, + location: DirectiveLocation, + }, + UnknownCacheKind { + span: Span, + cache_kind: Arc, + }, + UseCacheWithoutDynamicIO { + span: Span, + directive: String, + }, + WrappedDirective { + span: Span, + directive: String, + }, +} + +/// A mapping of hashed action id to the action's exported function name. +// Using BTreeMap to ensure the order of the actions is deterministic. +pub type ActionsMap = BTreeMap; + +#[tracing::instrument(level = tracing::Level::TRACE, skip_all)] +pub fn server_actions(file_name: &FileName, config: Config, comments: C) -> impl Pass { + visit_mut_pass(ServerActions { + config, + comments, + file_name: file_name.to_string(), + start_pos: BytePos(0), + file_directive: None, + in_exported_expr: false, + in_default_export_decl: false, + fn_decl_ident: None, + in_callee: false, + has_action: false, + has_cache: false, + this_status: ThisStatus::Allowed, + + reference_index: 0, + in_module_level: true, + should_track_names: false, + + names: Default::default(), + declared_idents: Default::default(), + + exported_idents: Default::default(), + + // This flag allows us to rewrite `function foo() {}` to `const foo = createProxy(...)`. + rewrite_fn_decl_to_proxy_decl: None, + rewrite_default_fn_expr_to_proxy_expr: None, + rewrite_expr_to_proxy_expr: None, + + annotations: Default::default(), + extra_items: Default::default(), + hoisted_extra_items: Default::default(), + export_actions: Default::default(), + + private_ctxt: SyntaxContext::empty().apply_mark(Mark::new()), + + arrow_or_fn_expr_ident: None, + exported_local_ids: HashSet::new(), + }) +} + +/// Serializes the Server Actions into a magic comment prefixed by +/// `__next_internal_action_entry_do_not_use__`. +fn generate_server_actions_comment(actions: ActionsMap) -> String { + format!( + " __next_internal_action_entry_do_not_use__ {} ", + serde_json::to_string(&actions).unwrap() + ) +} + +struct ServerActions { + #[allow(unused)] + config: Config, + file_name: String, + comments: C, + + start_pos: BytePos, + file_directive: Option, + in_exported_expr: bool, + in_default_export_decl: bool, + fn_decl_ident: Option, + in_callee: bool, + has_action: bool, + has_cache: bool, + this_status: ThisStatus, + + reference_index: u32, + in_module_level: bool, + should_track_names: bool, + + names: Vec, + declared_idents: Vec, + + // This flag allows us to rewrite `function foo() {}` to `const foo = createProxy(...)`. + rewrite_fn_decl_to_proxy_decl: Option, + rewrite_default_fn_expr_to_proxy_expr: Option>, + rewrite_expr_to_proxy_expr: Option>, + + exported_idents: Vec<( + /* ident */ Ident, + /* name */ String, + /* id */ String, + )>, + + annotations: Vec, + extra_items: Vec, + hoisted_extra_items: Vec, + export_actions: Vec<(/* name */ String, /* id */ String)>, + + private_ctxt: SyntaxContext, + + arrow_or_fn_expr_ident: Option, + exported_local_ids: HashSet, +} + +impl ServerActions { + fn generate_server_reference_id( + &self, + export_name: &str, + is_cache: bool, + params: Option<&Vec>, + ) -> String { + // Attach a checksum to the action using sha1: + // $$id = special_byte + sha1('hash_salt' + 'file_name' + ':' + 'export_name'); + // Currently encoded as hex. + + let mut hasher = Sha1::new(); + hasher.update(self.config.hash_salt.as_bytes()); + hasher.update(self.file_name.as_bytes()); + hasher.update(b":"); + hasher.update(export_name.as_bytes()); + let mut result = hasher.finalize().to_vec(); + + // Prepend an extra byte to the ID, with the following format: + // 0 000000 0 + // ^type ^arg mask ^rest args + // + // The type bit represents if the action is a cache function or not. + // For cache functions, the type bit is set to 1. Otherwise, it's 0. + // + // The arg mask bit is used to determine which arguments are used by + // the function itself, up to 6 arguments. The bit is set to 1 if the + // argument is used, or being spread or destructured (so it can be + // indirectly or partially used). The bit is set to 0 otherwise. + // + // The rest args bit is used to determine if there's a ...rest argument + // in the function signature. If there is, the bit is set to 1. + // + // For example: + // + // async function foo(a, foo, b, bar, ...baz) { + // 'use cache'; + // return a + b; + // } + // + // will have it encoded as [1][101011][1]. The first bit is set to 1 + // because it's a cache function. The second part has 1010 because the + // only arguments used are `a` and `b`. The subsequent 11 bits are set + // to 1 because there's a ...rest argument starting from the 5th. The + // last bit is set to 1 as well for the same reason. + let type_bit = if is_cache { 1u8 } else { 0u8 }; + let mut arg_mask = 0u8; + let mut rest_args = 0u8; + + if let Some(params) = params { + // TODO: For the current implementation, we don't track if an + // argument ident is actually referenced in the function body. + // Instead, we go with the easy route and assume defined ones are + // used. This can be improved in the future. + for (i, param) in params.iter().enumerate() { + if let Pat::Rest(_) = param.pat { + // If there's a ...rest argument, we set the rest args bit + // to 1 and set the arg mask to 0b111111. + arg_mask = 0b111111; + rest_args = 0b1; + break; + } + if i < 6 { + arg_mask |= 0b1 << (5 - i); + } else { + // More than 6 arguments, we set the rest args bit to 1. + // This is rare for a Server Action, usually. + rest_args = 0b1; + break; + } + } + } else { + // If we can't determine the arguments (e.g. not staticaly analyzable), + // we assume all arguments are used. + arg_mask = 0b111111; + rest_args = 0b1; + } + + result.push((type_bit << 7) | (arg_mask << 1) | rest_args); + result.rotate_right(1); + + hex_encode(result) + } + + fn gen_action_ident(&mut self) -> JsWord { + let id: JsWord = format!("$$RSC_SERVER_ACTION_{0}", self.reference_index).into(); + self.reference_index += 1; + id + } + + fn gen_cache_ident(&mut self) -> JsWord { + let id: JsWord = format!("$$RSC_SERVER_CACHE_{0}", self.reference_index).into(); + self.reference_index += 1; + id + } + + fn gen_ref_ident(&mut self) -> JsWord { + let id: JsWord = format!("$$RSC_SERVER_REF_{0}", self.reference_index).into(); + self.reference_index += 1; + id + } + + fn create_bound_action_args_array_pat(&mut self, arg_len: usize) -> Pat { + Pat::Array(ArrayPat { + span: DUMMY_SP, + elems: (0..arg_len) + .map(|i| { + Some(Pat::Ident( + Ident::new( + format!("$$ACTION_ARG_{i}").into(), + DUMMY_SP, + self.private_ctxt, + ) + .into(), + )) + }) + .collect(), + optional: false, + type_ann: None, + }) + } + + // Check if the function or arrow function is an action or cache function, + // and remove any server function directive. + fn get_directive_for_function( + &mut self, + maybe_body: Option<&mut BlockStmt>, + ) -> Option { + let mut directive: Option = None; + + // Even if it's a file-level action or cache module, the function body + // might still have directives that override the module-level annotations. + if let Some(body) = maybe_body { + let directive_visitor = &mut DirectiveVisitor { + config: &self.config, + directive: None, + has_file_directive: self.file_directive.is_some(), + is_allowed_position: true, + location: DirectiveLocation::FunctionBody, + }; + + body.stmts.retain(|stmt| { + let has_directive = directive_visitor.visit_stmt(stmt); + + !has_directive + }); + + directive = directive_visitor.directive.clone(); + } + + // All exported functions inherit the file directive if they don't have their own directive. + if self.in_exported_expr && directive.is_none() && self.file_directive.is_some() { + return self.file_directive.clone(); + } + + directive + } + + fn get_directive_for_module(&mut self, stmts: &mut Vec) -> Option { + let directive_visitor = &mut DirectiveVisitor { + config: &self.config, + directive: None, + has_file_directive: false, + is_allowed_position: true, + location: DirectiveLocation::Module, + }; + + stmts.retain(|item| { + if let ModuleItem::Stmt(stmt) = item { + let has_directive = directive_visitor.visit_stmt(stmt); + + !has_directive + } else { + directive_visitor.is_allowed_position = false; + true + } + }); + + directive_visitor.directive.clone() + } + + fn maybe_hoist_and_create_proxy_for_server_action_arrow_expr( + &mut self, + ids_from_closure: Vec, + arrow: &mut ArrowExpr, + ) -> Box { + let mut new_params: Vec = vec![]; + + if !ids_from_closure.is_empty() { + // First param is the encrypted closure variables. + new_params.push(Param { + span: DUMMY_SP, + decorators: vec![], + pat: Pat::Ident(IdentName::new("$$ACTION_CLOSURE_BOUND".into(), DUMMY_SP).into()), + }); + } + + for p in arrow.params.iter() { + new_params.push(Param::from(p.clone())); + } + + let action_name = self.gen_action_ident().to_string(); + let action_ident = Ident::new(action_name.clone().into(), arrow.span, self.private_ctxt); + let action_id = self.generate_server_reference_id(&action_name, false, Some(&new_params)); + + self.has_action = true; + self + .export_actions + .push((action_name.to_string(), action_id.clone())); + + let register_action_expr = bind_args_to_ref_expr( + annotate_ident_as_server_reference( + action_ident.clone(), + action_id.clone(), + arrow.span, + &self.comments, + ), + ids_from_closure + .iter() + .cloned() + .map(|id| Some(id.as_arg())) + .collect(), + action_id.clone(), + ); + + if let BlockStmtOrExpr::BlockStmt(block) = &mut *arrow.body { + block.visit_mut_with(&mut ClosureReplacer { + used_ids: &ids_from_closure, + private_ctxt: self.private_ctxt, + }); + } + + let mut new_body: BlockStmtOrExpr = *arrow.body.clone(); + + if !ids_from_closure.is_empty() { + // Prepend the decryption declaration to the body. + // var [arg1, arg2, arg3] = await decryptActionBoundArgs(actionId, + // $$ACTION_CLOSURE_BOUND) + let decryption_decl = VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + declare: false, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: self.create_bound_action_args_array_pat(ids_from_closure.len()), + init: Some(Box::new(Expr::Await(AwaitExpr { + span: DUMMY_SP, + arg: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("decryptActionBoundArgs").as_callee(), + args: vec![ + action_id.as_arg(), + quote_ident!("$$ACTION_CLOSURE_BOUND").as_arg(), + ], + ..Default::default() + })), + }))), + definite: Default::default(), + }], + ..Default::default() + }; + + match &mut new_body { + BlockStmtOrExpr::BlockStmt(body) => { + body.stmts.insert(0, decryption_decl.into()); + } + BlockStmtOrExpr::Expr(body_expr) => { + new_body = BlockStmtOrExpr::BlockStmt(BlockStmt { + span: DUMMY_SP, + stmts: vec![ + decryption_decl.into(), + Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(body_expr.take()), + }), + ], + ..Default::default() + }); + } + } + } + + // Create the action export decl from the arrow function + // export const $$RSC_SERVER_ACTION_0 = async function action($$ACTION_CLOSURE_BOUND) {} + self + .hoisted_extra_items + .push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: VarDecl { + kind: VarDeclKind::Const, + span: DUMMY_SP, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(action_ident.clone().into()), + definite: false, + init: Some(Box::new(Expr::Fn(FnExpr { + ident: self.arrow_or_fn_expr_ident.clone(), + function: Box::new(Function { + params: new_params, + body: match new_body { + BlockStmtOrExpr::BlockStmt(body) => Some(body), + BlockStmtOrExpr::Expr(expr) => Some(BlockStmt { + span: DUMMY_SP, + stmts: vec![Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(expr), + })], + ..Default::default() + }), + }, + decorators: vec![], + span: DUMMY_SP, + is_generator: false, + is_async: true, + ..Default::default() + }), + }))), + }], + declare: Default::default(), + ctxt: self.private_ctxt, + } + .into(), + }))); + + Box::new(register_action_expr.clone()) + } + + fn maybe_hoist_and_create_proxy_for_server_action_function( + &mut self, + ids_from_closure: Vec, + function: &mut Function, + fn_name: Option, + ) -> Box { + let mut new_params: Vec = vec![]; + + if !ids_from_closure.is_empty() { + // First param is the encrypted closure variables. + new_params.push(Param { + span: DUMMY_SP, + decorators: vec![], + pat: Pat::Ident(IdentName::new("$$ACTION_CLOSURE_BOUND".into(), DUMMY_SP).into()), + }); + } + + new_params.append(&mut function.params); + + let action_name: JsWord = self.gen_action_ident(); + let action_ident = Ident::new(action_name.clone(), function.span, self.private_ctxt); + let action_id = self.generate_server_reference_id(&action_name, false, Some(&new_params)); + + self.has_action = true; + self + .export_actions + .push((action_name.to_string(), action_id.clone())); + + let register_action_expr = bind_args_to_ref_expr( + annotate_ident_as_server_reference( + action_ident.clone(), + action_id.clone(), + function.span, + &self.comments, + ), + ids_from_closure + .iter() + .cloned() + .map(|id| Some(id.as_arg())) + .collect(), + action_id.clone(), + ); + + function.body.visit_mut_with(&mut ClosureReplacer { + used_ids: &ids_from_closure, + private_ctxt: self.private_ctxt, + }); + + let mut new_body: Option = function.body.clone(); + + if !ids_from_closure.is_empty() { + // Prepend the decryption declaration to the body. + // var [arg1, arg2, arg3] = await decryptActionBoundArgs(actionId, + // $$ACTION_CLOSURE_BOUND) + let decryption_decl = VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: self.create_bound_action_args_array_pat(ids_from_closure.len()), + init: Some(Box::new(Expr::Await(AwaitExpr { + span: DUMMY_SP, + arg: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("decryptActionBoundArgs").as_callee(), + args: vec![ + action_id.as_arg(), + quote_ident!("$$ACTION_CLOSURE_BOUND").as_arg(), + ], + ..Default::default() + })), + }))), + definite: Default::default(), + }], + ..Default::default() + }; + + if let Some(body) = &mut new_body { + body.stmts.insert(0, decryption_decl.into()); + } else { + new_body = Some(BlockStmt { + span: DUMMY_SP, + stmts: vec![decryption_decl.into()], + ..Default::default() + }); + } + } + + // Create the action export decl from the function + // export const $$RSC_SERVER_ACTION_0 = async function action($$ACTION_CLOSURE_BOUND) {} + self + .hoisted_extra_items + .push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: VarDecl { + kind: VarDeclKind::Const, + span: DUMMY_SP, + decls: vec![VarDeclarator { + span: DUMMY_SP, // TODO: need to map it to the original span? + name: Pat::Ident(action_ident.clone().into()), + definite: false, + init: Some(Box::new(Expr::Fn(FnExpr { + ident: fn_name, + function: Box::new(Function { + params: new_params, + body: new_body, + ..function.take() + }), + }))), + }], + declare: Default::default(), + ctxt: self.private_ctxt, + } + .into(), + }))); + + Box::new(register_action_expr) + } + + fn maybe_hoist_and_create_proxy_for_cache_arrow_expr( + &mut self, + ids_from_closure: Vec, + cache_kind: Arc, + arrow: &mut ArrowExpr, + ) -> Box { + let mut new_params: Vec = vec![]; + + // Add the collected closure variables as the first parameter to the + // function. They are unencrypted and passed into this function by the + // cache wrapper. + if !ids_from_closure.is_empty() { + new_params.push(Param { + span: DUMMY_SP, + decorators: vec![], + pat: self.create_bound_action_args_array_pat(ids_from_closure.len()), + }); + } + + for p in arrow.params.iter() { + new_params.push(Param::from(p.clone())); + } + + let cache_name: JsWord = self.gen_cache_ident(); + let cache_ident = private_ident!(cache_name.clone()); + let export_name: JsWord = cache_name; + + let reference_id = self.generate_server_reference_id(&export_name, true, Some(&new_params)); + + self.has_cache = true; + self + .export_actions + .push((export_name.to_string(), reference_id.clone())); + + if let BlockStmtOrExpr::BlockStmt(block) = &mut *arrow.body { + block.visit_mut_with(&mut ClosureReplacer { + used_ids: &ids_from_closure, + private_ctxt: self.private_ctxt, + }); + } + + // Create the action export decl from the arrow function + // export var cache_ident = async function() {} + self + .hoisted_extra_items + .push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(cache_ident.clone().into()), + init: Some(wrap_cache_expr( + Box::new(Expr::Fn(FnExpr { + ident: None, + function: Box::new(Function { + params: new_params, + body: match *arrow.body.take() { + BlockStmtOrExpr::BlockStmt(body) => Some(body), + BlockStmtOrExpr::Expr(expr) => Some(BlockStmt { + span: DUMMY_SP, + stmts: vec![Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(expr), + })], + ..Default::default() + }), + }, + decorators: vec![], + span: DUMMY_SP, + is_generator: false, + is_async: true, + ..Default::default() + }), + })), + &cache_kind, + &reference_id, + ids_from_closure.len(), + )), + definite: false, + }], + ..Default::default() + } + .into(), + }))); + + if let Some(Ident { sym, .. }) = &self.arrow_or_fn_expr_ident { + assign_name_to_ident(&cache_ident, sym.as_str(), &mut self.hoisted_extra_items); + } + + let bound_args: Vec<_> = ids_from_closure + .iter() + .cloned() + .map(|id| Some(id.as_arg())) + .collect(); + + let register_action_expr = annotate_ident_as_server_reference( + cache_ident.clone(), + reference_id.clone(), + arrow.span, + &self.comments, + ); + + // If there're any bound args from the closure, we need to hoist the + // register action expression to the top-level, and return the bind + // expression inline. + if !bound_args.is_empty() { + let ref_ident = private_ident!(self.gen_ref_ident()); + + let ref_decl = VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(ref_ident.clone().into()), + init: Some(Box::new(register_action_expr.clone())), + definite: false, + }], + ..Default::default() + }; + + // Hoist the register action expression to the top-level. + self + .extra_items + .push(ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(ref_decl))))); + + Box::new(bind_args_to_ref_expr( + Expr::Ident(ref_ident.clone()), + bound_args, + reference_id.clone(), + )) + } else { + Box::new(register_action_expr) + } + } + + fn maybe_hoist_and_create_proxy_for_cache_function( + &mut self, + ids_from_closure: Vec, + fn_name: Option, + cache_kind: Arc, + function: &mut Function, + ) -> Box { + let mut new_params: Vec = vec![]; + + // Add the collected closure variables as the first parameter to the + // function. They are unencrypted and passed into this function by the + // cache wrapper. + if !ids_from_closure.is_empty() { + new_params.push(Param { + span: DUMMY_SP, + decorators: vec![], + pat: self.create_bound_action_args_array_pat(ids_from_closure.len()), + }); + } + + for p in function.params.iter() { + new_params.push(p.clone()); + } + + let cache_name: JsWord = self.gen_cache_ident(); + let cache_ident = private_ident!(cache_name.clone()); + + let reference_id = self.generate_server_reference_id(&cache_name, true, Some(&new_params)); + + self.has_cache = true; + self + .export_actions + .push((cache_name.to_string(), reference_id.clone())); + + let register_action_expr = annotate_ident_as_server_reference( + cache_ident.clone(), + reference_id.clone(), + function.span, + &self.comments, + ); + + function.body.visit_mut_with(&mut ClosureReplacer { + used_ids: &ids_from_closure, + private_ctxt: self.private_ctxt, + }); + + // export var cache_ident = async function() {} + self + .hoisted_extra_items + .push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(cache_ident.clone().into()), + init: Some(wrap_cache_expr( + Box::new(Expr::Fn(FnExpr { + ident: fn_name.clone(), + function: Box::new(Function { + params: new_params, + ..function.take() + }), + })), + &cache_kind, + &reference_id, + ids_from_closure.len(), + )), + definite: false, + }], + ..Default::default() + } + .into(), + }))); + + if let Some(Ident { sym, .. }) = fn_name { + assign_name_to_ident(&cache_ident, sym.as_str(), &mut self.hoisted_extra_items); + } else if self.in_default_export_decl { + assign_name_to_ident(&cache_ident, "default", &mut self.hoisted_extra_items); + } + + let bound_args: Vec<_> = ids_from_closure + .iter() + .cloned() + .map(|id| Some(id.as_arg())) + .collect(); + + // If there're any bound args from the closure, we need to hoist the + // register action expression to the top-level, and return the bind + // expression inline. + if !bound_args.is_empty() { + let ref_ident = private_ident!(self.gen_ref_ident()); + + let ref_decl = VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(ref_ident.clone().into()), + init: Some(Box::new(register_action_expr.clone())), + definite: false, + }], + ..Default::default() + }; + + // Hoist the register action expression to the top-level. + self + .extra_items + .push(ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(ref_decl))))); + + Box::new(bind_args_to_ref_expr( + Expr::Ident(ref_ident.clone()), + bound_args, + reference_id.clone(), + )) + } else { + Box::new(register_action_expr) + } + } +} + +impl VisitMut for ServerActions { + fn visit_mut_export_decl(&mut self, decl: &mut ExportDecl) { + let old_in_exported_expr = replace(&mut self.in_exported_expr, true); + decl.decl.visit_mut_with(self); + self.in_exported_expr = old_in_exported_expr; + } + + fn visit_mut_export_default_decl(&mut self, decl: &mut ExportDefaultDecl) { + let old_in_exported_expr = replace(&mut self.in_exported_expr, true); + let old_in_default_export_decl = replace(&mut self.in_default_export_decl, true); + self.rewrite_default_fn_expr_to_proxy_expr = None; + decl.decl.visit_mut_with(self); + self.in_exported_expr = old_in_exported_expr; + self.in_default_export_decl = old_in_default_export_decl; + } + + fn visit_mut_export_default_expr(&mut self, expr: &mut ExportDefaultExpr) { + let old_in_exported_expr = replace(&mut self.in_exported_expr, true); + let old_in_default_export_decl = replace(&mut self.in_default_export_decl, true); + expr.expr.visit_mut_with(self); + self.in_exported_expr = old_in_exported_expr; + self.in_default_export_decl = old_in_default_export_decl; + } + + fn visit_mut_fn_expr(&mut self, f: &mut FnExpr) { + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.clone(); + if let Some(ident) = &f.ident { + self.arrow_or_fn_expr_ident = Some(ident.clone()); + } + f.visit_mut_children_with(self); + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + } + + fn visit_mut_function(&mut self, f: &mut Function) { + let directive = self.get_directive_for_function(f.body.as_mut()); + let declared_idents_until = self.declared_idents.len(); + let old_names = take(&mut self.names); + + if let Some(directive) = &directive { + self.this_status = ThisStatus::Forbidden { + directive: directive.clone(), + }; + } + + // Visit children + { + let old_in_module = replace(&mut self.in_module_level, false); + let should_track_names = directive.is_some() || self.should_track_names; + let old_should_track_names = replace(&mut self.should_track_names, should_track_names); + let old_in_exported_expr = replace(&mut self.in_exported_expr, false); + let old_in_default_export_decl = replace(&mut self.in_default_export_decl, false); + let old_fn_decl_ident = self.fn_decl_ident.take(); + f.visit_mut_children_with(self); + self.in_module_level = old_in_module; + self.should_track_names = old_should_track_names; + self.in_exported_expr = old_in_exported_expr; + self.in_default_export_decl = old_in_default_export_decl; + self.fn_decl_ident = old_fn_decl_ident; + } + + if let Some(directive) = directive { + if !f.is_async { + emit_error(ServerActionsErrorKind::InlineSyncFunction { + span: f.span, + directive, + }); + + return; + } + + let has_errors = HANDLER.with(|handler| handler.has_errors()); + + // Don't hoist a function if 1) an error was emitted, or 2) we're in the client layer. + if has_errors || !self.config.is_react_server_layer { + return; + } + + let mut child_names = take(&mut self.names); + + if self.should_track_names { + self.names = [old_names, child_names.clone()].concat(); + } + + if let Directive::UseCache { cache_kind } = directive { + // Collect all the identifiers defined inside the closure and used + // in the cache function. With deduplication. + retain_names_from_declared_idents( + &mut child_names, + &self.declared_idents[..declared_idents_until], + ); + + let new_expr = self.maybe_hoist_and_create_proxy_for_cache_function( + child_names.clone(), + self + .fn_decl_ident + .clone() + .or(self.arrow_or_fn_expr_ident.clone()), + cache_kind, + f, + ); + + if self.in_default_export_decl { + // This function expression is also the default export: + // `export default async function() {}` + // This specific case (default export) isn't handled by `visit_mut_expr`. + // Replace the original function expr with a action proxy expr. + self.rewrite_default_fn_expr_to_proxy_expr = Some(new_expr); + } else if let Some(ident) = &self.fn_decl_ident { + // Replace the original function declaration with a cache decl. + self.rewrite_fn_decl_to_proxy_decl = Some(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(ident.clone().into()), + init: Some(new_expr), + definite: false, + }], + ..Default::default() + }); + } else { + self.rewrite_expr_to_proxy_expr = Some(new_expr); + } + } else if !(matches!(self.file_directive, Some(Directive::UseServer)) + && self.in_exported_expr) + { + // Collect all the identifiers defined inside the closure and used + // in the action function. With deduplication. + retain_names_from_declared_idents( + &mut child_names, + &self.declared_idents[..declared_idents_until], + ); + + let new_expr = self.maybe_hoist_and_create_proxy_for_server_action_function( + child_names, + f, + self + .fn_decl_ident + .clone() + .or(self.arrow_or_fn_expr_ident.clone()), + ); + + if self.in_default_export_decl { + // This function expression is also the default export: + // `export default async function() {}` + // This specific case (default export) isn't handled by `visit_mut_expr`. + // Replace the original function expr with a action proxy expr. + self.rewrite_default_fn_expr_to_proxy_expr = Some(new_expr); + } else if let Some(ident) = &self.fn_decl_ident { + // Replace the original function declaration with an action proxy declaration + // expr. + self.rewrite_fn_decl_to_proxy_decl = Some(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(ident.clone().into()), + init: Some(new_expr), + definite: false, + }], + ..Default::default() + }); + } else { + self.rewrite_expr_to_proxy_expr = Some(new_expr); + } + } + } + } + + fn visit_mut_decl(&mut self, d: &mut Decl) { + self.rewrite_fn_decl_to_proxy_decl = None; + d.visit_mut_children_with(self); + + if let Some(decl) = &self.rewrite_fn_decl_to_proxy_decl { + *d = (*decl).clone().into(); + } + + self.rewrite_fn_decl_to_proxy_decl = None; + } + + fn visit_mut_fn_decl(&mut self, f: &mut FnDecl) { + let old_this_status = replace(&mut self.this_status, ThisStatus::Allowed); + let old_in_exported_expr = self.in_exported_expr; + if self.in_module_level && self.exported_local_ids.contains(&f.ident.to_id()) { + self.in_exported_expr = true + } + let old_fn_decl_ident = self.fn_decl_ident.replace(f.ident.clone()); + f.visit_mut_children_with(self); + self.this_status = old_this_status; + self.in_exported_expr = old_in_exported_expr; + self.fn_decl_ident = old_fn_decl_ident; + } + + fn visit_mut_arrow_expr(&mut self, a: &mut ArrowExpr) { + // Arrow expressions need to be visited in prepass to determine if it's + // an action function or not. + let directive = + self.get_directive_for_function(if let BlockStmtOrExpr::BlockStmt(block) = &mut *a.body { + Some(block) + } else { + None + }); + + if let Some(directive) = &directive { + self.this_status = ThisStatus::Forbidden { + directive: directive.clone(), + }; + } + + let declared_idents_until = self.declared_idents.len(); + let old_names = take(&mut self.names); + + { + // Visit children + let old_in_module = replace(&mut self.in_module_level, false); + let should_track_names = directive.is_some() || self.should_track_names; + let old_should_track_names = replace(&mut self.should_track_names, should_track_names); + let old_in_exported_expr = replace(&mut self.in_exported_expr, false); + let old_in_default_export_decl = replace(&mut self.in_default_export_decl, false); + { + for n in &mut a.params { + collect_idents_in_pat(n, &mut self.declared_idents); + } + } + a.visit_mut_children_with(self); + self.in_module_level = old_in_module; + self.should_track_names = old_should_track_names; + self.in_exported_expr = old_in_exported_expr; + self.in_default_export_decl = old_in_default_export_decl; + } + + if let Some(directive) = directive { + if !a.is_async { + emit_error(ServerActionsErrorKind::InlineSyncFunction { + span: a.span, + directive, + }); + + return; + } + + let has_errors = HANDLER.with(|handler| handler.has_errors()); + + // Don't hoist an arrow expression if 1) an error was emitted, or 2) we're in the client + // layer. + if has_errors || !self.config.is_react_server_layer { + return; + } + + let mut child_names = take(&mut self.names); + + if self.should_track_names { + self.names = [old_names, child_names.clone()].concat(); + } + + // Collect all the identifiers defined inside the closure and used + // in the action function. With deduplication. + retain_names_from_declared_idents( + &mut child_names, + &self.declared_idents[..declared_idents_until], + ); + + if let Directive::UseCache { cache_kind } = directive { + self.rewrite_expr_to_proxy_expr = + Some(self.maybe_hoist_and_create_proxy_for_cache_arrow_expr(child_names, cache_kind, a)); + } else if !matches!(self.file_directive, Some(Directive::UseServer)) { + self.rewrite_expr_to_proxy_expr = + Some(self.maybe_hoist_and_create_proxy_for_server_action_arrow_expr(child_names, a)); + } + } + } + + fn visit_mut_module(&mut self, m: &mut Module) { + self.start_pos = m.span.lo; + m.visit_mut_children_with(self); + } + + fn visit_mut_stmt(&mut self, n: &mut Stmt) { + n.visit_mut_children_with(self); + + if self.in_module_level { + return; + } + + // If it's a closure (not in the module level), we need to collect + // identifiers defined in the closure. + collect_decl_idents_in_stmt(n, &mut self.declared_idents); + } + + fn visit_mut_param(&mut self, n: &mut Param) { + n.visit_mut_children_with(self); + + if self.in_module_level { + return; + } + + collect_idents_in_pat(&n.pat, &mut self.declared_idents); + } + + fn visit_mut_prop_or_spread(&mut self, n: &mut PropOrSpread) { + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.clone(); + let old_in_exported_expr = self.in_exported_expr; + + match n { + PropOrSpread::Prop(box Prop::KeyValue(KeyValueProp { + key: PropName::Ident(ident_name), + value: box Expr::Arrow(_) | box Expr::Fn(_), + .. + })) => { + self.in_exported_expr = false; + self.arrow_or_fn_expr_ident = Some(ident_name.clone().into()); + } + PropOrSpread::Prop(box Prop::Method(MethodProp { key, .. })) => { + let key = key.clone(); + + if let PropName::Ident(ident_name) = &key { + self.arrow_or_fn_expr_ident = Some(ident_name.clone().into()); + } + + let old_this_status = replace(&mut self.this_status, ThisStatus::Allowed); + self.rewrite_expr_to_proxy_expr = None; + self.in_exported_expr = false; + n.visit_mut_children_with(self); + self.in_exported_expr = old_in_exported_expr; + self.this_status = old_this_status; + + if let Some(expr) = self.rewrite_expr_to_proxy_expr.take() { + *n = PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { key, value: expr }))); + } + + return; + } + _ => {} + } + + if !self.in_module_level && self.should_track_names { + if let PropOrSpread::Prop(box Prop::Shorthand(i)) = n { + self.names.push(Name::from(&*i)); + self.should_track_names = false; + n.visit_mut_children_with(self); + self.should_track_names = true; + return; + } + } + + n.visit_mut_children_with(self); + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + self.in_exported_expr = old_in_exported_expr; + } + + fn visit_mut_class_member(&mut self, n: &mut ClassMember) { + if let ClassMember::Method(ClassMethod { + is_abstract: false, + is_static: true, + kind: MethodKind::Method, + key, + span, + accessibility: None | Some(Accessibility::Public), + .. + }) = n + { + let key = key.clone(); + let span = *span; + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.clone(); + + if let PropName::Ident(ident_name) = &key { + self.arrow_or_fn_expr_ident = Some(ident_name.clone().into()); + } + + let old_this_status = replace(&mut self.this_status, ThisStatus::Allowed); + self.rewrite_expr_to_proxy_expr = None; + self.in_exported_expr = false; + n.visit_mut_children_with(self); + self.this_status = old_this_status; + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + + if let Some(expr) = self.rewrite_expr_to_proxy_expr.take() { + *n = ClassMember::ClassProp(ClassProp { + span, + key, + value: Some(expr), + is_static: true, + ..Default::default() + }); + } + } else { + n.visit_mut_children_with(self); + } + } + + fn visit_mut_class_method(&mut self, n: &mut ClassMethod) { + if n.is_static { + n.visit_mut_children_with(self); + } else { + let (is_action_fn, is_cache_fn) = has_body_directive(&n.function.body); + + if is_action_fn { + emit_error(ServerActionsErrorKind::InlineUseServerInClassInstanceMethod { span: n.span }); + } else if is_cache_fn { + emit_error(ServerActionsErrorKind::InlineUseCacheInClassInstanceMethod { span: n.span }); + } else { + n.visit_mut_children_with(self); + } + } + } + + fn visit_mut_call_expr(&mut self, n: &mut CallExpr) { + if let Callee::Expr(box Expr::Ident(Ident { sym, .. })) = &mut n.callee { + if sym == "jsxDEV" || sym == "_jsxDEV" { + // Do not visit the 6th arg in a generated jsxDEV call, which is a `this` + // expression, to avoid emitting an error for using `this` if it's + // inside of a server function. https://github.com/facebook/react/blob/9106107/packages/react/src/jsx/ReactJSXElement.js#L429 + if n.args.len() > 4 { + for arg in &mut n.args[0..4] { + arg.visit_mut_with(self); + } + return; + } + } + } + + n.visit_mut_children_with(self); + } + + fn visit_mut_callee(&mut self, n: &mut Callee) { + let old_in_callee = replace(&mut self.in_callee, true); + n.visit_mut_children_with(self); + self.in_callee = old_in_callee; + } + + fn visit_mut_expr(&mut self, n: &mut Expr) { + if !self.in_module_level && self.should_track_names { + if let Ok(mut name) = Name::try_from(&*n) { + if self.in_callee { + // This is a callee i.e. `foo.bar()`, + // we need to track the actual value instead of the method name. + if !name.1.is_empty() { + name.1.pop(); + } + } + + self.names.push(name); + self.should_track_names = false; + n.visit_mut_children_with(self); + self.should_track_names = true; + return; + } + } + + self.rewrite_expr_to_proxy_expr = None; + n.visit_mut_children_with(self); + if let Some(expr) = self.rewrite_expr_to_proxy_expr.take() { + *n = *expr; + } + } + + fn visit_mut_module_items(&mut self, stmts: &mut Vec) { + self.file_directive = self.get_directive_for_module(stmts); + + let in_cache_file = matches!(self.file_directive, Some(Directive::UseCache { .. })); + let in_action_file = matches!(self.file_directive, Some(Directive::UseServer)); + + if in_cache_file { + // If we're in a "use cache" file, collect all original IDs from + // export specifiers in a pre-pass so that we know which functions + // are exported, e.g. for this case: + // ``` + // "use cache" + // function foo() {} + // function Bar() {} + // export { foo } + // export default Bar + // ``` + for stmt in stmts.iter() { + match stmt { + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(export_default_expr)) => { + if let Expr::Ident(ident) = &*export_default_expr.expr { + self.exported_local_ids.insert(ident.to_id()); + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(named_export)) => { + if named_export.src.is_none() { + for spec in &named_export.specifiers { + if let ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(ident), + .. + }) = spec + { + self.exported_local_ids.insert(ident.to_id()); + } + } + } + } + _ => {} + } + } + } + + // Only track exported identifiers in action files or cache files. + let should_track_exports = self.file_directive.is_some(); + + let old_annotations = self.annotations.take(); + let mut new = Vec::with_capacity(stmts.len()); + + for mut stmt in stmts.take() { + // For server boundary files, it's not allowed to export things other than async + // functions. + if should_track_exports { + let mut disallowed_export_span = DUMMY_SP; + + // Currently only function exports are allowed. + match &mut stmt { + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { decl, span })) => { + match decl { + Decl::Fn(f) => { + // export function foo() {} + + let (is_action_fn, is_cache_fn) = has_body_directive(&f.function.body); + + let ref_id = if is_action_fn { + false + } else if is_cache_fn { + true + } else { + in_cache_file + }; + + // If it's a self-annotated cache function, we need to skip + // collecting the exported ident. Otherwise it will be double- + // annotated. + // TODO(shu): This is a workaround. We should have a better way + // to skip self-annotated exports here. + if !(is_cache_fn && self.config.is_react_server_layer) { + self.exported_idents.push(( + f.ident.clone(), + f.ident.sym.to_string(), + self.generate_server_reference_id( + f.ident.sym.as_ref(), + ref_id, + Some(&f.function.params), + ), + )); + } + } + Decl::Var(var) => { + // export const foo = 1 + let mut idents: Vec = Vec::new(); + collect_idents_in_var_decls(&var.decls, &mut idents); + + for ident in &idents { + self.exported_idents.push(( + ident.clone(), + ident.sym.to_string(), + self.generate_server_reference_id(ident.sym.as_ref(), in_cache_file, None), + )); + } + + for decl in &mut var.decls { + if let Some(init) = &decl.init { + if let Expr::Lit(_) = &**init { + // It's not allowed to export any literal. + disallowed_export_span = *span; + } + } + } + } + _ => { + disallowed_export_span = *span; + } + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(named)) => { + if named.src.is_some() { + disallowed_export_span = named.span; + } else { + for spec in &mut named.specifiers { + if let ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(ident), + exported, + .. + }) = spec + { + if let Some(export_name) = exported { + if let ModuleExportName::Ident(Ident { sym, .. }) = export_name { + // export { foo as bar } + self.exported_idents.push(( + ident.clone(), + sym.to_string(), + self.generate_server_reference_id(sym.as_ref(), in_cache_file, None), + )); + } else if let ModuleExportName::Str(str) = export_name { + // export { foo as "bar" } + self.exported_idents.push(( + ident.clone(), + str.value.to_string(), + self.generate_server_reference_id(str.value.as_ref(), in_cache_file, None), + )); + } + } else { + // export { foo } + self.exported_idents.push(( + ident.clone(), + ident.sym.to_string(), + self.generate_server_reference_id(ident.sym.as_ref(), in_cache_file, None), + )); + } + } else { + disallowed_export_span = named.span; + } + } + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(ExportDefaultDecl { + decl, + span, + .. + })) => match decl { + DefaultDecl::Fn(f) => { + let (is_action_fn, is_cache_fn) = has_body_directive(&f.function.body); + + let is_cache = if is_action_fn { + false + } else if is_cache_fn { + true + } else { + in_cache_file + }; + + // If it's a self-annotated cache function, we need to skip + // collecting the exported ident. Otherwise it will be double- + // annotated. + // TODO(shu): This is a workaround. We should have a better way + // to skip self-annotated exports here. + if !(is_cache_fn && self.config.is_react_server_layer) { + let ref_id = + self.generate_server_reference_id("default", is_cache, Some(&f.function.params)); + + if let Some(ident) = &f.ident { + // export default function foo() {} + self + .exported_idents + .push((ident.clone(), "default".into(), ref_id)); + } else { + // export default function() {} + // Use the span from the function expression + let span = f.function.span; + + let new_ident = Ident::new(self.gen_action_ident(), span, self.private_ctxt); + + f.ident = Some(new_ident.clone()); + + self + .exported_idents + .push((new_ident.clone(), "default".into(), ref_id)); + + assign_name_to_ident(&new_ident, "default", &mut self.extra_items); + } + } + } + _ => { + disallowed_export_span = *span; + } + }, + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(default_expr)) => { + match &mut *default_expr.expr { + Expr::Fn(_f) => {} + Expr::Arrow(arrow) => { + // export default async () => {} + // Use the span of the arrow function + let span = arrow.span; + + let (is_action_fn, is_cache_fn) = + has_body_directive(&if let BlockStmtOrExpr::BlockStmt(block) = &*arrow.body { + Some(block.clone()) + } else { + None + }); + + let is_cache = if is_action_fn { + false + } else if is_cache_fn { + true + } else { + in_cache_file + }; + + // If it's a self-annotated cache function, we need to skip + // collecting the exported ident. Otherwise it will be double- + // annotated. + // TODO(shu): This is a workaround. We should have a better way + // to skip self-annotated exports here. + if !(is_cache_fn && self.config.is_react_server_layer) { + let new_ident = Ident::new(self.gen_action_ident(), span, self.private_ctxt); + + self.exported_idents.push(( + new_ident.clone(), + "default".into(), + self.generate_server_reference_id( + "default", + is_cache, + Some( + &arrow + .params + .iter() + .map(|p| Param::from(p.clone())) + .collect(), + ), + ), + )); + + create_var_declarator(&new_ident, &mut self.extra_items); + assign_name_to_ident(&new_ident, "default", &mut self.extra_items); + + *default_expr.expr = assign_arrow_expr(&new_ident, Expr::Arrow(arrow.clone())); + } + } + Expr::Ident(ident) => { + // export default foo + self.exported_idents.push(( + ident.clone(), + "default".into(), + self.generate_server_reference_id("default", in_cache_file, None), + )); + } + Expr::Call(call) => { + // export default fn() + // Determining a useful span here is tricky. + let span = call.span; + + let new_ident = Ident::new(self.gen_action_ident(), span, self.private_ctxt); + + self.exported_idents.push(( + new_ident.clone(), + "default".into(), + self.generate_server_reference_id("default", in_cache_file, None), + )); + + create_var_declarator(&new_ident, &mut self.extra_items); + assign_name_to_ident(&new_ident, "default", &mut self.extra_items); + + *default_expr.expr = assign_arrow_expr(&new_ident, Expr::Call(call.clone())); + } + _ => { + disallowed_export_span = default_expr.span; + } + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportAll(ExportAll { span, .. })) => { + disallowed_export_span = *span; + } + _ => {} + } + + if disallowed_export_span != DUMMY_SP { + emit_error(ServerActionsErrorKind::ExportedSyncFunction { + span: disallowed_export_span, + in_action_file, + }); + + return; + } + } + + stmt.visit_mut_with(self); + + let mut new_stmt = stmt; + + if let Some(expr) = &self.rewrite_default_fn_expr_to_proxy_expr { + // If this happens, we need to replace the statement with a default export expr. + new_stmt = ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(ExportDefaultExpr { + span: DUMMY_SP, + expr: expr.clone(), + })); + self.rewrite_default_fn_expr_to_proxy_expr = None; + } + + if self.config.is_react_server_layer || self.file_directive.is_none() { + new.append(&mut self.hoisted_extra_items); + new.push(new_stmt); + new.extend(self.annotations.drain(..).map(ModuleItem::Stmt)); + new.append(&mut self.extra_items); + } + } + + let mut actions = self.export_actions.take(); + + if in_action_file || in_cache_file && !self.config.is_react_server_layer { + actions.extend( + self + .exported_idents + .iter() + .map(|e| (e.1.clone(), e.2.clone())), + ); + + if !actions.is_empty() { + self.has_action |= in_action_file; + self.has_cache |= in_cache_file; + } + }; + + // Make it a hashmap of id -> name. + let actions = actions + .into_iter() + .map(|a| (a.1, a.0)) + .collect::(); + + // If it's compiled in the client layer, each export field needs to be + // wrapped by a reference creation call. + let create_ref_ident = private_ident!("createServerReference"); + let call_server_ident = private_ident!("callServer"); + let find_source_map_url_ident = private_ident!("findSourceMapURL"); + + if (self.has_action || self.has_cache) && !self.config.is_react_server_layer { + // import { + // createServerReference, + // callServer, + // findSourceMapURL + // } from 'private-next-rsc-action-client-wrapper' + // createServerReference("action_id") + new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ + ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: create_ref_ident.clone(), + imported: None, + is_type_only: false, + }), + ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: call_server_ident.clone(), + imported: None, + is_type_only: false, + }), + ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: find_source_map_url_ident.clone(), + imported: None, + is_type_only: false, + }), + ], + src: Box::new(Str { + span: DUMMY_SP, + value: "private-next-rsc-action-client-wrapper".into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + }))); + new.rotate_right(1); + } + + // If it's a "use server" or a "use cache" file, all exports need to be annotated. + if should_track_exports { + for (ident, export_name, ref_id) in self.exported_idents.iter() { + if !self.config.is_react_server_layer { + if export_name == "default" { + self.comments.add_pure_comment(ident.span.lo); + + let export_expr = + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(ExportDefaultExpr { + span: DUMMY_SP, + expr: Box::new(Expr::Call(CallExpr { + span: ident.span, + callee: Callee::Expr(Box::new(Expr::Ident(create_ref_ident.clone()))), + args: vec![ + ref_id.clone().as_arg(), + call_server_ident.clone().as_arg(), + Expr::undefined(DUMMY_SP).as_arg(), + find_source_map_url_ident.clone().as_arg(), + "default".as_arg(), + ], + ..Default::default() + })), + })); + new.push(export_expr); + } else { + let call_expr_span = Span::dummy_with_cmt(); + self.comments.add_pure_comment(call_expr_span.lo); + + let export_expr = ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(IdentName::new(export_name.clone().into(), ident.span).into()), + init: Some(Box::new(Expr::Call(CallExpr { + span: call_expr_span, + callee: Callee::Expr(Box::new(Expr::Ident(create_ref_ident.clone()))), + args: vec![ + ref_id.clone().as_arg(), + call_server_ident.clone().as_arg(), + Expr::undefined(DUMMY_SP).as_arg(), + find_source_map_url_ident.clone().as_arg(), + export_name.clone().as_arg(), + ], + ..Default::default() + }))), + definite: false, + }], + ..Default::default() + })), + })); + new.push(export_expr); + } + } else if !in_cache_file { + self.annotations.push(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(annotate_ident_as_server_reference( + ident.clone(), + ref_id.to_string(), + ident.span, + &self.comments, + )), + })); + } + } + + // Ensure that the exports are functions by appending a runtime check: + // + // import { ensureServerEntryExports } from 'private-next-rsc-action-validate' + // ensureServerEntryExports([action1, action2, ...]) + // + // But it's only needed for the server layer, because on the client + // layer they're transformed into references already. + if (self.has_action || self.has_cache) && self.config.is_react_server_layer { + new.append(&mut self.extra_items); + + // For "use cache" files, there's no need to do extra annotations. + if !in_cache_file && !self.exported_idents.is_empty() { + let ensure_ident = private_ident!("ensureServerEntryExports"); + new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: ensure_ident.clone(), + imported: None, + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + value: "private-next-rsc-action-validate".into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + }))); + new.push(ModuleItem::Stmt(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Ident(ensure_ident))), + args: vec![ExprOrSpread { + spread: None, + expr: Box::new(Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: self + .exported_idents + .iter() + .map(|(ident, _, _)| { + Some(ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(ident.clone())), + }) + }) + .collect(), + })), + }], + ..Default::default() + })), + }))); + } + + // Append annotations to the end of the file. + new.extend(self.annotations.drain(..).map(ModuleItem::Stmt)); + } + } + + if self.has_action || self.has_cache { + // Prepend a special comment to the top of the file. + self.comments.add_leading( + self.start_pos, + Comment { + span: DUMMY_SP, + kind: CommentKind::Block, + text: generate_server_actions_comment(actions).into(), + }, + ); + } + + // import { cache as $$cache__ } from "private-next-rsc-cache-wrapper"; + if self.has_cache && self.config.is_react_server_layer { + new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: quote_ident!("$$cache__").into(), + imported: Some(quote_ident!("cache").into()), + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + value: "private-next-rsc-cache-wrapper".into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + }))); + + // Make it the first item + new.rotate_right(1); + } + + if (self.has_action || self.has_cache) && self.config.is_react_server_layer { + // Inlined actions are only allowed on the server layer. + // import { registerServerReference } from 'private-next-rsc-server-reference' + new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: quote_ident!("registerServerReference").into(), + imported: None, + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + value: "private-next-rsc-server-reference".into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + }))); + + // Encryption and decryption only happens on the server layer. + // import { encryptActionBoundArgs, decryptActionBoundArgs } from + // 'private-next-rsc-action-encryption' + new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ + ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: quote_ident!("encryptActionBoundArgs").into(), + imported: None, + is_type_only: false, + }), + ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: quote_ident!("decryptActionBoundArgs").into(), + imported: None, + is_type_only: false, + }), + ], + src: Box::new(Str { + span: DUMMY_SP, + value: "private-next-rsc-action-encryption".into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + }))); + + // Make it the first item + new.rotate_right(2); + } + + *stmts = new; + + self.annotations = old_annotations; + } + + fn visit_mut_stmts(&mut self, stmts: &mut Vec) { + let old_annotations = self.annotations.take(); + + let mut new = Vec::with_capacity(stmts.len()); + for mut stmt in stmts.take() { + stmt.visit_mut_with(self); + + new.push(stmt); + new.append(&mut self.annotations); + } + + *stmts = new; + + self.annotations = old_annotations; + } + + fn visit_mut_jsx_attr(&mut self, attr: &mut JSXAttr) { + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.take(); + + if let (Some(JSXAttrValue::JSXExprContainer(container)), JSXAttrName::Ident(ident_name)) = + (&attr.value, &attr.name) + { + match &container.expr { + JSXExpr::Expr(box Expr::Arrow(_)) | JSXExpr::Expr(box Expr::Fn(_)) => { + self.arrow_or_fn_expr_ident = Some(ident_name.clone().into()); + } + _ => {} + } + } + + attr.visit_mut_children_with(self); + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + } + + fn visit_mut_var_declarator(&mut self, var_declarator: &mut VarDeclarator) { + let old_in_exported_expr = self.in_exported_expr; + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.take(); + + if let (Pat::Ident(ident), Some(box Expr::Arrow(_) | box Expr::Fn(_))) = + (&var_declarator.name, &var_declarator.init) + { + if self.in_module_level && self.exported_local_ids.contains(&ident.to_id()) { + self.in_exported_expr = true + } + + self.arrow_or_fn_expr_ident = Some(ident.id.clone()); + } + + var_declarator.visit_mut_children_with(self); + + self.in_exported_expr = old_in_exported_expr; + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + } + + fn visit_mut_assign_expr(&mut self, assign_expr: &mut AssignExpr) { + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.clone(); + + if let ( + AssignTarget::Simple(SimpleAssignTarget::Ident(ident)), + box Expr::Arrow(_) | box Expr::Fn(_), + ) = (&assign_expr.left, &assign_expr.right) + { + // Ignore assignment expressions that we created. + if !ident.id.to_id().0.starts_with("$$RSC_SERVER_") { + self.arrow_or_fn_expr_ident = Some(ident.id.clone()); + } + } + + assign_expr.visit_mut_children_with(self); + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + } + + fn visit_mut_this_expr(&mut self, n: &mut ThisExpr) { + if let ThisStatus::Forbidden { directive } = &self.this_status { + emit_error(ServerActionsErrorKind::ForbiddenExpression { + span: n.span, + expr: "this".into(), + directive: directive.clone(), + }); + } + } + + fn visit_mut_super(&mut self, n: &mut Super) { + if let ThisStatus::Forbidden { directive } = &self.this_status { + emit_error(ServerActionsErrorKind::ForbiddenExpression { + span: n.span, + expr: "super".into(), + directive: directive.clone(), + }); + } + } + + fn visit_mut_ident(&mut self, n: &mut Ident) { + if n.sym == *"arguments" { + if let ThisStatus::Forbidden { directive } = &self.this_status { + emit_error(ServerActionsErrorKind::ForbiddenExpression { + span: n.span, + expr: "arguments".into(), + directive: directive.clone(), + }); + } + } + } + + noop_visit_mut_type!(); +} + +fn retain_names_from_declared_idents( + child_names: &mut Vec, + current_declared_idents: &[Ident], +) { + // Collect the names to retain in a separate vector + let mut retained_names = Vec::new(); + + for name in child_names.iter() { + let mut should_retain = true; + + // Merge child_names. For example if both `foo.bar` and `foo.bar.baz` are used, + // we only need to keep `foo.bar` as it covers the other. + + // Currently this is O(n^2) and we can potentially improve this to O(n log n) + // by sorting or using a hashset. + for another_name in child_names.iter() { + if name != another_name && name.0 == another_name.0 && name.1.len() >= another_name.1.len() { + let mut is_prefix = true; + for i in 0..another_name.1.len() { + if name.1[i] != another_name.1[i] { + is_prefix = false; + break; + } + } + if is_prefix { + should_retain = false; + break; + } + } + } + + if should_retain + && current_declared_idents + .iter() + .any(|ident| ident.to_id() == name.0) + && !retained_names.contains(name) + { + retained_names.push(name.clone()); + } + } + + // Replace the original child_names with the retained names + *child_names = retained_names; +} + +fn wrap_cache_expr(expr: Box, name: &str, id: &str, bound_args_len: usize) -> Box { + // expr -> $$cache__("name", "id", 0, expr) + Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("$$cache__").as_callee(), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(name.into()), + }, + ExprOrSpread { + spread: None, + expr: Box::new(id.into()), + }, + Number::from(bound_args_len).as_arg(), + expr.as_arg(), + ], + ..Default::default() + })) +} + +fn create_var_declarator(ident: &Ident, extra_items: &mut Vec) { + // Create the variable `var $$ACTION_0;` + extra_items.push(ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: ident.clone().into(), + init: None, + definite: Default::default(), + }], + ..Default::default() + }))))); +} + +fn assign_name_to_ident(ident: &Ident, name: &str, extra_items: &mut Vec) { + // Assign a name with `Object.defineProperty($$ACTION_0, 'name', {value: 'default'})` + extra_items.push(ModuleItem::Stmt(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(Expr::Ident(Ident::new( + "Object".into(), + DUMMY_SP, + ident.ctxt, + ))), + prop: MemberProp::Ident(IdentName::new("defineProperty".into(), DUMMY_SP)), + }))), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(ident.clone())), + }, + ExprOrSpread { + spread: None, + expr: Box::new("name".into()), + }, + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Object(ObjectLit { + span: DUMMY_SP, + props: vec![ + PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Str("value".into()), + value: Box::new(name.into()), + }))), + PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Str("writable".into()), + value: Box::new(false.into()), + }))), + ], + })), + }, + ], + ..Default::default() + })), + }))); +} + +fn assign_arrow_expr(ident: &Ident, expr: Expr) -> Expr { + if let Expr::Paren(_paren) = &expr { + expr + } else { + // Create the assignment `($$ACTION_0 = arrow)` + Expr::Paren(ParenExpr { + span: DUMMY_SP, + expr: Box::new(Expr::Assign(AssignExpr { + span: DUMMY_SP, + left: ident.clone().into(), + op: op!("="), + right: Box::new(expr), + })), + }) + } +} + +fn annotate_ident_as_server_reference( + ident: Ident, + action_id: String, + original_span: Span, + comments: &dyn Comments, +) -> Expr { + if !original_span.lo.is_dummy() { + comments.add_leading( + original_span.lo, + Comment { + kind: CommentKind::Block, + span: original_span, + text: "#__TURBOPACK_DISABLE_EXPORT_MERGING__".into(), + }, + ); + } + + // registerServerReference(reference, id, null) + Expr::Call(CallExpr { + span: original_span, + callee: quote_ident!("registerServerReference").as_callee(), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(ident)), + }, + ExprOrSpread { + spread: None, + expr: Box::new(action_id.clone().into()), + }, + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Null(Null { span: DUMMY_SP }))), + }, + ], + ..Default::default() + }) +} + +fn bind_args_to_ref_expr(expr: Expr, bound: Vec>, action_id: String) -> Expr { + if bound.is_empty() { + expr + } else { + // expr.bind(null, [encryptActionBoundArgs("id", [arg1, ...])]) + Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(expr), + prop: MemberProp::Ident(quote_ident!("bind")), + }) + .as_callee(), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Null(Null { span: DUMMY_SP }))), + }, + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("encryptActionBoundArgs").as_callee(), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(action_id.into()), + }, + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: bound, + })), + }, + ], + ..Default::default() + })), + }, + ], + ..Default::default() + }) + } +} + +// Detects if two strings are similar (but not the same). +// This implementation is fast and simple as it allows only one +// edit (add, remove, edit, swap), instead of using a N^2 Levenshtein algorithm. +// +// Example of similar strings of "use server": +// "use servers", +// "use-server", +// "use sevrer", +// "use srever", +// "use servre", +// "user server", +// +// This avoids accidental typos as there's currently no other static analysis +// tool to help when these mistakes happen. +fn detect_similar_strings(a: &str, b: &str) -> bool { + let mut a = a.chars().collect::>(); + let mut b = b.chars().collect::>(); + + if a.len() < b.len() { + (a, b) = (b, a); + } + + if a.len() == b.len() { + // Same length, get the number of character differences. + let mut diff = 0; + for i in 0..a.len() { + if a[i] != b[i] { + diff += 1; + if diff > 2 { + return false; + } + } + } + + // Should be 1 or 2, but not 0. + diff != 0 + } else { + if a.len() - b.len() > 1 { + return false; + } + + // A has one more character than B. + for i in 0..b.len() { + if a[i] != b[i] { + // This should be the only difference, a[i+1..] should be equal to b[i..]. + // Otherwise, they're not considered similar. + // A: "use srerver" + // B: "use server" + // ^ + return a[i + 1..] == b[i..]; + } + } + + // This happens when the last character of A is an extra character. + true + } +} + +// Check if the function or arrow function has any action or cache directives, +// without mutating the function body or erroring out. +// This is used to quickly determine if we need to use the module-level +// directives for this function or not. +fn has_body_directive(maybe_body: &Option) -> (bool, bool) { + let mut is_action_fn = false; + let mut is_cache_fn = false; + + if let Some(body) = maybe_body { + for stmt in body.stmts.iter() { + match stmt { + Stmt::Expr(ExprStmt { + expr: box Expr::Lit(Lit::Str(Str { value, .. })), + .. + }) => { + if value == "use server" { + is_action_fn = true; + break; + } else if value == "use cache" || value.starts_with("use cache: ") { + is_cache_fn = true; + break; + } + } + _ => break, + } + } + } + + (is_action_fn, is_cache_fn) +} + +fn collect_idents_in_array_pat(elems: &[Option], idents: &mut Vec) { + for elem in elems.iter().flatten() { + match elem { + Pat::Ident(ident) => { + idents.push(ident.id.clone()); + } + Pat::Array(array) => { + collect_idents_in_array_pat(&array.elems, idents); + } + Pat::Object(object) => { + collect_idents_in_object_pat(&object.props, idents); + } + Pat::Rest(rest) => { + if let Pat::Ident(ident) = &*rest.arg { + idents.push(ident.id.clone()); + } + } + Pat::Assign(AssignPat { left, .. }) => { + collect_idents_in_pat(left, idents); + } + Pat::Expr(..) | Pat::Invalid(..) => {} + } + } +} + +fn collect_idents_in_object_pat(props: &[ObjectPatProp], idents: &mut Vec) { + for prop in props { + match prop { + ObjectPatProp::KeyValue(KeyValuePatProp { key, value }) => { + if let PropName::Ident(ident) = key { + idents.push(Ident::new( + ident.sym.clone(), + ident.span, + SyntaxContext::empty(), + )); + } + + match &**value { + Pat::Ident(ident) => { + idents.push(ident.id.clone()); + } + Pat::Array(array) => { + collect_idents_in_array_pat(&array.elems, idents); + } + Pat::Object(object) => { + collect_idents_in_object_pat(&object.props, idents); + } + _ => {} + } + } + ObjectPatProp::Assign(AssignPatProp { key, .. }) => { + idents.push(key.id.clone()); + } + ObjectPatProp::Rest(RestPat { arg, .. }) => { + if let Pat::Ident(ident) = &**arg { + idents.push(ident.id.clone()); + } + } + } + } +} + +fn collect_idents_in_var_decls(decls: &[VarDeclarator], idents: &mut Vec) { + for decl in decls { + collect_idents_in_pat(&decl.name, idents); + } +} + +fn collect_idents_in_pat(pat: &Pat, idents: &mut Vec) { + match pat { + Pat::Ident(ident) => { + idents.push(ident.id.clone()); + } + Pat::Array(array) => { + collect_idents_in_array_pat(&array.elems, idents); + } + Pat::Object(object) => { + collect_idents_in_object_pat(&object.props, idents); + } + Pat::Assign(AssignPat { left, .. }) => { + collect_idents_in_pat(left, idents); + } + Pat::Rest(RestPat { arg, .. }) => { + if let Pat::Ident(ident) = &**arg { + idents.push(ident.id.clone()); + } + } + Pat::Expr(..) | Pat::Invalid(..) => {} + } +} + +fn collect_decl_idents_in_stmt(stmt: &Stmt, idents: &mut Vec) { + if let Stmt::Decl(decl) = stmt { + match decl { + Decl::Var(var) => { + collect_idents_in_var_decls(&var.decls, idents); + } + Decl::Fn(fn_decl) => { + idents.push(fn_decl.ident.clone()); + } + _ => {} + } + } +} + +struct DirectiveVisitor<'a> { + config: &'a Config, + location: DirectiveLocation, + directive: Option, + has_file_directive: bool, + is_allowed_position: bool, +} + +impl DirectiveVisitor<'_> { + /** + * Returns `true` if the statement contains a server directive. + * The found directive is assigned to `DirectiveVisitor::directive`. + */ + fn visit_stmt(&mut self, stmt: &Stmt) -> bool { + let in_fn_body = matches!(self.location, DirectiveLocation::FunctionBody); + let allow_inline = self.config.is_react_server_layer || self.has_file_directive; + + match stmt { + Stmt::Expr(ExprStmt { + expr: box Expr::Lit(Lit::Str(Str { value, span, .. })), + .. + }) => { + if value == "use server" { + if in_fn_body && !allow_inline { + emit_error(ServerActionsErrorKind::InlineUseServerInClientComponent { span: *span }) + } else if let Some(Directive::UseCache { .. }) = self.directive { + emit_error(ServerActionsErrorKind::MultipleDirectives { + span: *span, + location: self.location.clone(), + }); + } else if self.is_allowed_position { + self.directive = Some(Directive::UseServer); + + return true; + } else { + emit_error(ServerActionsErrorKind::MisplacedDirective { + span: *span, + directive: value.to_string(), + location: self.location.clone(), + }); + } + } else if detect_similar_strings(value, "use server") { + // Detect typo of "use server" + emit_error(ServerActionsErrorKind::MisspelledDirective { + span: *span, + directive: value.to_string(), + expected_directive: "use server".to_string(), + }); + } else + // `use cache` or `use cache: foo` + if value == "use cache" || value.starts_with("use cache: ") { + if in_fn_body && !allow_inline { + emit_error(ServerActionsErrorKind::InlineUseCacheInClientComponent { span: *span }) + } else if let Some(Directive::UseServer) = self.directive { + emit_error(ServerActionsErrorKind::MultipleDirectives { + span: *span, + location: self.location.clone(), + }); + } else if self.is_allowed_position { + if !self.config.dynamic_io_enabled { + emit_error(ServerActionsErrorKind::UseCacheWithoutDynamicIO { + span: *span, + directive: value.to_string(), + }); + } + + if value == "use cache" { + self.directive = Some(Directive::UseCache { + cache_kind: Arc::from("default"), + }); + } else { + // Slice the value after "use cache: " + let cache_kind = Arc::from(value.split_at("use cache: ".len()).1); + + if !self.config.cache_kinds.contains(&cache_kind) { + emit_error(ServerActionsErrorKind::UnknownCacheKind { + span: *span, + cache_kind: cache_kind.clone(), + }); + } + + self.directive = Some(Directive::UseCache { cache_kind }); + } + + return true; + } else { + emit_error(ServerActionsErrorKind::MisplacedDirective { + span: *span, + directive: value.to_string(), + location: self.location.clone(), + }); + } + } else { + // Detect typo of "use cache" + if detect_similar_strings(value, "use cache") { + emit_error(ServerActionsErrorKind::MisspelledDirective { + span: *span, + directive: value.to_string(), + expected_directive: "use cache".to_string(), + }); + } + } + } + Stmt::Expr(ExprStmt { + expr: + box Expr::Paren(ParenExpr { + expr: box Expr::Lit(Lit::Str(Str { value, .. })), + .. + }), + span, + .. + }) => { + // Match `("use server")`. + if value == "use server" || detect_similar_strings(value, "use server") { + if self.is_allowed_position { + emit_error(ServerActionsErrorKind::WrappedDirective { + span: *span, + directive: "use server".to_string(), + }); + } else { + emit_error(ServerActionsErrorKind::MisplacedWrappedDirective { + span: *span, + directive: "use server".to_string(), + location: self.location.clone(), + }); + } + } else if value == "use cache" || detect_similar_strings(value, "use cache") { + if self.is_allowed_position { + emit_error(ServerActionsErrorKind::WrappedDirective { + span: *span, + directive: "use cache".to_string(), + }); + } else { + emit_error(ServerActionsErrorKind::MisplacedWrappedDirective { + span: *span, + directive: "use cache".to_string(), + location: self.location.clone(), + }); + } + } + } + _ => { + // Directives must not be placed after other statements. + self.is_allowed_position = false; + } + }; + + false + } +} + +pub(crate) struct ClosureReplacer<'a> { + used_ids: &'a [Name], + private_ctxt: SyntaxContext, +} + +impl ClosureReplacer<'_> { + fn index(&self, e: &Expr) -> Option { + let name = Name::try_from(e).ok()?; + self.used_ids.iter().position(|used_id| *used_id == name) + } +} + +impl VisitMut for ClosureReplacer<'_> { + fn visit_mut_expr(&mut self, e: &mut Expr) { + e.visit_mut_children_with(self); + + if let Some(index) = self.index(e) { + *e = Expr::Ident(Ident::new( + // $$ACTION_ARG_0 + format!("$$ACTION_ARG_{index}").into(), + DUMMY_SP, + self.private_ctxt, + )); + } + } + + fn visit_mut_prop_or_spread(&mut self, n: &mut PropOrSpread) { + n.visit_mut_children_with(self); + + if let PropOrSpread::Prop(box Prop::Shorthand(i)) = n { + let name = Name::from(&*i); + if let Some(index) = self.used_ids.iter().position(|used_id| *used_id == name) { + *n = PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(i.clone().into()), + value: Box::new(Expr::Ident(Ident::new( + // $$ACTION_ARG_0 + format!("$$ACTION_ARG_{index}").into(), + DUMMY_SP, + self.private_ctxt, + ))), + }))); + } + } + } + + noop_visit_mut_type!(); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NamePart { + prop: JsWord, + is_member: bool, + optional: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Name(Id, Vec); + +impl From<&'_ Ident> for Name { + fn from(value: &Ident) -> Self { + Name(value.to_id(), vec![]) + } +} + +impl TryFrom<&'_ Expr> for Name { + type Error = (); + + fn try_from(value: &Expr) -> Result { + match value { + Expr::Ident(i) => Ok(Name(i.to_id(), vec![])), + Expr::Member(e) => e.try_into(), + Expr::OptChain(e) => e.try_into(), + _ => Err(()), + } + } +} + +impl TryFrom<&'_ MemberExpr> for Name { + type Error = (); + + fn try_from(value: &MemberExpr) -> Result { + match &value.prop { + MemberProp::Ident(prop) => { + let mut obj: Name = value.obj.as_ref().try_into()?; + obj.1.push(NamePart { + prop: prop.sym.clone(), + is_member: true, + optional: false, + }); + Ok(obj) + } + _ => Err(()), + } + } +} + +impl TryFrom<&'_ OptChainExpr> for Name { + type Error = (); + + fn try_from(value: &OptChainExpr) -> Result { + match &*value.base { + OptChainBase::Member(m) => match &m.prop { + MemberProp::Ident(prop) => { + let mut obj: Name = m.obj.as_ref().try_into()?; + obj.1.push(NamePart { + prop: prop.sym.clone(), + is_member: false, + optional: value.optional, + }); + Ok(obj) + } + _ => Err(()), + }, + OptChainBase::Call(_) => Err(()), + } + } +} + +impl From for Box { + fn from(value: Name) -> Self { + let mut expr = Box::new(Expr::Ident(value.0.into())); + + for NamePart { + prop, + is_member, + optional, + } in value.1.into_iter() + { + if is_member { + expr = Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: expr, + prop: MemberProp::Ident(IdentName::new(prop, DUMMY_SP)), + })); + } else { + expr = Box::new(Expr::OptChain(OptChainExpr { + span: DUMMY_SP, + base: Box::new(OptChainBase::Member(MemberExpr { + span: DUMMY_SP, + obj: expr, + prop: MemberProp::Ident(IdentName::new(prop, DUMMY_SP)), + })), + optional, + })); + } + } + + expr + } +} + +fn emit_error(error_kind: ServerActionsErrorKind) { + let (span, msg) = match error_kind { + ServerActionsErrorKind::ExportedSyncFunction { + span, + in_action_file, + } => ( + span, + formatdoc! { + r#" + Only async functions are allowed to be exported in a {directive} file. + "#, + directive = if in_action_file { + "\"use server\"" + } else { + "\"use cache\"" + } + }, + ), + ServerActionsErrorKind::ForbiddenExpression { + span, + expr, + directive, + } => ( + span, + formatdoc! { + r#" + {subject} cannot use `{expr}`. + "#, + subject = if let Directive::UseServer = directive { + "Server Actions" + } else { + "\"use cache\" functions" + } + }, + ), + ServerActionsErrorKind::InlineUseCacheInClassInstanceMethod { span } => ( + span, + formatdoc! { + r#" + It is not allowed to define inline "use cache" annotated class instance methods. + To define cached functions, use functions, object method properties, or static class methods instead. + "# + }, + ), + ServerActionsErrorKind::InlineUseCacheInClientComponent { span } => ( + span, + formatdoc! { + r#" + It is not allowed to define inline "use cache" annotated functions in Client Components. + To use "use cache" functions in a Client Component, you can either export them from a separate file with "use cache" or "use server" at the top, or pass them down through props from a Server Component. + "# + }, + ), + ServerActionsErrorKind::InlineUseServerInClassInstanceMethod { span } => ( + span, + formatdoc! { + r#" + It is not allowed to define inline "use server" annotated class instance methods. + To define Server Actions, use functions, object method properties, or static class methods instead. + "# + }, + ), + ServerActionsErrorKind::InlineUseServerInClientComponent { span } => ( + span, + formatdoc! { + r#" + It is not allowed to define inline "use server" annotated Server Actions in Client Components. + To use Server Actions in a Client Component, you can either export them from a separate file with "use server" at the top, or pass them down through props from a Server Component. + + Read more: https://nextjs.org/docs/app/api-reference/functions/server-actions#with-client-components + "# + }, + ), + ServerActionsErrorKind::InlineSyncFunction { span, directive } => ( + span, + formatdoc! { + r#" + {subject} must be async functions. + "#, + subject = if let Directive::UseServer = directive { + "Server Actions" + } else { + "\"use cache\" functions" + } + }, + ), + ServerActionsErrorKind::MisplacedDirective { + span, + directive, + location, + } => ( + span, + formatdoc! { + r#" + The "{directive}" directive must be at the top of the {location}. + "#, + location = match location { + DirectiveLocation::Module => "file", + DirectiveLocation::FunctionBody => "function body", + } + }, + ), + ServerActionsErrorKind::MisplacedWrappedDirective { + span, + directive, + location, + } => ( + span, + formatdoc! { + r#" + The "{directive}" directive must be at the top of the {location}, and cannot be wrapped in parentheses. + "#, + location = match location { + DirectiveLocation::Module => "file", + DirectiveLocation::FunctionBody => "function body", + } + }, + ), + ServerActionsErrorKind::MisspelledDirective { + span, + directive, + expected_directive, + } => ( + span, + formatdoc! { + r#" + Did you mean "{expected_directive}"? "{directive}" is not a supported directive name." + "# + }, + ), + ServerActionsErrorKind::MultipleDirectives { span, location } => ( + span, + formatdoc! { + r#" + Conflicting directives "use server" and "use cache" found in the same {location}. You cannot place both directives at the top of a {location}. Please remove one of them. + "#, + location = match location { + DirectiveLocation::Module => "file", + DirectiveLocation::FunctionBody => "function body", + } + }, + ), + ServerActionsErrorKind::UnknownCacheKind { span, cache_kind } => ( + span, + formatdoc! { + r#" + Unknown cache kind "{cache_kind}". Please configure a cache handler for this kind in the "experimental.cacheHandlers" object in your Next.js config. + "# + }, + ), + ServerActionsErrorKind::UseCacheWithoutDynamicIO { span, directive } => ( + span, + formatdoc! { + r#" + To use "{directive}", please enable the experimental feature flag "dynamicIO" in your Next.js config. + + Read more: https://nextjs.org/docs/canary/app/api-reference/directives/use-cache#usage + "# + }, + ), + ServerActionsErrorKind::WrappedDirective { span, directive } => ( + span, + formatdoc! { + r#" + The "{directive}" directive cannot be wrapped in parentheses. + "# + }, + ), + }; + + HANDLER.with(|handler| handler.struct_span_err(span, &msg).emit()); +} diff --git a/crates/next-custom-transforms/src/transforms/shake_exports.rs b/crates/next-custom-transforms/src/transforms/shake_exports.rs new file mode 100644 index 000000000000..5900369ef2a4 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/shake_exports.rs @@ -0,0 +1,123 @@ +use serde::Deserialize; +use swc_core::{ + common::Mark, + ecma::{ + ast::*, + atoms::{js_word, JsWord}, + transforms::optimization::simplify::dce::{dce, Config as DCEConfig}, + visit::{fold_pass, Fold, FoldWith, VisitMutWith}, + }, +}; + +#[derive(Clone, Debug, Deserialize)] +pub struct Config { + pub ignore: Vec, +} + +pub fn shake_exports(config: Config) -> impl Pass { + fold_pass(ExportShaker { + ignore: config.ignore, + ..Default::default() + }) +} + +#[derive(Debug, Default)] +struct ExportShaker { + ignore: Vec, + remove_export: bool, +} + +impl Fold for ExportShaker { + fn fold_module(&mut self, mut module: Module) -> Module { + module = module.fold_children_with(self); + module.visit_mut_with(&mut dce(DCEConfig::default(), Mark::new())); + module + } + + fn fold_module_items(&mut self, items: Vec) -> Vec { + let mut new_items = vec![]; + for item in items { + let item = item.fold_children_with(self); + if !self.remove_export { + new_items.push(item) + } + self.remove_export = false; + } + new_items + } + + fn fold_export_decl(&mut self, mut decl: ExportDecl) -> ExportDecl { + match &mut decl.decl { + Decl::Fn(fn_decl) => { + if !self.ignore.contains(&fn_decl.ident.sym) { + self.remove_export = true; + } + } + Decl::Class(class_decl) => { + if !self.ignore.contains(&class_decl.ident.sym) { + self.remove_export = true; + } + } + Decl::Var(var_decl) => { + var_decl.decls = var_decl + .decls + .iter() + .filter_map(|var_decl| { + if let Pat::Ident(BindingIdent { id, .. }) = &var_decl.name { + if self.ignore.contains(&id.sym) { + return Some(var_decl.to_owned()); + } + } + None + }) + .collect(); + if var_decl.decls.is_empty() { + self.remove_export = true; + } + } + _ => {} + } + decl + } + + fn fold_named_export(&mut self, mut export: NamedExport) -> NamedExport { + export.specifiers = export + .specifiers + .into_iter() + .filter_map(|spec| { + if let ExportSpecifier::Named(named_spec) = spec { + if let Some(ident) = &named_spec.exported { + if let ModuleExportName::Ident(ident) = ident { + if self.ignore.contains(&ident.sym) { + return Some(ExportSpecifier::Named(named_spec)); + } + } + } else if let ModuleExportName::Ident(ident) = &named_spec.orig { + if self.ignore.contains(&ident.sym) { + return Some(ExportSpecifier::Named(named_spec)); + } + } + } + None + }) + .collect(); + if export.specifiers.is_empty() { + self.remove_export = true + } + export + } + + fn fold_export_default_decl(&mut self, decl: ExportDefaultDecl) -> ExportDefaultDecl { + if !self.ignore.contains(&js_word!("default")) { + self.remove_export = true + } + decl + } + + fn fold_export_default_expr(&mut self, expr: ExportDefaultExpr) -> ExportDefaultExpr { + if !self.ignore.contains(&js_word!("default")) { + self.remove_export = true + } + expr + } +} diff --git a/crates/next-custom-transforms/src/transforms/strip_page_exports.rs b/crates/next-custom-transforms/src/transforms/strip_page_exports.rs new file mode 100644 index 000000000000..921f443b60b4 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/strip_page_exports.rs @@ -0,0 +1,1077 @@ +//! The original transform is available on the [Next.js repository](https://github.com/vercel/next.js/blob/f7fecf00cb40c2f784387ff8ccc5e213b8bdd9ca/packages/next-swc/crates/core/src/next_ssg.rs): +//! +//! This version adds support for eliminating client-side exports only. +//! **TODO** may consolidate into next_ssg + +use std::{cell::RefCell, mem::take, rc::Rc}; + +use rustc_hash::{FxHashMap, FxHashSet}; +use swc_core::{ + common::{ + errors::HANDLER, + pass::{Repeat, Repeated}, + DUMMY_SP, + }, + ecma::{ + ast::*, + visit::{fold_pass, noop_fold_type, noop_visit_type, Fold, FoldWith, Visit, VisitWith}, + }, +}; + +/// Determines which exports to remove. +#[derive(Debug, Default, Clone, Copy)] +pub enum ExportFilter { + /// Strip all data exports (getServerSideProps, + /// getStaticProps, getStaticPaths exports.) and their unique dependencies. + #[default] + StripDataExports, + /// Strip default export and all its unique dependencies. + StripDefaultExport, +} + +#[derive(Debug, Default, Clone, Copy)] +pub enum PageMode { + #[default] + None, + /// The Next.js page is declaring `getServerSideProps`. + Ssr, + /// The Next.js page is declaring `getStaticProps` and/or `getStaticPaths`. + Ssg, +} + +impl PageMode { + /// Which identifier (if any) to export in the output file. + fn data_marker(self) -> Option<&'static str> { + match self { + PageMode::None => None, + PageMode::Ssr => Some("__N_SSP"), + PageMode::Ssg => Some("__N_SSG"), + } + } +} + +/// A transform that either: +/// * strips Next.js data exports (getServerSideProps, getStaticProps, getStaticPaths); or +/// * strips the default export. +/// +/// Note: This transform requires running `resolver` **before** running it. +pub fn next_transform_strip_page_exports( + filter: ExportFilter, + ssr_removed_packages: Rc>>, +) -> impl Pass { + fold_pass(Repeat::new(NextSsg { + state: State { + ssr_removed_packages, + filter, + ..Default::default() + }, + in_lhs_of_var: false, + remove_expression: false, + })) +} + +/// State of the transforms. Shared by the analyzer and the transform. +#[derive(Debug, Default)] +struct State { + filter: ExportFilter, + + page_mode: PageMode, + + exports: FxHashMap, + + /// Identifiers referenced in the body of preserved functions. + /// + /// Cleared before running each pass, because we drop ast nodes between the + /// passes. + refs_from_preserved: FxHashSet, + + /// Identifiers referenced in the body of removed functions or + /// derivatives. + /// + /// Preserved between runs, because we should remember derivatives of data + /// functions as the data function itself is already removed. + refs_from_removed: FxHashSet, + + /// Identifiers of functions currently being declared, the body of which we + /// are currently visiting. + cur_declaring: FxHashSet, + + /// `true` if the transform has added a page mode marker to the AST. + added_data_marker: bool, + + should_run_again: bool, + + /// Track the import packages which are removed alongside + /// `getServerSideProps` in SSR. + ssr_removed_packages: Rc>>, +} + +/// The type of export associated to an identifier. +#[derive(Debug, Clone, Copy)] +enum ExportType { + Default, + GetServerSideProps, + GetStaticPaths, + GetStaticProps, +} + +impl ExportType { + fn from_specifier(specifier: &ExportSpecifier) -> Option> { + match specifier { + ExportSpecifier::Default(ExportDefaultSpecifier { exported, .. }) + | ExportSpecifier::Namespace(ExportNamespaceSpecifier { + name: ModuleExportName::Ident(exported), + .. + }) => { + let export_type = ExportType::from_ident(exported)?; + Some(ExportTypeResult { + exported_ident: exported, + local_ident: None, + export_type, + }) + } + + ExportSpecifier::Named(ExportNamedSpecifier { + exported: Some(ModuleExportName::Ident(exported)), + orig: ModuleExportName::Ident(orig), + .. + }) + | ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(orig @ exported), + .. + }) => { + let export_type = ExportType::from_ident(exported)?; + Some(ExportTypeResult { + exported_ident: exported, + local_ident: Some(orig), + export_type, + }) + } + _ => None, + } + } + + fn from_ident(ident: &Ident) -> Option { + Some(match &*ident.sym { + "default" => ExportType::Default, + "getStaticProps" => ExportType::GetStaticProps, + "getStaticPaths" => ExportType::GetStaticPaths, + "getServerSideProps" => ExportType::GetServerSideProps, + _ => return None, + }) + } +} + +struct ExportTypeResult<'a> { + exported_ident: &'a Ident, + local_ident: Option<&'a Ident>, + export_type: ExportType, +} + +impl State { + fn encounter_export( + &mut self, + exported_ident: &Ident, + local_ident: Option<&Ident>, + export_type: ExportType, + ) { + match export_type { + ExportType::GetServerSideProps => { + if matches!(self.page_mode, PageMode::Ssg) { + HANDLER.with(|handler| { + handler + .struct_span_err( + exported_ident.span, + "You can not use getStaticProps or getStaticPaths with \ + getServerSideProps. To use SSG, please remove getServerSideProps", + ) + .emit() + }); + return; + } + + self.page_mode = PageMode::Ssr; + } + ExportType::GetStaticPaths | ExportType::GetStaticProps => { + if matches!(self.page_mode, PageMode::Ssr) { + HANDLER.with(|handler| { + handler + .struct_span_err( + exported_ident.span, + "You can not use getStaticProps or getStaticPaths with \ + getServerSideProps. To use SSG, please remove getServerSideProps", + ) + .emit() + }); + return; + } + + self.page_mode = PageMode::Ssg; + } + _ => {} + } + + let local_ident = local_ident.unwrap_or(exported_ident); + + self.exports.insert(local_ident.to_id(), export_type); + } + + fn export_type(&self, id: &Id) -> Option { + self.exports.get(id).copied() + } + + fn should_retain_export_type(&self, export_type: ExportType) -> bool { + !matches!( + (self.filter, export_type), + ( + ExportFilter::StripDataExports, + ExportType::GetServerSideProps | ExportType::GetStaticProps | ExportType::GetStaticPaths, + ) | (ExportFilter::StripDefaultExport, ExportType::Default) + ) + } + + fn should_retain_id(&self, id: &Id) -> bool { + if let Some(export_type) = self.export_type(id) { + self.should_retain_export_type(export_type) + } else { + true + } + } + + fn dropping_export(&mut self, export_type: ExportType) -> bool { + if !self.should_retain_export_type(export_type) { + // If there are any assignments on the exported identifier, they'll + // need to be removed as well in the next pass. + self.should_run_again = true; + true + } else { + false + } + } +} + +struct Analyzer<'a> { + state: &'a mut State, + in_lhs_of_var: bool, + in_removed_item: bool, +} + +impl Analyzer<'_> { + fn add_ref(&mut self, id: Id) { + tracing::trace!( + "add_ref({}{:?}, in_removed_item = {:?})", + id.0, + id.1, + self.in_removed_item, + ); + if self.in_removed_item { + self.state.refs_from_removed.insert(id); + } else { + if self.state.cur_declaring.contains(&id) { + return; + } + + self.state.refs_from_preserved.insert(id); + } + } + + fn within_declaration(&mut self, id: &Id, f: impl FnOnce(&mut Self) -> R) -> R { + self.state.cur_declaring.insert(id.clone()); + let res = f(self); + self.state.cur_declaring.remove(id); + res + } + + fn within_removed_item(&mut self, in_removed_item: bool, f: impl FnOnce(&mut Self) -> R) -> R { + let old = self.in_removed_item; + // `in_removed_item` is strictly additive. + self.in_removed_item |= in_removed_item; + let res = f(self); + self.in_removed_item = old; + res + } + + fn within_lhs_of_var(&mut self, in_lhs_of_var: bool, f: impl FnOnce(&mut Self) -> R) -> R { + let old = self.in_lhs_of_var; + self.in_lhs_of_var = in_lhs_of_var; + let res = f(self); + self.in_lhs_of_var = old; + res + } + + fn visit_declaration(&mut self, id: &Id, d: &D) + where + D: VisitWith, + { + self.within_declaration(id, |this| { + let in_removed_item = !this.state.should_retain_id(id); + this.within_removed_item(in_removed_item, |this| { + tracing::trace!( + "transform_page: Handling `{}{:?}`; in_removed_item = {:?}", + id.0, + id.1, + this.in_removed_item + ); + + d.visit_children_with(this); + }); + }); + } +} + +impl Visit for Analyzer<'_> { + // This is important for reducing binary sizes. + noop_visit_type!(); + + fn visit_binding_ident(&mut self, i: &BindingIdent) { + if !self.in_lhs_of_var || self.in_removed_item { + self.add_ref(i.id.to_id()); + } + } + + fn visit_named_export(&mut self, n: &NamedExport) { + for specifier in &n.specifiers { + if let Some(ExportTypeResult { + exported_ident, + local_ident, + export_type, + }) = ExportType::from_specifier(specifier) + { + self + .state + .encounter_export(exported_ident, local_ident, export_type); + + if let Some(local_ident) = local_ident { + if self.state.should_retain_export_type(export_type) { + self.add_ref(local_ident.to_id()); + } + } + } + } + } + + fn visit_export_decl(&mut self, s: &ExportDecl) { + match &s.decl { + Decl::Var(d) => { + for decl in &d.decls { + if let Pat::Ident(ident) = &decl.name { + if let Some(export_type) = ExportType::from_ident(ident) { + self.state.encounter_export(ident, None, export_type); + + let retain = self.state.should_retain_export_type(export_type); + + if retain { + self.add_ref(ident.to_id()); + } + + self.within_removed_item(!retain, |this| { + decl.visit_with(this); + }); + } else { + // Always preserve declarations of unknown exports. + self.add_ref(ident.to_id()); + + decl.visit_with(self) + } + } else { + decl.visit_with(self) + } + } + } + Decl::Fn(decl) => { + let ident = &decl.ident; + if let Some(export_type) = ExportType::from_ident(ident) { + self.state.encounter_export(ident, None, export_type); + + let retain = self.state.should_retain_export_type(export_type); + + if retain { + self.add_ref(ident.to_id()); + } + + self.within_removed_item(!retain, |this| { + decl.visit_with(this); + }); + } else { + s.visit_children_with(self); + } + } + _ => s.visit_children_with(self), + } + } + + fn visit_export_default_decl(&mut self, s: &ExportDefaultDecl) { + match &s.decl { + DefaultDecl::Class(ClassExpr { + ident: Some(ident), .. + }) => self + .state + .encounter_export(ident, Some(ident), ExportType::Default), + DefaultDecl::Fn(FnExpr { + ident: Some(ident), .. + }) => self + .state + .encounter_export(ident, Some(ident), ExportType::Default), + _ => {} + } + self.within_removed_item( + matches!(self.state.filter, ExportFilter::StripDefaultExport), + |this| { + s.visit_children_with(this); + }, + ); + } + + fn visit_export_default_expr(&mut self, s: &ExportDefaultExpr) { + self.within_removed_item( + matches!(self.state.filter, ExportFilter::StripDefaultExport), + |this| { + s.visit_children_with(this); + }, + ); + } + + fn visit_expr(&mut self, e: &Expr) { + e.visit_children_with(self); + + if let Expr::Ident(i) = &e { + self.add_ref(i.to_id()); + } + } + + fn visit_jsx_element(&mut self, jsx: &JSXElement) { + fn get_leftmost_id_member_expr(e: &JSXMemberExpr) -> Id { + match &e.obj { + JSXObject::Ident(i) => i.to_id(), + JSXObject::JSXMemberExpr(e) => get_leftmost_id_member_expr(e), + } + } + + match &jsx.opening.name { + JSXElementName::Ident(i) => { + self.add_ref(i.to_id()); + } + JSXElementName::JSXMemberExpr(e) => { + self.add_ref(get_leftmost_id_member_expr(e)); + } + _ => {} + } + + jsx.visit_children_with(self); + } + + fn visit_fn_decl(&mut self, f: &FnDecl) { + self.visit_declaration(&f.ident.to_id(), f); + } + + fn visit_class_decl(&mut self, c: &ClassDecl) { + self.visit_declaration(&c.ident.to_id(), c); + } + + fn visit_fn_expr(&mut self, f: &FnExpr) { + f.visit_children_with(self); + + if let Some(id) = &f.ident { + self.add_ref(id.to_id()); + } + } + + fn visit_prop(&mut self, p: &Prop) { + p.visit_children_with(self); + + if let Prop::Shorthand(i) = &p { + self.add_ref(i.to_id()); + } + } + + fn visit_var_declarator(&mut self, v: &VarDeclarator) { + let in_removed_item = if let Pat::Ident(name) = &v.name { + !self.state.should_retain_id(&name.id.to_id()) + } else { + false + }; + + self.within_removed_item(in_removed_item, |this| { + this.within_lhs_of_var(true, |this| { + v.name.visit_with(this); + }); + + this.within_lhs_of_var(false, |this| { + v.init.visit_with(this); + }); + }); + } + + fn visit_member_expr(&mut self, e: &MemberExpr) { + let in_removed_item = if let Some(id) = find_member_root_id(e) { + !self.state.should_retain_id(&id) + } else { + false + }; + + self.within_removed_item(in_removed_item, |this| { + e.visit_children_with(this); + }); + } + + fn visit_assign_expr(&mut self, e: &AssignExpr) { + self.within_lhs_of_var(true, |this| { + e.left.visit_with(this); + }); + + self.within_lhs_of_var(false, |this| { + e.right.visit_with(this); + }); + } +} + +/// Actual implementation of the transform. +struct NextSsg { + pub state: State, + in_lhs_of_var: bool, + /// Marker set when a top-level expression item should be removed. This + /// occurs when visiting assignments on eliminated identifiers. + remove_expression: bool, +} + +impl NextSsg { + /// Returns `true` when an identifier should be removed from the output. + fn should_remove(&self, id: &Id) -> bool { + self.state.refs_from_removed.contains(id) && !self.state.refs_from_preserved.contains(id) + } + + /// Mark identifiers in `n` as a candidate for elimination. + fn mark_as_candidate(&mut self, n: &N) + where + N: for<'aa> VisitWith> + std::fmt::Debug, + { + tracing::debug!("mark_as_candidate: {:?}", n); + + let mut v = Analyzer { + state: &mut self.state, + in_lhs_of_var: false, + // Analyzer never change `in_removed_item`, so all identifiers in `n` + // will be marked as referenced from an removed item. + in_removed_item: true, + }; + + n.visit_with(&mut v); + self.state.should_run_again = true; + } + + /// Adds __N_SSG and __N_SSP declarations when eliminating data functions. + fn maybe_add_data_marker(&mut self, items: &mut Vec) { + if !matches!(self.state.filter, ExportFilter::StripDataExports) + || self.state.added_data_marker + || self.state.should_run_again + { + return; + } + + let Some(data_marker) = self.state.page_mode.data_marker() else { + return; + }; + + self.state.added_data_marker = true; + + if items.iter().any(|s| s.is_module_decl()) { + let insert_idx = items.iter().position(|item| { + matches!( + item, + ModuleItem::ModuleDecl( + ModuleDecl::ExportNamed(..) + | ModuleDecl::ExportDecl(..) + | ModuleDecl::ExportDefaultDecl(..) + | ModuleDecl::ExportDefaultExpr(..), + ) + ) + }); + + if let Some(insert_idx) = insert_idx { + items.insert( + insert_idx, + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(IdentName::new(data_marker.into(), DUMMY_SP).into()), + init: Some(true.into()), + definite: Default::default(), + }], + ..Default::default() + })), + })), + ); + } + } + } + + fn within_lhs_of_var(&mut self, in_lhs_of_var: bool, f: impl FnOnce(&mut Self) -> R) -> R { + let old = self.in_lhs_of_var; + self.in_lhs_of_var = in_lhs_of_var; + let res = f(self); + self.in_lhs_of_var = old; + res + } +} + +impl Repeated for NextSsg { + fn changed(&self) -> bool { + self.state.should_run_again + } + + fn reset(&mut self) { + self.state.refs_from_preserved.clear(); + self.state.cur_declaring.clear(); + self.state.should_run_again = false; + } +} + +/// `VisitMut` is faster than [Fold], but we use [Fold] because it's much easier +/// to read. +/// +/// Note: We don't implement `fold_script` because next.js doesn't use it. +impl Fold for NextSsg { + fn fold_array_pat(&mut self, mut arr: ArrayPat) -> ArrayPat { + arr = arr.fold_children_with(self); + + if !arr.elems.is_empty() { + arr.elems.retain(|e| !matches!(e, Some(Pat::Invalid(..)))); + } + + arr + } + + fn fold_assign_target_pat(&mut self, mut n: AssignTargetPat) -> AssignTargetPat { + n = n.fold_children_with(self); + + match &n { + AssignTargetPat::Array(arr) => { + if arr.elems.is_empty() { + return AssignTargetPat::Invalid(Invalid { span: DUMMY_SP }); + } + } + AssignTargetPat::Object(obj) => { + if obj.props.is_empty() { + return AssignTargetPat::Invalid(Invalid { span: DUMMY_SP }); + } + } + _ => {} + } + + n + } + + fn fold_expr(&mut self, e: Expr) -> Expr { + match e { + Expr::Assign(assign_expr) => { + let mut retain = true; + let left = self.within_lhs_of_var(true, |this| assign_expr.left.clone().fold_with(this)); + + let right = self.within_lhs_of_var(false, |this| { + match left { + AssignTarget::Simple(SimpleAssignTarget::Invalid(..)) + | AssignTarget::Pat(AssignTargetPat::Invalid(..)) => { + retain = false; + this.mark_as_candidate(&assign_expr.right); + } + + _ => {} + } + assign_expr.right.clone().fold_with(this) + }); + + if retain { + self.remove_expression = false; + Expr::Assign(AssignExpr { + left, + right, + ..assign_expr + }) + } else { + self.remove_expression = true; + *right + } + } + _ => { + self.remove_expression = false; + e.fold_children_with(self) + } + } + } + + fn fold_import_decl(&mut self, mut i: ImportDecl) -> ImportDecl { + // Imports for side effects. + if i.specifiers.is_empty() { + return i; + } + + let import_src = &i.src.value; + + i.specifiers.retain(|s| match s { + ImportSpecifier::Named(ImportNamedSpecifier { local, .. }) + | ImportSpecifier::Default(ImportDefaultSpecifier { local, .. }) + | ImportSpecifier::Namespace(ImportStarAsSpecifier { local, .. }) => { + if self.should_remove(&local.to_id()) { + if matches!(self.state.page_mode, PageMode::Ssr) + && matches!(self.state.filter, ExportFilter::StripDataExports) + // filter out non-packages import + // third part packages must start with `a-z` or `@` + && import_src.starts_with(|c: char| c.is_ascii_lowercase() || c == '@') + { + self + .state + .ssr_removed_packages + .borrow_mut() + .insert(import_src.to_string()); + } + tracing::trace!( + "Dropping import `{}{:?}` because it should be removed", + local.sym, + local.ctxt + ); + + self.state.should_run_again = true; + false + } else { + true + } + } + }); + + i + } + + fn fold_module(&mut self, m: Module) -> Module { + tracing::info!("ssg: Start"); + { + // Fill the state. + let mut v = Analyzer { + state: &mut self.state, + in_lhs_of_var: false, + in_removed_item: false, + }; + m.visit_with(&mut v); + } + + // TODO: Use better detection logic + // if let PageMode::None = self.state.page_mode { + // return m; + // } + + m.fold_children_with(self) + } + + fn fold_module_item(&mut self, i: ModuleItem) -> ModuleItem { + if let ModuleItem::ModuleDecl(ModuleDecl::Import(i)) = i { + let is_for_side_effect = i.specifiers.is_empty(); + let i = i.fold_with(self); + + if !is_for_side_effect && i.specifiers.is_empty() { + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })); + } + + return ModuleItem::ModuleDecl(ModuleDecl::Import(i)); + } + + let i = i.fold_children_with(self); + + match &i { + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(e)) if e.specifiers.is_empty() => { + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })) + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(e)) => match &e.decl { + Decl::Fn(f) => { + if let Some(export_type) = self.state.export_type(&f.ident.to_id()) { + if self.state.dropping_export(export_type) { + tracing::trace!("Dropping an export specifier because it's an SSR/SSG function"); + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })); + } + } + } + + Decl::Var(d) => { + if d.decls.is_empty() { + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })); + } + } + _ => {} + }, + + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(_)) + | ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(_)) => { + if self.state.dropping_export(ExportType::Default) { + tracing::trace!("Dropping an export specifier because it's a default export"); + + return ModuleItem::Stmt(Stmt::Empty(EmptyStmt { span: DUMMY_SP })); + } + } + _ => {} + } + + i + } + + fn fold_module_items(&mut self, mut items: Vec) -> Vec { + items = items.fold_children_with(self); + + // Drop empty nodes. + items.retain(|s| !matches!(s, ModuleItem::Stmt(Stmt::Empty(..)))); + + self.maybe_add_data_marker(&mut items); + + items + } + + fn fold_named_export(&mut self, mut n: NamedExport) -> NamedExport { + n.specifiers = n.specifiers.fold_with(self); + + n.specifiers.retain(|s| { + let (export_type, local_ref) = match s { + ExportSpecifier::Default(ExportDefaultSpecifier { exported, .. }) + | ExportSpecifier::Namespace(ExportNamespaceSpecifier { + name: ModuleExportName::Ident(exported), + .. + }) => (ExportType::from_ident(exported), None), + ExportSpecifier::Named(ExportNamedSpecifier { + exported: Some(ModuleExportName::Ident(exported)), + orig: ModuleExportName::Ident(orig), + .. + }) + | ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(orig @ exported), + .. + }) => (ExportType::from_ident(exported), Some(orig)), + _ => (None, None), + }; + + let Some(export_type) = export_type else { + return true; + }; + + let retain = self.state.should_retain_export_type(export_type); + + if !retain { + // If the export specifier is not retained, but it refers to a local ident, + // we need to run again to possibly remove the local ident. + if let Some(local_ref) = local_ref { + self.state.should_run_again = true; + self.state.refs_from_removed.insert(local_ref.to_id()); + } + } + + self.state.should_retain_export_type(export_type) + }); + + n + } + + fn fold_object_pat(&mut self, mut obj: ObjectPat) -> ObjectPat { + obj = obj.fold_children_with(self); + + if !obj.props.is_empty() { + obj.props = take(&mut obj.props) + .into_iter() + .filter_map(|prop| match prop { + ObjectPatProp::KeyValue(prop) => { + if prop.value.is_invalid() { + None + } else { + Some(ObjectPatProp::KeyValue(prop)) + } + } + ObjectPatProp::Assign(prop) => { + if self.should_remove(&prop.key.to_id()) { + self.mark_as_candidate(&prop.value); + + None + } else { + Some(ObjectPatProp::Assign(prop)) + } + } + ObjectPatProp::Rest(prop) => { + if prop.arg.is_invalid() { + None + } else { + Some(ObjectPatProp::Rest(prop)) + } + } + }) + .collect(); + } + + obj + } + + /// This methods returns [Pat::Invalid] if the pattern should be removed. + fn fold_pat(&mut self, mut p: Pat) -> Pat { + p = p.fold_children_with(self); + + if self.in_lhs_of_var { + match &mut p { + Pat::Ident(name) => { + if self.should_remove(&name.id.to_id()) { + self.state.should_run_again = true; + tracing::trace!( + "Dropping var `{}{:?}` because it should be removed", + name.id.sym, + name.id.ctxt + ); + + return Pat::Invalid(Invalid { span: DUMMY_SP }); + } + } + Pat::Array(arr) => { + if arr.elems.is_empty() { + return Pat::Invalid(Invalid { span: DUMMY_SP }); + } + } + Pat::Object(obj) => { + if obj.props.is_empty() { + return Pat::Invalid(Invalid { span: DUMMY_SP }); + } + } + Pat::Rest(rest) => { + if rest.arg.is_invalid() { + return Pat::Invalid(Invalid { span: DUMMY_SP }); + } + } + Pat::Expr(expr) => { + if let Expr::Member(member_expr) = &**expr { + if let Some(id) = find_member_root_id(member_expr) { + if self.should_remove(&id) { + self.state.should_run_again = true; + tracing::trace!( + "Dropping member expression object `{}{:?}` because it should \ + be removed", + id.0, + id.1 + ); + + return Pat::Invalid(Invalid { span: DUMMY_SP }); + } + } + } + } + _ => {} + } + } + + p + } + + fn fold_simple_assign_target(&mut self, mut n: SimpleAssignTarget) -> SimpleAssignTarget { + n = n.fold_children_with(self); + + if let SimpleAssignTarget::Ident(name) = &n { + if self.should_remove(&name.id.to_id()) { + self.state.should_run_again = true; + tracing::trace!( + "Dropping var `{}{:?}` because it should be removed", + name.id.sym, + name.id.ctxt + ); + + return SimpleAssignTarget::Invalid(Invalid { span: DUMMY_SP }); + } + } + + if let SimpleAssignTarget::Member(member_expr) = &n { + if let Some(id) = find_member_root_id(member_expr) { + if self.should_remove(&id) { + self.state.should_run_again = true; + tracing::trace!( + "Dropping member expression object `{}{:?}` because it should be removed", + id.0, + id.1 + ); + + return SimpleAssignTarget::Invalid(Invalid { span: DUMMY_SP }); + } + } + } + + n + } + + #[allow(clippy::single_match)] + fn fold_stmt(&mut self, mut s: Stmt) -> Stmt { + match s { + Stmt::Decl(Decl::Fn(f)) => { + if self.should_remove(&f.ident.to_id()) { + self.mark_as_candidate(&f.function); + return Stmt::Empty(EmptyStmt { span: DUMMY_SP }); + } + + s = Stmt::Decl(Decl::Fn(f)); + } + Stmt::Decl(Decl::Class(c)) => { + if self.should_remove(&c.ident.to_id()) { + self.mark_as_candidate(&c.class); + return Stmt::Empty(EmptyStmt { span: DUMMY_SP }); + } + + s = Stmt::Decl(Decl::Class(c)); + } + _ => {} + } + + self.remove_expression = false; + + let s = s.fold_children_with(self); + + match s { + Stmt::Decl(Decl::Var(v)) if v.decls.is_empty() => { + return Stmt::Empty(EmptyStmt { span: DUMMY_SP }); + } + Stmt::Expr(_) => { + if self.remove_expression { + self.remove_expression = false; + return Stmt::Empty(EmptyStmt { span: DUMMY_SP }); + } + } + _ => {} + } + + s + } + + /// This method make `name` of [VarDeclarator] to [Pat::Invalid] if it + /// should be removed. + fn fold_var_declarator(&mut self, d: VarDeclarator) -> VarDeclarator { + let name = self.within_lhs_of_var(true, |this| d.name.clone().fold_with(this)); + + let init = self.within_lhs_of_var(false, |this| { + if name.is_invalid() { + this.mark_as_candidate(&d.init); + } + d.init.clone().fold_with(this) + }); + + VarDeclarator { name, init, ..d } + } + + fn fold_var_declarators(&mut self, mut decls: Vec) -> Vec { + decls = decls.fold_children_with(self); + decls.retain(|d| !d.name.is_invalid()); + + decls + } + + // This is important for reducing binary sizes. + noop_fold_type!(); +} + +/// Returns the root identifier of a member expression. +/// +/// e.g. `a.b.c` => `a` +fn find_member_root_id(member_expr: &MemberExpr) -> Option { + match &*member_expr.obj { + Expr::Member(member) => find_member_root_id(member), + Expr::Ident(ident) => Some(ident.to_id()), + _ => None, + } +} diff --git a/crates/next-custom-transforms/src/transforms/warn_for_edge_runtime.rs b/crates/next-custom-transforms/src/transforms/warn_for_edge_runtime.rs new file mode 100644 index 000000000000..9d86a50f1443 --- /dev/null +++ b/crates/next-custom-transforms/src/transforms/warn_for_edge_runtime.rs @@ -0,0 +1,393 @@ +use std::sync::Arc; + +use swc_core::{ + atoms::Atom, + common::{errors::HANDLER, SourceMap, Span}, + ecma::{ + ast::{ + op, BinExpr, CallExpr, Callee, CondExpr, Expr, IdentName, IfStmt, ImportDecl, Lit, + MemberExpr, MemberProp, NamedExport, UnaryExpr, + }, + utils::{ExprCtx, ExprExt}, + visit::{Visit, VisitWith}, + }, +}; + +pub fn warn_for_edge_runtime( + cm: Arc, + ctx: ExprCtx, + should_error_for_node_apis: bool, + is_production: bool, +) -> impl Visit { + WarnForEdgeRuntime { + cm, + ctx, + should_error_for_node_apis, + should_add_guards: false, + guarded_symbols: Default::default(), + guarded_process_props: Default::default(), + guarded_runtime: false, + is_production, + } +} + +/// This is a very simple visitor that currently only checks if a condition (be it an if-statement +/// or ternary expression) contains a reference to disallowed globals/etc. +/// It does not know the difference between +/// ```js +/// if(typeof clearImmediate === "function") clearImmediate(); +/// ``` +/// and +/// ```js +/// if(typeof clearImmediate !== "function") clearImmediate(); +/// ``` +struct WarnForEdgeRuntime { + cm: Arc, + ctx: ExprCtx, + should_error_for_node_apis: bool, + + should_add_guards: bool, + guarded_symbols: Vec, + guarded_process_props: Vec, + // for process.env.NEXT_RUNTIME + guarded_runtime: bool, + is_production: bool, +} + +const EDGE_UNSUPPORTED_NODE_APIS: &[&str] = &[ + "clearImmediate", + "setImmediate", + "BroadcastChannel", + "ByteLengthQueuingStrategy", + "CompressionStream", + "CountQueuingStrategy", + "DecompressionStream", + "DomException", + "MessageChannel", + "MessageEvent", + "MessagePort", + "ReadableByteStreamController", + "ReadableStreamBYOBRequest", + "ReadableStreamDefaultController", + "TransformStreamDefaultController", + "WritableStreamDefaultController", +]; + +/// https://vercel.com/docs/functions/runtimes/edge-runtime#compatible-node.js-modules +const NODEJS_MODULE_NAMES: &[&str] = &[ + "_http_agent", + "_http_client", + "_http_common", + "_http_incoming", + "_http_outgoing", + "_http_server", + "_stream_duplex", + "_stream_passthrough", + "_stream_readable", + "_stream_transform", + "_stream_wrap", + "_stream_writable", + "_tls_common", + "_tls_wrap", + // "assert", + // "assert/strict", + // "async_hooks", + // "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "dns/promises", + "domain", + // "events", + "fs", + "fs/promises", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "os", + "path", + "path/posix", + "path/win32", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "readline/promises", + "repl", + "stream", + "stream/consumers", + "stream/promises", + "stream/web", + "string_decoder", + "sys", + "timers", + "timers/promises", + "tls", + "trace_events", + "tty", + "url", + // "util", + // "util/types", + "v8", + "vm", + "wasi", + "worker_threads", + "zlib", +]; + +impl WarnForEdgeRuntime { + fn warn_if_nodejs_module(&self, span: Span, module_specifier: &str) -> Option<()> { + if self.guarded_runtime { + return None; + } + + // Node.js modules can be loaded with `node:` prefix or directly + if module_specifier.starts_with("node:") || NODEJS_MODULE_NAMES.contains(&module_specifier) { + let loc = self.cm.lookup_line(span.lo).ok()?; + + let msg = format!( + "A Node.js module is loaded ('{module_specifier}' at line {}) which is not \ + supported in the Edge Runtime. +Learn More: https://nextjs.org/docs/messages/node-module-in-edge-runtime", + loc.line + 1 + ); + + HANDLER.with(|h| { + h.struct_span_warn(span, &msg).emit(); + }); + } + + None + } + + fn emit_unsupported_api_error(&self, span: Span, api_name: &str) -> Option<()> { + if self.guarded_runtime + || self + .guarded_symbols + .iter() + .any(|guarded| guarded == api_name) + { + return None; + } + + let loc = self.cm.lookup_line(span.lo).ok()?; + + let msg = format!( + "A Node.js API is used ({api_name} at line: {}) which is not supported in the Edge \ + Runtime. +Learn more: https://nextjs.org/docs/api-reference/edge-runtime", + loc.line + 1 + ); + + HANDLER.with(|h| { + if self.should_error_for_node_apis { + h.struct_span_err(span, &msg).emit(); + } else { + h.struct_span_warn(span, &msg).emit(); + } + }); + + None + } + + fn is_in_middleware_layer(&self) -> bool { + true + } + + fn warn_for_unsupported_process_api(&self, span: Span, prop: &IdentName) { + if !self.is_in_middleware_layer() || prop.sym == "env" { + return; + } + if self.guarded_runtime || self.guarded_process_props.contains(&prop.sym) { + return; + } + + self.emit_unsupported_api_error(span, &format!("process.{}", prop.sym)); + } + + fn add_guards(&mut self, test: &Expr) { + let old = self.should_add_guards; + self.should_add_guards = true; + test.visit_with(self); + self.should_add_guards = old; + } + + fn add_guard_for_test(&mut self, test: &Expr) { + if !self.should_add_guards { + return; + } + + match test { + Expr::Ident(ident) => { + self.guarded_symbols.push(ident.sym.clone()); + } + Expr::Member(member) => { + if member.prop.is_ident_with("NEXT_RUNTIME") { + if let Expr::Member(obj_member) = &*member.obj { + if obj_member.obj.is_global_ref_to(self.ctx, "process") + && obj_member.prop.is_ident_with("env") + { + self.guarded_runtime = true; + } + } + } + if member.obj.is_global_ref_to(self.ctx, "process") { + if let MemberProp::Ident(prop) = &member.prop { + self.guarded_process_props.push(prop.sym.clone()); + } + } + } + Expr::Bin(BinExpr { + left, + right, + op: op!("===") | op!("==") | op!("!==") | op!("!="), + .. + }) => { + self.add_guard_for_test(left); + self.add_guard_for_test(right); + } + _ => (), + } + } + + fn emit_dynamic_not_allowed_error(&self, span: Span) { + if self.is_production { + let msg = "Dynamic Code Evaluation (e. g. 'eval', 'new Function', \ + 'WebAssembly.compile') not allowed in Edge Runtime" + .to_string(); + + HANDLER.with(|h| { + h.struct_span_err(span, &msg).emit(); + }); + } + } + + fn with_new_scope(&mut self, f: impl FnOnce(&mut Self)) { + let old_guarded_symbols_len = self.guarded_symbols.len(); + let old_guarded_process_props_len = self.guarded_symbols.len(); + let old_guarded_runtime = self.guarded_runtime; + f(self); + self.guarded_symbols.truncate(old_guarded_symbols_len); + self + .guarded_process_props + .truncate(old_guarded_process_props_len); + self.guarded_runtime = old_guarded_runtime; + } +} + +impl Visit for WarnForEdgeRuntime { + fn visit_call_expr(&mut self, n: &CallExpr) { + n.visit_children_with(self); + + if let Callee::Import(_) = &n.callee { + if let Some(Expr::Lit(Lit::Str(s))) = n.args.first().map(|e| &*e.expr) { + self.warn_if_nodejs_module(n.span, &s.value); + } + } + } + + fn visit_bin_expr(&mut self, node: &BinExpr) { + match node.op { + op!("&&") | op!("||") | op!("??") => { + if self.should_add_guards { + // This is a condition and not a shorthand for if-then + self.add_guards(&node.left); + node.right.visit_with(self); + } else { + self.with_new_scope(move |this| { + this.add_guards(&node.left); + node.right.visit_with(this); + }); + } + } + op!("==") | op!("===") => { + self.add_guard_for_test(&node.left); + self.add_guard_for_test(&node.right); + node.visit_children_with(self); + } + _ => { + node.visit_children_with(self); + } + } + } + fn visit_cond_expr(&mut self, node: &CondExpr) { + self.with_new_scope(move |this| { + this.add_guards(&node.test); + + node.cons.visit_with(this); + node.alt.visit_with(this); + }); + } + + fn visit_expr(&mut self, n: &Expr) { + if let Expr::Ident(ident) = n { + if ident.ctxt == self.ctx.unresolved_ctxt { + if ident.sym == "eval" { + self.emit_dynamic_not_allowed_error(ident.span); + return; + } + + for api in EDGE_UNSUPPORTED_NODE_APIS { + if self.is_in_middleware_layer() && ident.sym == *api { + self.emit_unsupported_api_error(ident.span, api); + return; + } + } + } + } + + n.visit_children_with(self); + } + + fn visit_if_stmt(&mut self, node: &IfStmt) { + self.with_new_scope(move |this| { + this.add_guards(&node.test); + + node.cons.visit_with(this); + node.alt.visit_with(this); + }); + } + + fn visit_import_decl(&mut self, n: &ImportDecl) { + n.visit_children_with(self); + + self.warn_if_nodejs_module(n.span, &n.src.value); + } + + fn visit_member_expr(&mut self, n: &MemberExpr) { + if n.obj.is_global_ref_to(self.ctx, "process") { + if let MemberProp::Ident(prop) = &n.prop { + self.warn_for_unsupported_process_api(n.span, prop); + return; + } + } + + n.visit_children_with(self); + } + + fn visit_named_export(&mut self, n: &NamedExport) { + n.visit_children_with(self); + + if let Some(module_specifier) = &n.src { + self.warn_if_nodejs_module(n.span, &module_specifier.value); + } + } + + fn visit_unary_expr(&mut self, node: &UnaryExpr) { + if node.op == op!("typeof") { + self.add_guard_for_test(&node.arg); + return; + } + + node.visit_children_with(self); + } +} diff --git a/crates/node_binding/Cargo.toml b/crates/node_binding/Cargo.toml index a2ba0ae0313a..bcc0baa05a38 100644 --- a/crates/node_binding/Cargo.toml +++ b/crates/node_binding/Cargo.toml @@ -24,6 +24,8 @@ rspack_fs = { workspace = true } rspack_fs_node = { workspace = true } rspack_hash = { workspace = true } rspack_hook = { workspace = true } +rspack_loader_next_app = { workspace = true } +rspack_loader_next_swc = { workspace = true } rspack_napi = { workspace = true } rspack_paths = { workspace = true } rspack_plugin_html = { workspace = true } @@ -59,7 +61,6 @@ serde_json = { workspace = true } swc_core = { workspace = true, default-features = false, features = ["ecma_transforms_react"] } tokio = { workspace = true, features = ["rt", "macros", "test-util", "parking_lot"] } - rspack_loader_lightningcss = { workspace = true } rspack_loader_preact_refresh = { workspace = true } rspack_loader_react_refresh = { workspace = true } @@ -87,6 +88,7 @@ rspack_plugin_lightning_css_minimizer = { workspace = true } rspack_plugin_limit_chunk_count = { workspace = true } rspack_plugin_merge_duplicate_chunks = { workspace = true } rspack_plugin_mf = { workspace = true } +rspack_plugin_next_flight_client_entry = { workspace = true } rspack_plugin_no_emit_on_errors = { workspace = true } rspack_plugin_progress = { workspace = true } rspack_plugin_real_content_hash = { workspace = true } diff --git a/crates/node_binding/binding.d.ts b/crates/node_binding/binding.d.ts index 11962c86800d..cc738b2399c9 100644 --- a/crates/node_binding/binding.d.ts +++ b/crates/node_binding/binding.d.ts @@ -241,6 +241,7 @@ export declare class JsExportsInfo { } export declare class JsModule { + get constructorName(): string get context(): string | undefined get originalSource(): JsCompatSource | undefined get resource(): string | undefined @@ -263,6 +264,7 @@ export declare class JsModule { get matchResource(): string | undefined set matchResource(val: string | undefined) emitFile(filename: string, source: JsCompatSource, jsAssetInfo?: AssetInfo | undefined | null): void + get buildInfo(): Record } export declare class JsModuleGraph { @@ -395,7 +397,8 @@ export declare enum BuiltinPluginName { RsdoctorPlugin = 'RsdoctorPlugin', JsLoaderRspackPlugin = 'JsLoaderRspackPlugin', LazyCompilationPlugin = 'LazyCompilationPlugin', - ModuleInfoHeaderPlugin = 'ModuleInfoHeaderPlugin' + ModuleInfoHeaderPlugin = 'ModuleInfoHeaderPlugin', + FlightClientEntryPlugin = 'FlightClientEntryPlugin' } export declare function cleanupGlobalTrace(): void @@ -407,6 +410,11 @@ export interface ContextInfo { export declare function formatDiagnostic(diagnostic: JsDiagnostic): ExternalObject<'Diagnostic'> +export interface JsAction { + workers: Record + layer: Record +} + export interface JsAddingRuntimeModule { name: string generator: () => String @@ -667,6 +675,18 @@ export interface JsFactoryMeta { sideEffectFree?: boolean } +export interface JsFlightClientEntryPluginState { + serverActions: Record + edgeServerActions: Record + serverActionModules: Record + edgeServerActionModules: Record + ssrModules: Record + edgeSsrModules: Record + rscModules: Record + edgeRscModules: Record + injectedClientEntries: Record +} + export interface JsHtmlPluginAssets { publicPath: string js: Array @@ -775,6 +795,16 @@ export interface JsModuleDescriptor { id?: string } +export interface JsModuleInfo { + moduleId: string + isAsync: boolean +} + +export interface JsModulePair { + server?: JsModuleInfo + client?: JsModuleInfo +} + export interface JsNormalModuleFactoryCreateModuleArgs { dependencyType: string rawRequest: string @@ -1024,6 +1054,13 @@ export interface JsRuntimeRequirementInTreeResult { runtimeRequirements: JsRuntimeGlobals } +export interface JsShouldInvalidateCbCtx { + entryName: string + absolutePagePath: string + bundlePath: string + clientBrowserLoader: string +} + export interface JsStatsAsset { type: string name: string @@ -1675,6 +1712,17 @@ export interface RawFlagAllModulesAsUsedPluginOptions { explanation: string } +export interface RawFlightClientEntryPluginOptions { + dev: boolean + appDir: string + isEdgeServer: boolean + encryptionKey: string + builtinAppLoader: boolean + shouldInvalidateCb: (ctx: JsShouldInvalidateCbCtx) => boolean + invalidateCb: () => void + stateCb: (state: JsFlightClientEntryPluginState) => void +} + export interface RawFuncUseCtx { resource?: string realResource?: string diff --git a/crates/node_binding/src/module.rs b/crates/node_binding/src/module.rs index 4732fd190a73..5abf26e2167f 100644 --- a/crates/node_binding/src/module.rs +++ b/crates/node_binding/src/module.rs @@ -9,7 +9,7 @@ use rspack_core::{ ResourceParsedData, RuntimeModuleStage, SourceType, }; use rspack_napi::{ - napi::bindgen_prelude::*, threadsafe_function::ThreadsafeFunction, OneShotInstanceRef, + napi::bindgen_prelude::*, threadsafe_function::ThreadsafeFunction, JsonExt, OneShotInstanceRef, }; use rspack_plugin_runtime::RuntimeModuleFromJs; use rspack_util::source_map::SourceMapKind; @@ -84,6 +84,27 @@ impl JsModule { #[napi] impl JsModule { + #[napi(getter)] + pub fn constructor_name(&mut self) -> napi::Result { + let (_, module) = self.as_ref()?; + let name = if module.as_concatenated_module().is_some() { + "ConcatenatedModule" + } else if module.as_normal_module().is_some() { + "NormalModule" + } else if module.as_context_module().is_some() { + "ContextModule" + } else if module.as_external_module().is_some() { + "ExternalModule" + } else if module.as_raw_module().is_some() { + "RawModule" + } else if module.as_self_module().is_some() { + "SelfModule" + } else { + "Module" + }; + Ok(name.to_string()) + } + #[napi(getter)] pub fn context(&mut self) -> napi::Result> { let (_, module) = self.as_ref()?; @@ -369,6 +390,12 @@ impl JsModule { ); Ok(()) } + + #[napi(getter, ts_return_type = "Record")] + pub fn build_info(&mut self, env: Env) -> napi::Result { + let (_, module) = self.as_ref()?; + module.build_info().extra.to_js(env) + } } type ModuleInstanceRefs = IdentifierMap>; diff --git a/crates/node_binding/src/plugins/js_loader/resolver.rs b/crates/node_binding/src/plugins/js_loader/resolver.rs index 558b8c91fad7..e0d22bf49455 100644 --- a/crates/node_binding/src/plugins/js_loader/resolver.rs +++ b/crates/node_binding/src/plugins/js_loader/resolver.rs @@ -16,6 +16,7 @@ use rspack_error::{ }; use rspack_hook::plugin_hook; use rspack_loader_lightningcss::{config::Config, LIGHTNINGCSS_LOADER_IDENTIFIER}; +use rspack_loader_next_swc::{NextSwcLoader, NEXT_SWC_LOADER_IDENTIFIER}; use rspack_loader_preact_refresh::PREACT_REFRESH_LOADER_IDENTIFIER; use rspack_loader_react_refresh::REACT_REFRESH_LOADER_IDENTIFIER; use rspack_loader_swc::{SwcLoader, SWC_LOADER_IDENTIFIER}; @@ -51,6 +52,11 @@ pub fn serde_error_to_miette( type SwcLoaderCache<'a> = LazyLock, Arc), Arc>>>; static SWC_LOADER_CACHE: SwcLoaderCache = LazyLock::new(|| RwLock::new(FxHashMap::default())); +type NextSwcLoaderCache<'a> = + LazyLock, Arc), Arc>>>; +static NEXT_SWC_LOADER_CACHE: NextSwcLoaderCache = + LazyLock::new(|| RwLock::new(FxHashMap::default())); + pub async fn get_builtin_loader(builtin: &str, options: Option<&str>) -> Result { let options: Arc = options.unwrap_or("{}").into(); if builtin.starts_with(SWC_LOADER_IDENTIFIER) { @@ -81,6 +87,40 @@ pub async fn get_builtin_loader(builtin: &str, options: Option<&str>) -> Result< return Ok(loader); } + if builtin.starts_with(NEXT_SWC_LOADER_IDENTIFIER) { + if let Some(loader) = NEXT_SWC_LOADER_CACHE + .read() + .await + .get(&(Cow::Borrowed(builtin), options.clone())) + { + return Ok(loader.clone()); + } + + let loader = Arc::new( + rspack_loader_next_swc::NextSwcLoader::new(options.as_ref()) + .map_err(|e| { + serde_error_to_miette( + e, + options.clone(), + "failed to parse builtin:swc-loader options", + ) + })? + .with_identifier(builtin.into()), + ); + + NEXT_SWC_LOADER_CACHE.write().await.insert( + (Cow::Owned(builtin.to_owned()), options.clone()), + loader.clone(), + ); + return Ok(loader); + } + + if builtin.starts_with(rspack_loader_next_app::NEXT_APP_LOADER_IDENTIFIER) { + return Ok(Arc::new(rspack_loader_next_app::NextAppLoader::new( + builtin.into(), + ))); + } + if builtin.starts_with(LIGHTNINGCSS_LOADER_IDENTIFIER) { let config: rspack_loader_lightningcss::config::RawConfig = serde_json::from_str(options.as_ref()).map_err(|e| { diff --git a/crates/node_binding/src/raw_options/raw_builtins/mod.rs b/crates/node_binding/src/raw_options/raw_builtins/mod.rs index 1710bfbed6ba..5de25ce31955 100644 --- a/crates/node_binding/src/raw_options/raw_builtins/mod.rs +++ b/crates/node_binding/src/raw_options/raw_builtins/mod.rs @@ -3,6 +3,7 @@ mod raw_bundle_info; mod raw_copy; mod raw_css_extract; mod raw_dll; +mod raw_flight_client_entry_plugin; mod raw_html; mod raw_ids; mod raw_ignore; @@ -19,6 +20,7 @@ mod raw_swc_js_minimizer; use napi::{bindgen_prelude::FromNapiValue, Env, JsUnknown}; use napi_derive::napi; use raw_dll::{RawDllReferenceAgencyPluginOptions, RawFlagAllModulesAsUsedPluginOptions}; +use raw_flight_client_entry_plugin::RawFlightClientEntryPluginOptions; use raw_ids::RawOccurrenceChunkIdsPluginOptions; use raw_lightning_css_minimizer::RawLightningCssMinimizerRspackPluginOptions; use raw_sri::RawSubresourceIntegrityPluginOptions; @@ -65,6 +67,7 @@ use rspack_plugin_mf::{ ConsumeSharedPlugin, ContainerPlugin, ContainerReferencePlugin, ModuleFederationRuntimePlugin, ProvideSharedPlugin, ShareRuntimePlugin, }; +use rspack_plugin_next_flight_client_entry::FlightClientEntryPlugin; use rspack_plugin_no_emit_on_errors::NoEmitOnErrorsPlugin; use rspack_plugin_progress::ProgressPlugin; use rspack_plugin_real_content_hash::RealContentHashPlugin; @@ -204,6 +207,8 @@ pub enum BuiltinPluginName { JsLoaderRspackPlugin, LazyCompilationPlugin, ModuleInfoHeaderPlugin, + + FlightClientEntryPlugin, } #[napi(object)] @@ -579,6 +584,11 @@ impl BuiltinPlugin { let verbose = downcast_into::(self.options)?; plugins.push(ModuleInfoHeaderPlugin::new(verbose).boxed()); } + BuiltinPluginName::FlightClientEntryPlugin => { + let raw_options = downcast_into::(self.options)?; + let options = raw_options.into(); + plugins.push(FlightClientEntryPlugin::new(options).boxed()); + } } Ok(()) } diff --git a/crates/node_binding/src/raw_options/raw_builtins/raw_flight_client_entry_plugin.rs b/crates/node_binding/src/raw_options/raw_builtins/raw_flight_client_entry_plugin.rs new file mode 100644 index 000000000000..8d6cd0ab1517 --- /dev/null +++ b/crates/node_binding/src/raw_options/raw_builtins/raw_flight_client_entry_plugin.rs @@ -0,0 +1,181 @@ +use rspack_napi::threadsafe_function::ThreadsafeFunction; +use rspack_plugin_next_flight_client_entry::{ + Action, ModuleInfo, ModulePair, Options, ShouldInvalidateCbCtx, State, +}; +use rustc_hash::FxHashMap as HashMap; + +#[napi(object, object_from_js = false)] +pub struct JsModuleInfo { + pub module_id: String, + pub is_async: bool, +} + +impl From for JsModuleInfo { + fn from(value: ModuleInfo) -> Self { + JsModuleInfo { + module_id: value.module_id, + is_async: value.is_async, + } + } +} + +#[napi(object, object_from_js = false)] +pub struct JsAction { + pub workers: HashMap, + pub layer: HashMap, +} + +impl From for JsAction { + fn from(action: Action) -> Self { + let workers = action + .workers + .into_iter() + .map(|(key, worker)| (key, worker.into())) + .collect::>(); + JsAction { + workers, + layer: action.layer, + } + } +} + +fn into_js_actions(actions: HashMap) -> HashMap { + actions + .into_iter() + .map(|(key, action)| (key, action.into())) + .collect() +} + +#[napi(object, object_from_js = false)] +pub struct JsModulePair { + pub server: Option, + pub client: Option, +} + +impl From for JsModulePair { + fn from(module_pair: ModulePair) -> Self { + JsModulePair { + server: module_pair.server.map(Into::into), + client: module_pair.client.map(Into::into), + } + } +} + +fn into_js_module_pairs( + module_pairs: HashMap, +) -> HashMap { + module_pairs + .into_iter() + .map(|(key, module_pair)| (key, module_pair.into())) + .collect() +} + +fn into_js_module_infos( + module_infos: HashMap, +) -> HashMap { + module_infos + .into_iter() + .map(|(key, module_info)| (key, module_info.into())) + .collect() +} + +#[napi(object, object_from_js = false)] +pub struct JsFlightClientEntryPluginState { + pub server_actions: HashMap, + pub edge_server_actions: HashMap, + + pub server_action_modules: HashMap, + pub edge_server_action_modules: HashMap, + + pub ssr_modules: HashMap, + pub edge_ssr_modules: HashMap, + + pub rsc_modules: HashMap, + pub edge_rsc_modules: HashMap, + pub injected_client_entries: HashMap, +} + +impl From for JsFlightClientEntryPluginState { + fn from(state: State) -> Self { + JsFlightClientEntryPluginState { + server_actions: into_js_actions(state.server_actions), + edge_server_actions: into_js_actions(state.edge_server_actions), + + server_action_modules: into_js_module_pairs(state.server_action_modules), + edge_server_action_modules: into_js_module_pairs(state.edge_server_action_modules), + + ssr_modules: into_js_module_infos(state.ssr_modules), + edge_ssr_modules: into_js_module_infos(state.edge_ssr_modules), + + rsc_modules: into_js_module_infos(state.rsc_modules), + edge_rsc_modules: into_js_module_infos(state.edge_rsc_modules), + injected_client_entries: state.injected_client_entries, + } + } +} + +#[napi(object, object_from_js = false)] +pub struct JsShouldInvalidateCbCtx { + pub entry_name: String, + pub absolute_page_path: String, + pub bundle_path: String, + pub client_browser_loader: String, +} + +impl From for JsShouldInvalidateCbCtx { + fn from(val: ShouldInvalidateCbCtx) -> Self { + JsShouldInvalidateCbCtx { + entry_name: val.entry_name, + absolute_page_path: val.absolute_page_path, + bundle_path: val.bundle_path, + client_browser_loader: val.client_browser_loader, + } + } +} + +#[napi(object, object_to_js = false)] +pub struct RawFlightClientEntryPluginOptions { + pub dev: bool, + pub app_dir: String, + pub is_edge_server: bool, + pub encryption_key: String, + pub builtin_app_loader: bool, + #[napi(ts_type = "(ctx: JsShouldInvalidateCbCtx) => boolean")] + pub should_invalidate_cb: ThreadsafeFunction, + #[napi(ts_type = "() => void")] + pub invalidate_cb: ThreadsafeFunction<(), ()>, + #[napi(ts_type = "(state: JsFlightClientEntryPluginState) => void")] + pub state_cb: ThreadsafeFunction, +} + +impl From for Options { + fn from(val: RawFlightClientEntryPluginOptions) -> Self { + let should_invalidate_cb = val.should_invalidate_cb; + let invalidate_cb = val.invalidate_cb; + let state_cb = val.state_cb; + + Options { + dev: val.dev, + app_dir: val.app_dir.into(), + is_edge_server: val.is_edge_server, + encryption_key: val.encryption_key, + builtin_app_loader: val.builtin_app_loader, + should_invalidate_cb: Box::new(move |ctx| { + let js_ctx = ctx.into(); + let should_invalidate_cb = should_invalidate_cb.clone(); + should_invalidate_cb + .blocking_call_with_sync(js_ctx) + .unwrap() + }), + invalidate_cb: Box::new(move || { + let invalidate_cb = invalidate_cb.clone(); + invalidate_cb.blocking_call_with_sync(()).unwrap() + }), + state_cb: Box::new(move |state| { + let js_state = state.into(); + let state_cb = state_cb.clone(); + Box::pin(async move { state_cb.call(js_state).await }) + }), + } + } +} diff --git a/crates/node_binding/src/resolver.rs b/crates/node_binding/src/resolver.rs index 14c344fef6b1..8e8538733a4a 100644 --- a/crates/node_binding/src/resolver.rs +++ b/crates/node_binding/src/resolver.rs @@ -42,7 +42,7 @@ impl JsResolver { } } - #[napi] + #[napi(ts_return_type = "JsResolver")] pub fn with_options( &self, raw: Option, diff --git a/crates/rspack_cacheable/src/with/as_preset/json.rs b/crates/rspack_cacheable/src/with/as_preset/json.rs index 24b3bac54f90..3016bbc08af4 100644 --- a/crates/rspack_cacheable/src/with/as_preset/json.rs +++ b/crates/rspack_cacheable/src/with/as_preset/json.rs @@ -1,4 +1,4 @@ -use json::JsonValue; +use json::{object::Object, JsonValue}; use rkyv::{ rancor::Fallible, ser::Writer, @@ -50,3 +50,43 @@ where json::parse(field).map_err(|_| DeserializeError::MessageError("deserialize json value failed")) } } + +impl ArchiveWith for AsPreset { + type Archived = ArchivedString; + type Resolver = JsonResolver; + + #[inline] + fn resolve_with(_field: &Object, resolver: Self::Resolver, out: Place) { + let JsonResolver { inner, value } = resolver; + ArchivedString::resolve_from_str(&value, inner, out); + } +} + +impl SerializeWith for AsPreset +where + S: Fallible + Writer, +{ + #[inline] + fn serialize_with(field: &Object, serializer: &mut S) -> Result { + let value = json::stringify(field.clone()); + let inner = ArchivedString::serialize_from_str(&value, serializer)?; + Ok(JsonResolver { value, inner }) + } +} + +impl DeserializeWith for AsPreset +where + D: Fallible, +{ + #[inline] + fn deserialize_with(field: &ArchivedString, _: &mut D) -> Result { + match json::parse(field) + .map_err(|_| DeserializeError::MessageError("deserialize json value failed"))? + { + JsonValue::Object(object) => Ok(object), + _ => Err(DeserializeError::MessageError( + "deserialize json value should be object", + )), + } + } +} diff --git a/crates/rspack_core/src/module.rs b/crates/rspack_core/src/module.rs index e577eed80671..3806678849f4 100644 --- a/crates/rspack_core/src/module.rs +++ b/crates/rspack_core/src/module.rs @@ -4,11 +4,10 @@ use std::sync::Arc; use std::{any::Any, borrow::Cow, fmt::Debug}; use async_trait::async_trait; -use json::JsonValue; -use rspack_cacheable::with::AsPreset; +use json::{object::Object, JsonValue}; use rspack_cacheable::{ cacheable, cacheable_dyn, - with::{AsOption, AsVec}, + with::{AsOption, AsPreset, AsVec}, }; use rspack_collections::{Identifiable, Identifier, IdentifierSet}; use rspack_error::{Diagnosable, Result}; @@ -73,6 +72,9 @@ pub struct BuildInfo { pub module_concatenation_bailout: Option, pub assets: HashMap, pub module: bool, + // Used solely for communication with the build info on the JavaScript side + #[cacheable(with=AsPreset)] + pub extra: Object, } impl Default for BuildInfo { @@ -95,6 +97,7 @@ impl Default for BuildInfo { module_concatenation_bailout: None, assets: Default::default(), module: false, + extra: Object::new(), } } } diff --git a/crates/rspack_core/src/module_graph/mod.rs b/crates/rspack_core/src/module_graph/mod.rs index 691995ed274d..859f93ad6890 100644 --- a/crates/rspack_core/src/module_graph/mod.rs +++ b/crates/rspack_core/src/module_graph/mod.rs @@ -768,6 +768,12 @@ impl<'a> ModuleGraph<'a> { Ok(()) } + pub fn get_resolved_module(&self, dependency_id: &DependencyId) -> Option<&BoxModule> { + self + .connection_by_dependency_id(dependency_id) + .and_then(|con| self.module_by_identifier(&con.resolved_module)) + } + /// Uniquely identify a module by its identifier and return the aliased reference pub fn module_by_identifier(&self, identifier: &ModuleIdentifier) -> Option<&BoxModule> { self.loop_partials(|p| p.modules.get(identifier))?.as_ref() diff --git a/crates/rspack_core/src/normal_module.rs b/crates/rspack_core/src/normal_module.rs index e8e5f0192bbd..cbaf583051d6 100644 --- a/crates/rspack_core/src/normal_module.rs +++ b/crates/rspack_core/src/normal_module.rs @@ -517,6 +517,7 @@ impl Module for NormalModule { .into_iter() .map(Into::into) .collect(); + self.build_info.extra = loader_result.extra; if no_parse { self.parsed = false; diff --git a/crates/rspack_loader_next_app/Cargo.toml b/crates/rspack_loader_next_app/Cargo.toml new file mode 100644 index 000000000000..b8cb7fd988cf --- /dev/null +++ b/crates/rspack_loader_next_app/Cargo.toml @@ -0,0 +1,33 @@ +[package] +description = "rspack builtin next app loader" +edition = "2021" +license = "MIT" +name = "rspack_loader_next_app" +repository = "https://github.com/web-infra-dev/rspack" +version = "0.2.0" + +[dependencies] +async-recursion = { workspace = true } +async-trait = { workspace = true } +base64-simd = { version = "0.8.0", features = ["alloc"] } +futures = { workspace = true } +json = { workspace = true } +lazy-regex = "3.4.1" +regex = { workspace = true } +rspack_ast = { workspace = true } +rspack_cacheable = { workspace = true } +rspack_core = { workspace = true } +rspack_error = { workspace = true } +rspack_loader_runner = { workspace = true } +rspack_paths = { workspace = true } +rspack_plugin_javascript = { workspace = true } +rspack_swc_plugin_import = { workspace = true } +rspack_util = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde-querystring = "0.2.0" +serde_json = { workspace = true } +sugar_path = { workspace = true } +tokio = { workspace = true } + +[package.metadata.cargo-shear] +ignored = ["rspack_plugin_javascript", "rspack_swc_plugin_import", "rspack_ast"] diff --git a/crates/rspack_loader_next_app/LICENSE b/crates/rspack_loader_next_app/LICENSE new file mode 100644 index 000000000000..46310101ad8a --- /dev/null +++ b/crates/rspack_loader_next_app/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2022-present Bytedance, Inc. and its affiliates. + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/rspack_loader_next_app/src/create_app_route_code.rs b/crates/rspack_loader_next_app/src/create_app_route_code.rs new file mode 100644 index 000000000000..3f3cc15361b1 --- /dev/null +++ b/crates/rspack_loader_next_app/src/create_app_route_code.rs @@ -0,0 +1,112 @@ +use rspack_error::Result; +use rspack_paths::Utf8Path; +use rspack_util::{fx_hash::FxIndexMap, json_stringify}; +use serde_json::json; + +use crate::{ + create_absolute_path, + is_metadata_route::is_metadata_route, + load_entrypoint::load_next_js_template, + util::{ensure_leading_slash, is_group_segment}, +}; + +pub struct WebpackResourceQueries { + pub edge_ssr_entry: &'static str, + pub metadata: &'static str, + pub metadata_route: &'static str, + pub metadata_image_meta: &'static str, +} + +pub const WEBPACK_RESOURCE_QUERIES: WebpackResourceQueries = WebpackResourceQueries { + edge_ssr_entry: "__next_edge_ssr_entry__", + metadata: "__next_metadata__", + metadata_route: "__next_metadata_route__", + metadata_image_meta: "__next_metadata_image_meta__", +}; + +pub async fn create_app_route_code( + name: &str, + page: &str, + page_path: &str, + package_root: &Utf8Path, + page_extensions: &[String], + next_config_output: &Option, + app_dir: &str, +) -> Result { + // routePath is the path to the route handler file, + // but could be aliased e.g. private-next-app-dir/favicon.ico + let route_path = page_path; + + // This, when used with the resolver will give us the pathname to the built + // route handler file. + let mut resolved_page_path = create_absolute_path(app_dir, route_path); + + // If this is a metadata route, then we need to use the metadata loader for + // the route to ensure that the route is generated. + let (file_base_name, ext) = { + let resolved_page_path = Utf8Path::new(&resolved_page_path); + ( + resolved_page_path.file_stem().unwrap().to_string(), + resolved_page_path.extension(), + ) + }; + if is_metadata_route(name) && file_base_name != "route" { + let is_dynamic_route_extension = + ext.is_some_and(|ext| page_extensions.iter().any(|the_ext| ext == the_ext)); + + resolved_page_path = format!( + "next-metadata-route-loader?{}!?{}", + json!({ + "filePath": resolved_page_path, + "isDynamicRouteExtension": if is_dynamic_route_extension { "1" } else { "0" }, + }) + .to_string(), + WEBPACK_RESOURCE_QUERIES.metadata_route + ); + } + + let pathname = normalize_app_path(page); + let bundle_path = normalize_app_path(page); + + let mut replacements = FxIndexMap::default(); + replacements.insert("VAR_USERLAND", resolved_page_path.to_string()); + replacements.insert("VAR_DEFINITION_PAGE", page.to_string()); + replacements.insert("VAR_DEFINITION_PATHNAME", pathname); + replacements.insert("VAR_DEFINITION_FILENAME", file_base_name); + replacements.insert("VAR_DEFINITION_BUNDLE_PATH", bundle_path); + replacements.insert("VAR_RESOLVED_PAGE_PATH", resolved_page_path); + + let mut injections = FxIndexMap::default(); + injections.insert("nextConfigOutput", json_stringify(next_config_output)); + + let entrypoint = load_next_js_template( + "app-route.js", + package_root, + replacements, + injections, + Default::default(), + ) + .await?; + + Ok(entrypoint) +} + +fn normalize_app_path(route: &str) -> String { + let path = ensure_leading_slash( + &route + .split('/') + .filter(|segment| { + !segment.is_empty() && !is_group_segment(segment) && !segment.starts_with('@') + }) + .enumerate() + .fold(String::new(), |mut pathname, (index, segment)| { + if (segment == "page" || segment == "route") && index == route.split('/').count() - 1 { + return pathname; + } + pathname.push('/'); + pathname.push_str(segment); + pathname + }), + ); + path.replace("%5F", "_") +} diff --git a/crates/rspack_loader_next_app/src/create_metadata_exports_code.rs b/crates/rspack_loader_next_app/src/create_metadata_exports_code.rs new file mode 100644 index 000000000000..266ace1f956f --- /dev/null +++ b/crates/rspack_loader_next_app/src/create_metadata_exports_code.rs @@ -0,0 +1,45 @@ +use crate::{ + create_static_metadata_from_route::CollectingMetadata, + is_metadata_route::PossibleImageFileNameConvention, +}; + +pub fn create_metadata_exports_code(metadata: &Option) -> String { + if let Some(metadata) = metadata { + format!( + "{}: {{ + icon: [{}], + apple: [{}], + openGraph: [{}], + twitter: [{}], + manifest: {} + }}", + METADATA_TYPE, + &metadata + .get(&PossibleImageFileNameConvention::Icon) + .unwrap_or(&vec![]) + .join(","), + &metadata + .get(&PossibleImageFileNameConvention::Apple) + .unwrap_or(&vec![]) + .join(","), + &metadata + .get(&PossibleImageFileNameConvention::OpenGraph) + .unwrap_or(&vec![]) + .join(","), + &metadata + .get(&PossibleImageFileNameConvention::Twitter) + .unwrap_or(&vec![]) + .join(","), + metadata + .get(&PossibleImageFileNameConvention::Manifest) + .map(|vec| vec.get(0)) + .flatten() + .map(String::as_str) + .unwrap_or_else(|| "undefined") + ) + } else { + String::new() + } +} + +const METADATA_TYPE: &str = "metadata"; diff --git a/crates/rspack_loader_next_app/src/create_static_metadata_from_route.rs b/crates/rspack_loader_next_app/src/create_static_metadata_from_route.rs new file mode 100644 index 000000000000..99db2f291a4d --- /dev/null +++ b/crates/rspack_loader_next_app/src/create_static_metadata_from_route.rs @@ -0,0 +1,263 @@ +use std::collections::HashMap; + +use rspack_core::{CompilationId, LoaderContext, RunnerContext}; +use rspack_error::Result; +use rspack_paths::Utf8PathBuf; +use rspack_util::fx_hash::BuildFxHasher; +use serde_json::json; + +use crate::{ + is_metadata_route::{PossibleImageFileNameConvention, STATIC_METADATA_IMAGES}, + util::metadata_resolver, +}; + +pub async fn enum_metadata_files( + dir: &str, + filename: &str, + extensions: &[&str], + numeric_suffix: bool, + app_dir: &str, + loader_context: &mut LoaderContext, +) -> Result> { + let mut collected_files = Vec::new(); + + // Collect ., []. + let mut possible_file_names = vec![filename.to_string()]; + if numeric_suffix { + for index in 0..10 { + possible_file_names.push(format!("{}{}", filename, index)); + } + } + + for name in possible_file_names { + let (resolved, missing_dependencies) = metadata_resolver( + dir, + &name, + extensions, + app_dir, + loader_context.context.compilation_id, + ) + .await?; + loader_context + .missing_dependencies + .extend(missing_dependencies); + if let Some(resolved) = resolved { + collected_files.push(Utf8PathBuf::from(resolved)); + } + } + + Ok(collected_files) +} + +pub type CollectingMetadata = HashMap, BuildFxHasher>; + +struct MetadataImage { + filename: &'static str, + extensions: &'static [&'static str], +} + +type StaticMetadataImages = HashMap; + +struct WebpackResourceQueries { + pub edge_ssr_entry: &'static str, + pub metadata: &'static str, + pub metadata_route: &'static str, + pub metadata_image_meta: &'static str, +} + +const WEBPACK_RESOURCE_QUERIES: WebpackResourceQueries = WebpackResourceQueries { + edge_ssr_entry: "__next_edge_ssr_entry__", + metadata: "__next_metadata__", + metadata_route: "__next_metadata_route__", + metadata_image_meta: "__next_metadata_image_meta__", +}; + +pub struct StaticMetadataCreator<'a> { + resolved_dir: &'a str, + segment: &'a str, + is_root_layout_or_root_page: bool, + page_extensions: &'a [String], + base_path: &'a str, + app_dir: &'a str, + loader_context: &'a mut LoaderContext, + + // state + has_static_metadata_files: bool, + static_images_metadata: CollectingMetadata, +} + +impl<'a> StaticMetadataCreator<'a> { + pub fn new( + resolved_dir: &'a str, + segment: &'a str, + is_root_layout_or_root_page: bool, + page_extensions: &'a [String], + base_path: &'a str, + app_dir: &'a str, + loader_context: &'a mut LoaderContext, + ) -> Self { + Self { + resolved_dir, + segment, + is_root_layout_or_root_page, + page_extensions, + base_path, + app_dir, + loader_context, + + has_static_metadata_files: false, + static_images_metadata: Default::default(), + } + } + + async fn collect_icon_module_if_exists( + &mut self, + ty: PossibleImageFileNameConvention, + ) -> Result<()> { + if matches!(ty, PossibleImageFileNameConvention::Manifest) { + let mut static_manifest_extension = Vec::with_capacity(self.page_extensions.len() + 2); + static_manifest_extension.push("webmanifest"); + static_manifest_extension.push("json"); + for page_extension in self.page_extensions { + static_manifest_extension.push(page_extension); + } + + let manifest_file = enum_metadata_files( + &self.resolved_dir, + "manifest", + &static_manifest_extension, + false, + &self.app_dir, + self.loader_context, + ) + .await?; + if manifest_file.len() > 0 { + self.has_static_metadata_files = true; + let path_buf = &manifest_file[0]; + let Some(name) = path_buf.file_stem() else { + return Ok(()); + }; + let Some(ext) = path_buf.extension() else { + return Ok(()); + }; + let extension = if static_manifest_extension + .iter() + .any(|the_ext| ext == *the_ext) + { + ext + } else { + "webmanifest" + }; + self.static_images_metadata.insert( + PossibleImageFileNameConvention::Manifest, + vec![json::stringify(format!("/{}.{}", name, extension))], + ); + } + return Ok(()); + } + + let is_favicon = matches!(ty, PossibleImageFileNameConvention::Favicon); + let metadata = STATIC_METADATA_IMAGES.get(&ty).unwrap(); + let mut extensions = metadata.extensions.to_vec(); + if !is_favicon { + self + .page_extensions + .iter() + .for_each(|ext| extensions.push(ext)); + } + let mut resolved_metadata_files = enum_metadata_files( + &self.resolved_dir, + &metadata.filename, + &extensions, + !is_favicon, + &self.app_dir, + self.loader_context, + ) + .await?; + + resolved_metadata_files.sort_by(|a, b| a.cmp(b)); + + for filepath in resolved_metadata_files { + let query = json!({ + "type": ty.as_str(), + "segment": self.segment, + "basePath": self.base_path, + "pageExtensions": self + .page_extensions + }) + .to_string(); + let image_module_import_source = format!( + "next-metadata-image-loader?{}!{}?{}", + query, filepath, WEBPACK_RESOURCE_QUERIES.metadata + ); + + let image_module = format!( + "(async (props) => (await import(/* webpackMode: \"eager\" */ {})).default(props))", + serde_json::to_string(&image_module_import_source).unwrap() + ); + + self.has_static_metadata_files = true; + if matches!(ty, PossibleImageFileNameConvention::Favicon) { + let metadata = self + .static_images_metadata + .entry(PossibleImageFileNameConvention::Icon) + .or_insert(vec![]); + metadata.insert(0, image_module); + } else { + let metadata = self.static_images_metadata.entry(ty).or_insert(vec![]); + metadata.push(image_module); + } + } + Ok(()) + } + + pub async fn create_static_metadata_from_route(mut self) -> Result> { + // Intentionally make these serial to reuse directory access cache. + self + .collect_icon_module_if_exists(PossibleImageFileNameConvention::Icon) + .await?; + self + .collect_icon_module_if_exists(PossibleImageFileNameConvention::Apple) + .await?; + self + .collect_icon_module_if_exists(PossibleImageFileNameConvention::OpenGraph) + .await?; + self + .collect_icon_module_if_exists(PossibleImageFileNameConvention::Twitter) + .await?; + if self.is_root_layout_or_root_page { + self + .collect_icon_module_if_exists(PossibleImageFileNameConvention::Favicon) + .await?; + self + .collect_icon_module_if_exists(PossibleImageFileNameConvention::Manifest) + .await?; + } + Ok(if self.has_static_metadata_files { + Some(self.static_images_metadata) + } else { + None + }) + } +} + +pub async fn create_static_metadata_from_route( + resolved_dir: &str, + segment: &str, + is_root_layout_or_root_page: bool, + page_extensions: &[String], + base_path: &str, + app_dir: &str, + loader_context: &mut LoaderContext, +) -> Result> { + let creator = StaticMetadataCreator::new( + resolved_dir, + segment, + is_root_layout_or_root_page, + page_extensions, + base_path, + app_dir, + loader_context, + ); + creator.create_static_metadata_from_route().await +} diff --git a/crates/rspack_loader_next_app/src/create_tree_code_from_path.rs b/crates/rspack_loader_next_app/src/create_tree_code_from_path.rs new file mode 100644 index 000000000000..eb26c3dced1f --- /dev/null +++ b/crates/rspack_loader_next_app/src/create_tree_code_from_path.rs @@ -0,0 +1,518 @@ +use std::{collections::HashSet, path::PathBuf}; + +use rspack_core::{CompilationId, LoaderContext, RunnerContext}; +use rspack_error::{error_bail as bail, Result}; +use rspack_util::{ + fx_hash::{BuildFxHasher, FxIndexMap}, + json_stringify, +}; + +use crate::{ + create_metadata_exports_code::create_metadata_exports_code, + create_static_metadata_from_route::{create_static_metadata_from_route, CollectingMetadata}, + util::{ + create_absolute_path, is_app_builtin_not_found_page, is_directory, is_group_segment, + normalize_parallel_key, read_dir_with_compilation_cache, resolver, + }, +}; + +const DEFAULT_GLOBAL_ERROR_PATH: &str = "next/dist/client/components/error-boundary"; +const DEFAULT_LAYOUT_PATH: &str = "next/dist/client/components/default-layout"; +const DEFAULT_NOT_FOUND_PATH: &str = "next/dist/client/components/not-found-error"; +const DEFAULT_FORBIDDEN_PATH: &str = "next/dist/client/components/forbidden-error"; +const DEFAULT_UNAUTHORIZED_PATH: &str = "next/dist/client/components/unauthorized-error"; +const DEFAULT_PARALLEL_ROUTE_PATH: &str = "next/dist/client/components/parallel-route-default"; + +const APP_DIR_ALIAS: &str = "private-next-app-dir"; +const PAGE_SEGMENT: &str = "page$"; +const PARALLEL_CHILDREN_SEGMENT: &str = "children$"; +const UNDERSCORE_NOT_FOUND_ROUTE: &str = "/_not-found"; +const UNDERSCORE_NOT_FOUND_ROUTE_ENTRY: &str = "/_not-found/page"; +const PAGE_SEGMENT_KEY: &str = "__PAGE__"; +const DEFAULT_SEGMENT_KEY: &str = "__DEFAULT__"; + +const HTTP_ACCESS_FALLBACKS: [&str; 3] = ["not-found", "forbidden", "unauthorized"]; +const NORMAL_FILE_TYPES: [&str; 5] = ["layout", "template", "error", "loading", "global-error"]; + +pub struct TreeCodeResult { + pub code: String, + pub pages: String, + pub root_layout: Option, + pub global_error: String, +} + +pub async fn create_tree_code_from_path( + page_path: &str, + page: &str, + loader_context: &mut LoaderContext, + page_extensions: &[String], + base_path: &str, + app_dir: &str, + app_paths: &[String], + collected_declarations: &mut Vec<(String, String)>, +) -> Result { + let is_not_found_route = page == UNDERSCORE_NOT_FOUND_ROUTE_ENTRY; + let is_default_not_found = is_app_builtin_not_found_page(page_path); + let app_dir_prefix = if is_default_not_found { + APP_DIR_ALIAS + } else { + page_path.split_once('/').map(|i| i.0).unwrap_or(page_path) + }; + let mut pages = vec![]; + + let mut root_layout = None; + let mut global_error = None; + let mut tree_code = create_subtree_props_from_segment_path( + vec![], + collected_declarations, + app_dir_prefix, + base_path, + is_default_not_found, + is_not_found_route, + page_extensions, + app_paths, + app_dir, + loader_context, + &mut pages, + &mut root_layout, + &mut global_error, + ) + .await?; + tree_code += ".children;"; + + let mut pages = json_stringify(&pages); + pages += ";"; + + Ok(TreeCodeResult { + code: tree_code, + pages, + root_layout, + global_error: global_error.unwrap_or(DEFAULT_GLOBAL_ERROR_PATH.to_string()), + }) +} + +#[derive(Debug)] +enum Segments<'a> { + Children(&'a str), + ParallelRoute(&'a str, Vec<&'a str>), +} + +fn resolve_parallel_segments<'a>( + pathname: &str, + app_paths: &'a [String], +) -> Result>> { + let mut matched: Vec = Vec::new(); + let mut matched_children_index: Option = None; + let mut existing_children_path: Option<&str> = None; + + for app_path in app_paths { + if app_path.starts_with(&(pathname.to_string() + "/")) { + let rest: Vec<&str> = app_path[pathname.len() + 1..].split('/').collect(); + + // It is the actual page, mark it specially. + if rest.len() == 1 && rest[0] == "page" { + existing_children_path = Some(app_path); + matched_children_index = Some(matched.len()); + matched.push(Segments::Children(PAGE_SEGMENT)); + continue; + } + + let is_parallel_route = rest[0].starts_with('@'); + if is_parallel_route { + if rest.len() == 2 && rest[1] == "page" { + matched.push(Segments::ParallelRoute(rest[0], vec![PAGE_SEGMENT])); + continue; + } + let mut segments = vec![PARALLEL_CHILDREN_SEGMENT]; + segments.extend(&rest[1..]); + matched.push(Segments::ParallelRoute(rest[0], segments)); + continue; + } + + if let Some(existing_path) = existing_children_path + && let Some(i) = matched_children_index + && let Segments::Children(c) = matched[i] + && c != rest[0] + { + let is_incoming_parallel_page = app_path.contains('@'); + let has_current_parallel_page = existing_path.contains('@'); + + if is_incoming_parallel_page { + continue; + } else if !has_current_parallel_page && !is_incoming_parallel_page { + bail!("You cannot have two parallel pages that resolve to the same path. Please check {} and {}. Refer to the route group docs for more information: https://nextjs.org/docs/app/building-your-application/routing/route-groups", existing_path, app_path); + } + } + + existing_children_path = Some(app_path); + matched_children_index = Some(matched.len()); + matched.push(Segments::Children(rest[0])); + } + } + + Ok(matched) +} + +async fn resolve_adjacent_parallel_segments( + segment_path: &str, + app_dir_prefix: &str, + app_dir: &str, + compilation_id: CompilationId, +) -> Result> { + let absolute_segment_path = + create_absolute_path(app_dir, &format!("{}{}", app_dir_prefix, segment_path)); + + if absolute_segment_path.is_empty() { + return Ok(vec![]); + } + + let segment_is_directory = is_directory(&absolute_segment_path).await; + + if !segment_is_directory { + return Ok(vec![]); + } + + // We need to resolve all parallel routes in this level. + let mut parallel_segments: Vec = vec!["children".to_string()]; + read_dir_with_compilation_cache(&absolute_segment_path, compilation_id, |results| { + for (name, metadata) in results.iter() { + if metadata.is_dir() && name.starts_with('@') { + parallel_segments.push(name.to_string()); + } + } + }) + .await?; + + Ok(parallel_segments) +} + +#[async_recursion::async_recursion] +async fn create_subtree_props_from_segment_path( + segments: Vec<&str>, + nested_collected_declarations: &mut Vec<(String, String)>, + app_dir_prefix: &str, + base_path: &str, + is_default_not_found: bool, + is_not_found_route: bool, + page_extensions: &[String], + app_paths: &[String], + app_dir: &str, + loader_context: &mut LoaderContext, + pages: &mut Vec, + root_layout: &mut Option, + global_error: &mut Option, +) -> Result { + let segment_path = segments.join("/"); + + let mut props: FxIndexMap<&str, String> = Default::default(); + let is_root_layer = segments.is_empty(); + let is_root_layout_or_root_page = segments.len() <= 1; + + let mut parallel_segments: Vec = vec![]; + if is_root_layer { + parallel_segments.push(Segments::Children("")); + } else { + parallel_segments.extend(resolve_parallel_segments(&segment_path, app_paths)?); + } + + let mut metadata: Option = None; + let router_dir_path = format!("{}{}", app_dir_prefix, segment_path); + let resolved_route_dir = if is_default_not_found { + "".to_string() + } else { + create_absolute_path(app_dir, &router_dir_path) + }; + + if !resolved_route_dir.is_empty() { + metadata = create_static_metadata_from_route( + &resolved_route_dir, + &segment_path, + is_root_layout_or_root_page, + &page_extensions, + base_path, + &app_dir, + loader_context, + ) + .await?; + } + + for segment in parallel_segments { + if matches!(segment, Segments::Children(PAGE_SEGMENT)) { + let matched_page_path = format!("{}{}/page", app_dir_prefix, segment_path); + + let (resolved_page_path, missing_dependencies) = resolver( + &matched_page_path, + app_dir, + page_extensions, + loader_context.context.compilation_id, + ) + .await?; + loader_context + .missing_dependencies + .extend(missing_dependencies); + if let Some(resolved_page_path) = resolved_page_path { + pages.push(resolved_page_path.clone()); + + let var_name = format!("page{}", nested_collected_declarations.len()); + nested_collected_declarations.push((var_name.clone(), resolved_page_path.clone())); + + props.insert( + "children", + format!( + "['{}', {{}}, {{\npage: [{}, {}], {}\n}}]", + PAGE_SEGMENT_KEY, + var_name, + json_stringify(&resolved_page_path), + create_metadata_exports_code(&metadata) + ), + ); + continue; + } + } + + let mut sub_segment_path = segments.clone(); + if let Segments::ParallelRoute(parallel_key, _) = segment { + sub_segment_path.push(parallel_key); + } + + let normalized_parallel_segment: &str = match &segment { + Segments::Children(s) => s, + Segments::ParallelRoute(_, s) => &s[0], + }; + + if normalized_parallel_segment != PAGE_SEGMENT + && normalized_parallel_segment != PARALLEL_CHILDREN_SEGMENT + { + sub_segment_path.push(normalized_parallel_segment); + } + + let mut parallel_segment_path = sub_segment_path.join("/"); + let parallel_segment_path = if parallel_segment_path.ends_with('/') { + parallel_segment_path + } else { + parallel_segment_path.push('/'); + parallel_segment_path + }; + + let file_paths: Vec<_> = futures::future::join_all( + NORMAL_FILE_TYPES + .iter() + .chain(HTTP_ACCESS_FALLBACKS.iter()) + .map(|file| { + let parallel_segment_path = ¶llel_segment_path; + let compilation_id = loader_context.context.compilation_id; + async move { + let result = resolver( + &format!("{}{}{}", app_dir_prefix, parallel_segment_path, file), + app_dir, + page_extensions, + compilation_id, + ) + .await?; + Ok::<(&str, (Option, HashSet)), rspack_error::Error>(( + file, result, + )) + } + }), + ) + .await + .into_iter() + .try_collect()?; + + let mut defined_file_paths: FxIndexMap<&str, String> = + FxIndexMap::from_iter(file_paths.into_iter().filter_map( + |(file, (path, missing_dependencies))| { + loader_context + .missing_dependencies + .extend(missing_dependencies); + path.map(|p| (file, p)) + }, + )); + + let is_first_layer_group_route = segments.len() == 1 + && sub_segment_path + .iter() + .filter(|seg| is_group_segment(seg)) + .count() + == 1; + + if is_root_layer || is_first_layer_group_route { + for &ty in HTTP_ACCESS_FALLBACKS.iter() { + let (root_fallback_file, missing_dependencies) = resolver( + &format!("{}/{}", app_dir_prefix, ty), + app_dir, + page_extensions, + loader_context.context.compilation_id, + ) + .await?; + loader_context + .missing_dependencies + .extend(missing_dependencies); + let has_root_fallback_file = root_fallback_file.is_some(); + + let has_layer_fallback_file = defined_file_paths.contains_key(ty); + + if !(has_root_fallback_file && is_first_layer_group_route) && !has_layer_fallback_file { + let default_fallback_path = match ty { + "not-found" => DEFAULT_NOT_FOUND_PATH, + "forbidden" => DEFAULT_FORBIDDEN_PATH, + "unauthorized" => DEFAULT_UNAUTHORIZED_PATH, + _ => unreachable!(), + }; + defined_file_paths.insert(ty, default_fallback_path.to_string()); + } + } + } + + if root_layout.is_none() { + let layout_path = defined_file_paths.get("layout"); + *root_layout = layout_path.cloned(); + if is_default_not_found && layout_path.is_none() && root_layout.is_none() { + *root_layout = Some(DEFAULT_LAYOUT_PATH.to_string()); + defined_file_paths.insert("layout", DEFAULT_LAYOUT_PATH.to_string()); + } + } + + if global_error.is_none() { + let (resolved_global_error_path, missing_dependencies) = resolver( + &format!("{}/{}", app_dir_prefix, "global-error"), + app_dir, + page_extensions, + loader_context.context.compilation_id, + ) + .await?; + loader_context + .missing_dependencies + .extend(missing_dependencies); + if let Some(resolved_global_error_path) = resolved_global_error_path { + *global_error = Some(resolved_global_error_path); + } + } + + let (parallel_key, parallel_segment_key) = match &segment { + Segments::Children(s) => ("children", *s), + Segments::ParallelRoute(parallel_key, vec) => (*parallel_key, vec[0]), + }; + let parallel_segment_key = match parallel_segment_key { + PARALLEL_CHILDREN_SEGMENT => "children", + PAGE_SEGMENT => PAGE_SEGMENT_KEY, + _ => parallel_segment_key, + }; + + let normalized_parallel_key = normalize_parallel_key(parallel_key); + let subtree_code = if is_not_found_route && normalized_parallel_key == "children" { + let not_found_path = defined_file_paths + .get("not-found") + .map(|s| s.as_str()) + .unwrap_or(DEFAULT_NOT_FOUND_PATH); + + let var_name = format!("notFound{}", nested_collected_declarations.len()); + + let code = format!( + "{{\nchildren: [{}, {{\nchildren: ['{}', {}, {{\npage: [{}, {}]\n}}]\n}}, {{}}]\n}}", + json_stringify(UNDERSCORE_NOT_FOUND_ROUTE), + PAGE_SEGMENT_KEY, + "{}", + &var_name, + json_stringify(not_found_path) + ); + + nested_collected_declarations.push((var_name, not_found_path.to_string())); + + code + } else { + create_subtree_props_from_segment_path( + sub_segment_path, + nested_collected_declarations, + app_dir_prefix, + base_path, + is_default_not_found, + is_not_found_route, + page_extensions, + app_paths, + app_dir, + loader_context, + pages, + root_layout, + global_error, + ) + .await? + }; + + let modules_code = format!( + "{{\n{} {}\n}}", + defined_file_paths + .iter() + .map(|(file, file_path)| { + let var_name = format!("module{}", nested_collected_declarations.len()); + nested_collected_declarations.push((var_name.clone(), file_path.clone())); + format!("'{}': [{}, {}],", file, var_name, json_stringify(file_path)) + }) + .collect::>() + .join("\n"), + create_metadata_exports_code(&metadata) + ); + + props.insert( + normalized_parallel_key, + format!( + "[\n'{}',\n{},\n{}\n]", + parallel_segment_key, subtree_code, modules_code + ), + ); + } + + let adjacent_parallel_segments = resolve_adjacent_parallel_segments( + &segment_path, + app_dir_prefix, + app_dir, + loader_context.context.compilation_id, + ) + .await?; + + for adjacent_parallel_segment in &adjacent_parallel_segments { + if !props.contains_key(&normalize_parallel_key(&adjacent_parallel_segment)) { + let actual_segment = if adjacent_parallel_segment == "children" { + "".to_string() + } else { + format!("/{}", &adjacent_parallel_segment) + }; + + let (default_path, missing_dependencies) = resolver( + &format!( + "{}{}{}/default", + app_dir_prefix, segment_path, actual_segment + ), + app_dir, + page_extensions, + loader_context.context.compilation_id, + ) + .await?; + loader_context + .missing_dependencies + .extend(missing_dependencies); + let default_path = default_path.unwrap_or_else(|| DEFAULT_PARALLEL_ROUTE_PATH.to_string()); + let json_stringified_default_path = json_stringify(&default_path); + + let var_name = format!("default{}", nested_collected_declarations.len()); + nested_collected_declarations.push((var_name.clone(), default_path)); + + props.insert( + normalize_parallel_key(&adjacent_parallel_segment), + format!( + "[\n'{}', {}, {{\ndefaultPage: [{}, {}]\n}}\n]", + DEFAULT_SEGMENT_KEY, "{}", var_name, json_stringified_default_path, + ), + ); + } + } + + Ok(format!( + "{{\n{}\n}}", + props + .into_iter() + .map(|(k, v)| format!("{k}: {v}")) + .collect::>() + .join(",\n") + )) +} diff --git a/crates/rspack_loader_next_app/src/is_metadata_route.rs b/crates/rspack_loader_next_app/src/is_metadata_route.rs new file mode 100644 index 000000000000..7829a2d9aa56 --- /dev/null +++ b/crates/rspack_loader_next_app/src/is_metadata_route.rs @@ -0,0 +1,278 @@ +#[allow(unused_variables)] +use std::collections::HashMap; + +use lazy_regex::Lazy; +use regex::Regex; +use rspack_util::fx_hash::BuildFxHasher; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PossibleImageFileNameConvention { + Icon, + Apple, + Favicon, + Twitter, + OpenGraph, + Manifest, +} + +impl PossibleImageFileNameConvention { + pub fn as_str(&self) -> &'static str { + match self { + PossibleImageFileNameConvention::Icon => "icon", + PossibleImageFileNameConvention::Apple => "apple", + PossibleImageFileNameConvention::Favicon => "favicon", + PossibleImageFileNameConvention::Twitter => "twitter", + PossibleImageFileNameConvention::OpenGraph => "opengraph", + PossibleImageFileNameConvention::Manifest => "manifest", + } + } +} + +pub struct MetadataImage { + pub filename: &'static str, + pub extensions: &'static [&'static str], +} + +pub type StaticMetadataImages = + HashMap; + +pub static STATIC_METADATA_IMAGES: Lazy = Lazy::new(|| { + let mut map: StaticMetadataImages = Default::default(); + map.insert( + PossibleImageFileNameConvention::Icon, + MetadataImage { + filename: "icon", + extensions: &["ico", "jpg", "jpeg", "png", "svg"], + }, + ); + map.insert( + PossibleImageFileNameConvention::Apple, + MetadataImage { + filename: "apple-icon", + extensions: &["jpg", "jpeg", "png"], + }, + ); + map.insert( + PossibleImageFileNameConvention::Favicon, + MetadataImage { + filename: "favicon", + extensions: &["ico"], + }, + ); + map.insert( + PossibleImageFileNameConvention::OpenGraph, + MetadataImage { + filename: "opengraph-image", + extensions: &["jpg", "jpeg", "png", "gif"], + }, + ); + map.insert( + PossibleImageFileNameConvention::Twitter, + MetadataImage { + filename: "twitter-image", + extensions: &["jpg", "jpeg", "png", "gif"], + }, + ); + map +}); + +pub fn get_extension_regex_string( + static_extensions: &[&str], + dynamic_extensions: Option<&[&str]>, +) -> String { + if dynamic_extensions.is_none() { + return format!("\\.(?:{})", static_extensions.join("|")); + } + let dynamic_extensions = dynamic_extensions.unwrap(); + format!( + "(?:\\.({})|((\\[\\])?\\.({})))", + static_extensions.join("|"), + dynamic_extensions.join("|") + ) +} + +pub fn is_metadata_route_file( + app_dir_relative_path: &str, + page_extensions: &[&str], + with_extension: bool, +) -> bool { + let metadata_route_files_regex = vec![ + { + let mut page_extensions = page_extensions.to_vec(); + page_extensions.push("txt"); + Regex::new(&format!( + r"^[\\/]robots{}", + if with_extension { + get_extension_regex_string(&page_extensions, None) + } else { + String::new() + } + )) + .unwrap() + }, + { + let mut page_extensions = page_extensions.to_vec(); + page_extensions.push("webmanifest"); + page_extensions.push("json"); + Regex::new(&format!( + r"^[\\/]manifest{}", + if with_extension { + get_extension_regex_string(&page_extensions, None) + } else { + String::new() + } + )) + .unwrap() + }, + Regex::new(r"^[\\/]{favicon}\.ico$").unwrap(), + Regex::new(&format!( + r"[\\/]sitemap{}", + if with_extension { + get_extension_regex_string(&["xml"], Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap(), + { + let metadata = STATIC_METADATA_IMAGES + .get(&PossibleImageFileNameConvention::Icon) + .unwrap(); + Regex::new(&format!( + r"[\\/]{}\\d?{}", + metadata.filename, + if with_extension { + get_extension_regex_string(metadata.extensions, Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap() + }, + { + let metadata = STATIC_METADATA_IMAGES + .get(&PossibleImageFileNameConvention::Apple) + .unwrap(); + Regex::new(&format!( + r"[\\/]{}\\d?{}", + metadata.filename, + if with_extension { + get_extension_regex_string(metadata.extensions, Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap() + }, + { + let metadata = STATIC_METADATA_IMAGES + .get(&PossibleImageFileNameConvention::OpenGraph) + .unwrap(); + Regex::new(&format!( + r"[\\/]{}\\d?{}", + metadata.filename, + if with_extension { + get_extension_regex_string(metadata.extensions, Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap() + }, + { + let metadata = STATIC_METADATA_IMAGES + .get(&PossibleImageFileNameConvention::Twitter) + .unwrap(); + Regex::new(&format!( + r"[\\/]{}\\d?{}", + metadata.filename, + if with_extension { + get_extension_regex_string(metadata.extensions, Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap() + }, + ]; + + let normalized_app_dir_relative_path = normalize_path_sep(app_dir_relative_path); + metadata_route_files_regex + .iter() + .any(|r| r.is_match(&normalized_app_dir_relative_path)) +} + +pub fn is_static_metadata_route_file(app_dir_relative_path: &str) -> bool { + is_metadata_route_file(app_dir_relative_path, &vec![], true) +} + +pub fn is_static_metadata_route(page: &str) -> bool { + page == "/robots" || page == "/manifest" || is_static_metadata_route_file(page) +} + +pub fn is_metadata_route(route: &str) -> bool { + let mut page = route.replace("^/?app/", "").replace("/route$", ""); + if !page.starts_with('/') { + page = format!("/{}", page); + } + + !page.ends_with("/page") && is_metadata_route_file(&page, &DEFAULT_EXTENSIONS, false) +} + +pub static DEFAULT_EXTENSIONS: Lazy> = + Lazy::new(|| vec!["js", "jsx", "ts", "tsx"]); + +fn normalize_path_sep(path: &str) -> String { + path.replace("\\", "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_extension_match_regex( + static_extensions: &[&str], + dynamic_extensions: Option<&[&str]>, + ) -> Regex { + Regex::new(&format!( + "^{}$", + get_extension_regex_string(static_extensions, dynamic_extensions) + )) + .unwrap() + } + + #[test] + fn test_with_dynamic_extensions() { + let regex = create_extension_match_regex(&["png", "jpg"], Some(&["tsx", "ts"])); + assert!(regex.is_match(".png")); + assert!(regex.is_match(".jpg")); + assert!(!regex.is_match(".webp")); + + assert!(regex.is_match(".tsx")); + assert!(regex.is_match(".ts")); + assert!(!regex.is_match(".js")); + } + + #[test] + fn test_match_dynamic_multi_routes_with_dynamic_extensions() { + let regex = create_extension_match_regex(&["png"], Some(&["ts"])); + assert!(regex.is_match(".png")); + assert!(!regex.is_match("[].png")); + + assert!(regex.is_match(".ts")); + assert!(regex.is_match("[].ts")); + assert!(!regex.is_match(".tsx")); + assert!(!regex.is_match("[].tsx")); + } + + #[test] + fn test_without_dynamic_extensions() { + let regex = create_extension_match_regex(&["png", "jpg"], None); + assert!(regex.is_match(".png")); + assert!(regex.is_match(".jpg")); + assert!(!regex.is_match(".webp")); + + assert!(!regex.is_match(".tsx")); + assert!(!regex.is_match(".js")); + } +} diff --git a/crates/rspack_loader_next_app/src/lib.rs b/crates/rspack_loader_next_app/src/lib.rs new file mode 100644 index 000000000000..cda88552cc5b --- /dev/null +++ b/crates/rspack_loader_next_app/src/lib.rs @@ -0,0 +1,192 @@ +#![feature(let_chains)] +#![feature(iterator_try_collect)] +#[allow(unused_variables)] +#[allow(dead_code)] +#[allow(unused_imports)] +mod create_app_route_code; +mod create_metadata_exports_code; +mod create_static_metadata_from_route; +mod create_tree_code_from_path; +mod is_metadata_route; +mod load_entrypoint; +mod options; +mod util; + +use std::path::MAIN_SEPARATOR; + +use create_app_route_code::create_app_route_code; +use create_tree_code_from_path::{create_tree_code_from_path, TreeCodeResult}; +use load_entrypoint::load_next_js_template; +use rspack_cacheable::{cacheable, cacheable_dyn}; +use rspack_core::{Loader, LoaderContext, RunnerContext}; +use rspack_error::{error, Result}; +use rspack_loader_runner::{Identifiable, Identifier}; +use rspack_paths::Utf8PathBuf; +use rspack_util::{fx_hash::FxIndexMap, json_stringify}; +use util::{normalize_app_path, normalize_underscore}; + +pub use crate::options::Options; + +fn create_absolute_path(app_dir: &str, path_to_turn_absolute: &str) -> String { + let path_with_os_separator = path_to_turn_absolute.replace("/", &MAIN_SEPARATOR.to_string()); + let absolute_path = path_with_os_separator.replace("private-next-app-dir", app_dir); + absolute_path +} + +pub const NEXT_APP_LOADER_IDENTIFIER: &str = "builtin:next-app-loader"; + +#[cacheable] +#[derive(Debug)] +pub struct NextAppLoader { + id: Identifier, +} + +impl NextAppLoader { + pub fn new(ident: &str) -> Self { + Self { id: ident.into() } + } + + async fn loader_impl(&self, loader_context: &mut LoaderContext) -> Result<()> { + let loader = &*loader_context.current_loader(); + let Some(options) = loader.query() else { + return Ok(()); + }; + let Some(options) = options.strip_prefix('?') else { + return Ok(()); + }; + + let Options { + name, + page_path, + app_dir, + app_paths, + page_extensions, + base_path, + next_config_output, + middleware_config, + project_root, + preferred_region, + } = serde_querystring::from_str::(options, serde_querystring::ParseMode::Duplicate) + .map_err(|e| error!(e.to_string()))?; + let project_root = Utf8PathBuf::from(project_root); + let page = name.strip_prefix("app").unwrap_or(&name); + let app_paths = app_paths.unwrap_or_default(); + let middleware_config = json::parse( + &String::from_utf8( + base64_simd::STANDARD + .decode_to_vec(middleware_config.as_bytes()) + .map_err(|e| error!(e.to_string()))?, + ) + .map_err(|e| error!(e.to_string()))?, + ) + .map_err(|e| error!(e.to_string()))?; + + let mut route = json::object::Object::new(); + route.insert("page", json::JsonValue::String(page.to_string())); + route.insert( + "absolutePagePath", + json::JsonValue::String(create_absolute_path(&app_dir, &page_path)), + ); + if let Some(preferred_region) = preferred_region { + route.insert("preferredRegion", json::JsonValue::String(preferred_region)); + } + route.insert("middlewareConfig", middleware_config); + route.insert("relatedModules", json::JsonValue::String(page.to_string())); + + loader_context + .extra + .insert("route", json::JsonValue::Object(route)); + + if name.ends_with("/route") { + let code = create_app_route_code( + &name, + page, + &page_path, + &project_root, + &page_extensions, + &next_config_output, + &app_dir, + ) + .await?; + loader_context.finish_with(code); + return Ok(()); + } + + let mut collected_declarations = vec![]; + + let TreeCodeResult { + code: tree_code, + pages, + root_layout, + global_error, + } = create_tree_code_from_path( + &page_path, + page, + loader_context, + &page_extensions, + &base_path, + &app_dir, + &app_paths, + &mut collected_declarations, + ) + .await?; + + if root_layout.is_none() { + panic!("root_layout is None"); + } + + let pathname = normalize_app_path(page); + let pathname = normalize_underscore(&pathname); + let code = load_next_js_template( + "app-page.js", + &project_root, + FxIndexMap::from_iter([ + ("VAR_DEFINITION_PAGE", page.to_string()), + ("VAR_DEFINITION_PATHNAME", pathname), + ("VAR_MODULE_GLOBAL_ERROR", global_error), + ]), + FxIndexMap::from_iter([ + ("tree", tree_code), + ("pages", pages), + ("__next_app_require__", "__webpack_require__".to_string()), + ( + "__next_app_load_chunk__", + "() => Promise.resolve()".to_string(), + ), + ]), + FxIndexMap::default(), + ) + .await?; + + let mut all_code = collected_declarations + .into_iter() + .map(|(var_name, path)| { + format!( + "const {var_name} = () => import(/* webpackMode: \"eager\" */ {});\n", + json_stringify(&path) + ) + }) + .collect::>() + .join(""); + all_code += code.as_str(); + + loader_context.finish_with(all_code); + + Ok(()) + } +} + +impl Identifiable for NextAppLoader { + fn identifier(&self) -> rspack_loader_runner::Identifier { + self.id + } +} + +#[cacheable_dyn] +#[async_trait::async_trait] +impl Loader for NextAppLoader { + async fn run(&self, loader_context: &mut LoaderContext) -> Result<()> { + // for better diagnostic, as async_trait macro don't show beautiful error message + self.loader_impl(loader_context).await + } +} diff --git a/crates/rspack_loader_next_app/src/load_entrypoint.rs b/crates/rspack_loader_next_app/src/load_entrypoint.rs new file mode 100644 index 000000000000..22db4b9c37e0 --- /dev/null +++ b/crates/rspack_loader_next_app/src/load_entrypoint.rs @@ -0,0 +1,270 @@ +use std::sync::LazyLock; + +use rspack_error::{error, Result}; +use rspack_paths::{Utf8Path, Utf8PathBuf}; +use rspack_util::{ + fx_hash::{FxDashMap, FxIndexMap, FxIndexSet}, + json_stringify, +}; +use sugar_path::SugarPath; + +static NEXT_TEMPLATES_CACHE: LazyLock> = + LazyLock::new(|| FxDashMap::default()); + +fn templates_folder(package_root: &Utf8Path) -> Utf8PathBuf { + package_root + .join("next") + .join("dist") + .join("build") + .join("templates") +} + +fn templates_esm_folder(package_root: &Utf8Path) -> Utf8PathBuf { + package_root + .join("next") + .join("dist") + .join("esm") + .join("build") + .join("templates") +} + +async fn templates_content(path: Utf8PathBuf) -> Result { + if let Some(content) = NEXT_TEMPLATES_CACHE.get(&path) { + Ok(content.clone()) + } else { + let content = tokio::fs::read_to_string(&path) + .await + .map_err(|e| error!(e))?; + NEXT_TEMPLATES_CACHE.insert(path, content.clone()); + Ok(content) + } +} + +pub async fn load_next_js_template( + path: &str, + package_root: &Utf8Path, + replacements: FxIndexMap<&'static str, String>, + injections: FxIndexMap<&'static str, String>, + imports: FxIndexMap<&'static str, Option>, +) -> Result { + let template_folder = templates_folder(package_root); + let path = templates_esm_folder(package_root).join(path); + let content = templates_content(path).await?; + + fn replace_all( + re: ®ex::Regex, + haystack: &str, + mut replacement: impl FnMut(®ex::Captures) -> Result, + ) -> Result { + let mut new = String::with_capacity(haystack.len()); + let mut last_match = 0; + for caps in re.captures_iter(haystack) { + let m = caps.get(0).unwrap(); + new.push_str(&haystack[last_match..m.start()]); + new.push_str(&replacement(&caps)?); + last_match = m.end(); + } + new.push_str(&haystack[last_match..]); + Ok(new) + } + + // Update the relative imports to be absolute. This will update any relative + // imports to be relative to the root of the `next` package. + let regex = lazy_regex::regex!("(?:from '(\\..*)'|import '(\\..*)')"); + + let mut count = 0; + let mut content = replace_all(regex, &content, |caps| { + let from_request = caps.get(1).map_or("", |c| c.as_str()); + let import_request = caps.get(2).map_or("", |c| c.as_str()); + + count += 1; + let is_from_request = !from_request.is_empty(); + + let imported = template_folder.join(if is_from_request { + from_request + } else { + import_request + }); + + let relative = imported.as_std_path().relative(package_root.as_std_path()); + + if !relative.starts_with("next/") { + return Err(error!( + "Invariant: Expected relative import to start with \"next/\", found \"{}\"", + relative.display() + )); + } + + Ok(if is_from_request { + format!("from {}", json_stringify(&relative)) + } else { + format!("import {}", json_stringify(&relative)) + }) + }) + .map_err(|e| error!(e))?; + + // Verify that at least one import was replaced. It's the case today where + // every template file has at least one import to update, so this ensures that + // we don't accidentally remove the import replacement code or use the wrong + // template file. + if count == 0 { + return Err(error!("Invariant: Expected to replace at least one import")); + } + + // Replace all the template variables with the actual values. If a template + // variable is missing, throw an error. + let mut replaced = FxIndexSet::default(); + for (key, replacement) in &replacements { + let full = format!("'{}'", key); + + if content.contains(&full) { + replaced.insert(*key); + content = content.replace(&full, &json_stringify(&replacement)); + } + } + + // Check to see if there's any remaining template variables. + let regex = lazy_regex::lazy_regex!("/VAR_[A-Z_]+"); + let matches = regex + .find_iter(&content) + .map(|m| m.as_str().to_string()) + .collect::>(); + + if !matches.is_empty() { + return Err(error!( + "Invariant: Expected to replace all template variables, found {}", + matches.join(", "), + )); + } + + // Check to see if any template variable was provided but not used. + if replaced.len() != replacements.len() { + // Find the difference between the provided replacements and the replaced + // template variables. This will let us notify the user of any template + // variables that were not used but were provided. + let difference = replacements + .keys() + .filter(|k| !replaced.contains(*k)) + .cloned() + .collect::>(); + + return Err(error!( + "Invariant: Expected to replace all template variables, missing {} in template", + difference.join(", "), + )); + } + + // Replace the injections. + let mut injected = FxIndexSet::default(); + for (key, injection) in &injections { + let full = format!("// INJECT:{}", key); + + if content.contains(&full) { + // Track all the injections to ensure that we're not missing any. + injected.insert(*key); + content = content.replace(&full, &format!("const {} = {}", key, injection)); + } + } + + // Check to see if there's any remaining injections. + let regex = lazy_regex::regex!("// INJECT:[A-Za-z0-9_]+"); + let matches = regex + .find_iter(&content) + .map(|m| m.as_str().to_string()) + .collect::>(); + + if !matches.is_empty() { + return Err(error!( + "Invariant: Expected to inject all injections, found {}", + matches.join(", "), + )); + } + + // Check to see if any injection was provided but not used. + if injected.len() != injections.len() { + // Find the difference between the provided replacements and the replaced + // template variables. This will let us notify the user of any template + // variables that were not used but were provided. + let difference = injections + .keys() + .filter(|k| !injected.contains(*k)) + .cloned() + .collect::>(); + + return Err(error!( + "Invariant: Expected to inject all injections, missing {} in template", + difference.join(", "), + )); + } + + // Replace the optional imports. + let mut imports_added = FxIndexSet::default(); + for (key, import_path) in &imports { + let mut full = format!("// OPTIONAL_IMPORT:{}", key); + let namespace = if !content.contains(&full) { + full = format!("// OPTIONAL_IMPORT:* as {}", key); + if content.contains(&full) { + true + } else { + continue; + } + } else { + false + }; + + // Track all the imports to ensure that we're not missing any. + imports_added.insert(*key); + + if let Some(path) = import_path { + content = content.replace( + &full, + &format!( + "import {}{} from {}", + if namespace { "* as " } else { "" }, + key, + &json_stringify(&path) + ), + ); + } else { + content = content.replace(&full, &format!("const {} = null", key)); + } + } + + // Check to see if there's any remaining imports. + let regex = lazy_regex::regex!("// OPTIONAL_IMPORT:(\\* as )?[A-Za-z0-9_]+"); + let matches = regex + .find_iter(&content) + .map(|m| m.as_str().to_string()) + .collect::>(); + + if !matches.is_empty() { + return Err(error!( + "Invariant: Expected to inject all imports, found {}", + matches.join(", "), + )); + } + + // Check to see if any import was provided but not used. + if imports_added.len() != imports.len() { + // Find the difference between the provided imports and the injected + // imports. This will let us notify the user of any imports that were + // not used but were provided. + let difference = imports + .keys() + .filter(|k| !imports_added.contains(*k)) + .cloned() + .collect::>(); + + return Err(error!( + "Invariant: Expected to inject all imports, missing {} in template", + difference.join(", "), + )); + } + + // Ensure that the last line is a newline. + if !content.ends_with('\n') { + content.push('\n'); + } + + Ok(content) +} diff --git a/crates/rspack_loader_next_app/src/options.rs b/crates/rspack_loader_next_app/src/options.rs new file mode 100644 index 000000000000..22677c6ecb1d --- /dev/null +++ b/crates/rspack_loader_next_app/src/options.rs @@ -0,0 +1,19 @@ +use rspack_cacheable::cacheable; +use serde::Deserialize; + +#[cacheable] +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Options { + pub name: String, + pub page_path: String, + pub app_dir: String, + pub app_paths: Option>, + pub preferred_region: Option, + pub page_extensions: Vec, + pub base_path: String, + pub next_config_output: Option, + // nextConfigExperimentalUseEarlyImport?: true + pub middleware_config: String, + pub project_root: String, +} diff --git a/crates/rspack_loader_next_app/src/util.rs b/crates/rspack_loader_next_app/src/util.rs new file mode 100644 index 000000000000..9f31db251ccc --- /dev/null +++ b/crates/rspack_loader_next_app/src/util.rs @@ -0,0 +1,210 @@ +use std::{ + collections::{HashMap, HashSet}, + fs::Metadata, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use rspack_core::CompilationId; +use rspack_error::{error, Result}; +use rspack_paths::Utf8Path; +use rspack_util::fx_hash::{BuildFxHasher, FxDashMap}; +use tokio::sync::RwLock; + +static READ_DIR_CACHE: LazyLock< + RwLock<( + Option, + FxDashMap>, + )>, +> = LazyLock::new(|| RwLock::new((None, FxDashMap::default()))); + +pub fn normalize_app_path(route: &str) -> String { + let segments = route.split('/'); + let segments_len = segments.clone().count(); + let mut pathname = String::new(); + + for (index, segment) in segments.enumerate() { + // Empty segments are ignored. + if segment.is_empty() { + continue; + } + + // Groups are ignored. + if is_group_segment(segment) { + continue; + } + + // Parallel segments are ignored. + if segment.starts_with('@') { + continue; + } + + // The last segment (if it's a leaf) should be ignored. + if (segment == "page" || segment == "route") && index == segments_len - 1 { + continue; + } + + pathname.push('/'); + pathname.push_str(segment); + } + + ensure_leading_slash(&pathname) +} + +pub fn ensure_leading_slash(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/{}", path) + } +} + +pub fn is_group_segment(segment: &str) -> bool { + segment.starts_with('(') && segment.ends_with(')') +} + +pub fn normalize_underscore(pathname: &str) -> String { + pathname.replace("%5F", "_") +} + +pub fn normalize_parallel_key(key: &str) -> &str { + key.strip_prefix('@').unwrap_or(key) +} + +pub fn is_app_builtin_not_found_page(page: &str) -> bool { + let re = lazy_regex::regex!(r"next[\\/]dist[\\/]client[\\/]components[\\/]not-found-error"); + re.is_match(page) +} + +pub fn create_absolute_path(app_dir: &str, path_to_turn_absolute: &str) -> String { + let p = path_to_turn_absolute.replace("/", std::path::MAIN_SEPARATOR_STR); + if let Some(p) = p.strip_prefix("private-next-app-dir") { + format!("{}{}", app_dir, p) + } else { + p + } +} + +pub async fn metadata_resolver( + dirname: &str, + filename: &str, + exts: &[&str], + app_dir: &str, + compilation_id: CompilationId, +) -> Result<(Option, HashSet)> { + let absolute_dir = create_absolute_path(app_dir, dirname); + + let mut result: Option = None; + let mut missing_dependencies = HashSet::default(); + + for ext in exts { + // Compared to `resolver` above the exts do not have the `.` included already, so it's added here. + let filename_with_ext = format!("{}.{}", filename, ext); + let absolute_path_with_extension = format!( + "{}{}{}", + absolute_dir, + std::path::MAIN_SEPARATOR, + filename_with_ext + ); + if result.is_none() + && file_exists_in_directory(dirname, &filename_with_ext, compilation_id).await? + { + result = Some(absolute_path_with_extension.clone()); + } + // Call `add_missing_dependency` for all files even if they didn't match, + // because they might be added or removed during development. + missing_dependencies.insert(PathBuf::from(absolute_path_with_extension)); + } + + Ok((result, missing_dependencies)) +} + +pub async fn resolver( + pathname: &str, + app_dir: &str, + extensions: &[String], + compilation_id: CompilationId, +) -> Result<(Option, HashSet)> { + let absolute_path = create_absolute_path(app_dir, pathname); + + let filename_index = absolute_path.rfind(std::path::MAIN_SEPARATOR).unwrap_or(0); + let dirname = &absolute_path[..filename_index]; + let filename = &absolute_path[filename_index + 1..]; + + let mut result: Option = None; + let mut missing_dependencies = HashSet::default(); + + for ext in extensions { + let absolute_path_with_extension = format!("{}.{}", absolute_path, ext); + if result.is_none() + && file_exists_in_directory(dirname, &format!("{}.{}", filename, ext), compilation_id).await? + { + result = Some(absolute_path_with_extension.clone()); + } + // Call `add_missing_dependency` for all files even if they didn't match, + // because they might be added or removed during development. + missing_dependencies.insert(PathBuf::from(absolute_path_with_extension)); + } + + Ok((result, missing_dependencies)) +} + +pub async fn read_dir_with_compilation_cache( + dir: &str, + compilation_id: CompilationId, + f: impl FnOnce(&HashMap) -> R, +) -> Result { + let cache = READ_DIR_CACHE.read().await; + let cache = if cache.0 != Some(compilation_id) { + cache.1.clear(); + drop(cache); + let mut cache = READ_DIR_CACHE.write().await; + cache.0 = Some(compilation_id); + drop(cache); + READ_DIR_CACHE.read().await + } else { + cache + }; + let r = if let Some(results) = cache.1.get(dir) { + f(&results) + } else { + let mut results = HashMap::default(); + let mut entries = tokio::fs::read_dir(dir) + .await + .map_err(|e| error!("{dir} {e}"))?; + while let Some(entry) = entries.next_entry().await.map_err(|e| error!(e))? { + results.insert( + entry + .file_name() + .into_string() + .map_err(|e| error!("failed to convert OsString to String"))?, + entry.metadata().await.map_err(|e| error!(e))?, + ); + } + let r = f(&results); + cache.1.insert(dir.to_string(), results); + r + }; + Ok(r) +} + +pub async fn file_exists_in_directory( + dirname: &str, + filename: &str, + compilation_id: CompilationId, +) -> Result { + read_dir_with_compilation_cache(dirname, compilation_id, |results| { + results + .get(filename) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) + }) + .await +} + +pub async fn is_directory(path: &str) -> bool { + tokio::fs::metadata(path) + .await + .map(|m| m.is_dir()) + .unwrap_or(false) +} diff --git a/crates/rspack_loader_next_swc/Cargo.toml b/crates/rspack_loader_next_swc/Cargo.toml new file mode 100644 index 000000000000..be35bab17b2d --- /dev/null +++ b/crates/rspack_loader_next_swc/Cargo.toml @@ -0,0 +1,51 @@ +[package] +description = "rspack builtin next swc loader" +edition = "2021" +license = "MIT" +name = "rspack_loader_next_swc" +repository = "https://github.com/web-infra-dev/rspack" +version = "0.2.0" +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[package.metadata.cargo-shear] +ignored = ["swc", "rspack_swc_plugin_import", "either"] +[features] +default = [] +plugin = [ + "swc_core/plugin_transform_host_native", + "swc_core/plugin_transform_host_native_filesystem_cache", + "swc_core/plugin_transform_host_native_shared_runtime", +] + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +base64 = { version = "0.22.1" } +either = { workspace = true } +indoc = { workspace = true } +jsonc-parser = { version = "0.26.2", features = ["serde"] } +modularize_imports = { version = "0.77.0" } +next-custom-transforms = { workspace = true } +once_cell = { workspace = true } +preset_env_base = "2.0.1" +regex = { workspace = true } +rspack_ast = { workspace = true } +rspack_cacheable = { workspace = true } +rspack_core = { workspace = true } +rspack_error = { workspace = true } +rspack_loader_runner = { workspace = true } +rspack_paths = { workspace = true } +rspack_plugin_javascript = { workspace = true } +rspack_swc_plugin_import = { workspace = true } +rspack_util = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +stacker = { workspace = true } +sugar_path = { workspace = true } +swc = { workspace = true } +swc_config = { workspace = true } +swc_core = { workspace = true, features = ["base", "ecma_ast", "common"] } +url = "2.5.4" + +[build-dependencies] +cargo_toml = { version = "0.21.0" } diff --git a/crates/rspack_loader_next_swc/LICENSE b/crates/rspack_loader_next_swc/LICENSE new file mode 100644 index 000000000000..46310101ad8a --- /dev/null +++ b/crates/rspack_loader_next_swc/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2022-present Bytedance, Inc. and its affiliates. + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/rspack_loader_next_swc/build.rs b/crates/rspack_loader_next_swc/build.rs new file mode 100644 index 000000000000..c38bc42090ed --- /dev/null +++ b/crates/rspack_loader_next_swc/build.rs @@ -0,0 +1,18 @@ +fn main() { + const CARGO_TOML: &str = include_str!("../../Cargo.toml"); + let workspace_toml = cargo_toml::Manifest::from_str(CARGO_TOML) + .expect("Should parse cargo toml") + .workspace; + let swc_core_version = workspace_toml + .as_ref() + .and_then(|ws| { + ws.dependencies.get("swc_core").and_then(|dep| match dep { + cargo_toml::Dependency::Simple(s) => Some(&**s), + cargo_toml::Dependency::Inherited(_) => unreachable!(), + cargo_toml::Dependency::Detailed(d) => d.version.as_deref(), + }) + }) + .expect("Should have `swc_core` version") + .to_owned(); + println!("cargo::rustc-env=RSPACK_SWC_CORE_VERSION={swc_core_version}"); +} diff --git a/crates/rspack_loader_next_swc/src/compiler.rs b/crates/rspack_loader_next_swc/src/compiler.rs new file mode 100644 index 000000000000..0aa42655e3b9 --- /dev/null +++ b/crates/rspack_loader_next_swc/src/compiler.rs @@ -0,0 +1,595 @@ +/** + * Some code is modified based on + * https://github.com/swc-project/swc/blob/5dacaa174baaf6bf40594d79d14884c8c2fc0de2/crates/swc/src/lib.rs + * Apache-2.0 licensed + * Author Donny/강동윤 + * Copyright (c) + */ +use std::env; +use std::fs::File; +use std::path::Path; +use std::sync::LazyLock; +use std::{path::PathBuf, sync::Arc}; + +use anyhow::{anyhow, bail, Context, Error}; +use base64::prelude::*; +use indoc::formatdoc; +use jsonc_parser::parse_to_serde_value; +use rspack_ast::javascript::{Ast as JsAst, Context as JsAstContext, Program as JsProgram}; +use rspack_error::miette::{self, MietteDiagnostic}; +use rspack_util::itoa; +use rspack_util::swc::minify_file_comments; +use serde_json::error::Category; +use swc::config::JsMinifyCommentOption; +use swc::BoolOr; +use swc_config::merge::Merge; +use swc_core::base::config::{ + BuiltInput, Config, ConfigFile, InputSourceMap, IsModule, Rc, RootMode, +}; +use swc_core::base::{sourcemap, SwcComments}; +use swc_core::common::comments::Comments; +use swc_core::common::errors::Handler; +use swc_core::common::SourceFile; +use swc_core::common::{ + comments::SingleThreadedComments, FileName, FilePathMapping, Mark, SourceMap, GLOBALS, +}; +use swc_core::ecma::ast::{EsVersion, Pass, Program}; +use swc_core::ecma::parser::{ + parse_file_as_module, parse_file_as_program, parse_file_as_script, Syntax, +}; +use swc_core::ecma::transforms::base::helpers::{self, Helpers}; +use swc_core::{ + base::{config::Options, try_with_handler}, + common::Globals, +}; +use url::Url; + +use crate::compiler::miette::Report; + +fn parse_swcrc(s: &str) -> Result { + fn convert_json_err(e: serde_json::Error) -> Error { + let line = e.line(); + let column = e.column(); + + let msg = match e.classify() { + Category::Io => "io error", + Category::Syntax => "syntax error", + Category::Data => "unmatched data", + Category::Eof => "unexpected eof", + }; + Error::new(e).context(format!( + "failed to deserialize .swcrc (json) file: {}: {}:{}", + msg, + itoa!(line), + itoa!(column) + )) + } + + let v = parse_to_serde_value( + s.trim_start_matches('\u{feff}'), + &jsonc_parser::ParseOptions { + allow_comments: true, + allow_trailing_commas: true, + allow_loose_object_property_names: false, + }, + )? + .ok_or_else(|| Error::msg("failed to deserialize empty .swcrc (json) file"))?; + + if let Ok(rc) = serde_json::from_value(v.clone()) { + return Ok(rc); + } + + serde_json::from_value(v) + .map(Rc::Single) + .map_err(convert_json_err) +} + +fn find_swcrc(path: &Path, root: &Path, root_mode: RootMode) -> Option { + let mut parent = path.parent(); + while let Some(dir) = parent { + let swcrc = dir.join(".swcrc"); + + if swcrc.exists() { + return Some(swcrc); + } + + if dir == root && root_mode == RootMode::Root { + break; + } + parent = dir.parent(); + } + + None +} + +fn load_swcrc(path: &Path) -> Result { + let content = std::fs::read_to_string(path).context("failed to read config (.swcrc) file")?; + + parse_swcrc(&content) +} + +fn read_config(opts: &Options, name: &FileName) -> Result, Error> { + static CUR_DIR: LazyLock = LazyLock::new(|| { + if cfg!(target_arch = "wasm32") { + PathBuf::new() + } else { + ::std::env::current_dir().expect("should be available") + } + }); + + let res: Result<_, Error> = { + let Options { + ref root, + root_mode, + swcrc, + config_file, + .. + } = opts; + + let root = root.as_ref().unwrap_or(&CUR_DIR); + + let swcrc_path = match config_file { + Some(ConfigFile::Str(s)) => Some(PathBuf::from(s.clone())), + _ => { + if *swcrc { + if let FileName::Real(ref path) = name { + find_swcrc(path, root, *root_mode) + } else { + None + } + } else { + None + } + } + }; + + let config_file = match swcrc_path.as_deref() { + Some(s) => Some(load_swcrc(s)?), + _ => None, + }; + let filename_path = match name { + FileName::Real(p) => Some(&**p), + _ => None, + }; + + if let Some(filename_path) = filename_path { + if let Some(config) = config_file { + let dir = swcrc_path + .as_deref() + .and_then(|p| p.parent()) + .expect(".swcrc path should have parent dir"); + + let mut config = config + .into_config(Some(filename_path)) + .context("failed to process config file")?; + + if let Some(c) = &mut config { + if c.jsc.base_url != PathBuf::new() { + let joined = dir.join(&c.jsc.base_url); + c.jsc.base_url = if cfg!(target_os = "windows") && c.jsc.base_url.as_os_str() == "." { + dir.canonicalize().with_context(|| { + format!( + "failed to canonicalize base url using the path of \ + .swcrc\nDir: {}\n(Used logic for windows)", + dir.display(), + ) + })? + } else { + joined.canonicalize().with_context(|| { + format!( + "failed to canonicalize base url using the path of \ + .swcrc\nPath: {}\nDir: {}\nbaseUrl: {}", + joined.display(), + dir.display(), + c.jsc.base_url.display() + ) + })? + }; + } + } + + return Ok(config); + } + + let config_file = config_file.unwrap_or_default(); + let config = config_file.into_config(Some(filename_path))?; + + return Ok(config); + } + + let config = match config_file { + Some(config_file) => config_file.into_config(None)?, + None => Rc::default().into_config(None)?, + }; + + match config { + Some(config) => Ok(Some(config)), + None => { + anyhow::bail!("no config matched for file ({})", name) + } + } + }; + + res.with_context(|| format!("failed to read .swcrc file for input file at `{}`", name)) +} + +pub(crate) struct SwcCompiler { + pub cm: Arc, + pub fm: Arc, + pub comments: SingleThreadedComments, + pub options: Options, + pub globals: Globals, + pub helpers: Helpers, + pub config: Config, + pub unresolved_mark: Mark, +} + +impl SwcCompiler { + fn parse_js( + &self, + fm: Arc, + handler: &Handler, + target: EsVersion, + syntax: Syntax, + is_module: IsModule, + comments: Option<&dyn Comments>, + ) -> Result { + let mut error = false; + + let mut errors = vec![]; + let program_result = match is_module { + IsModule::Bool(true) => { + parse_file_as_module(&fm, syntax, target, comments, &mut errors).map(Program::Module) + } + IsModule::Bool(false) => { + parse_file_as_script(&fm, syntax, target, comments, &mut errors).map(Program::Script) + } + IsModule::Unknown => parse_file_as_program(&fm, syntax, target, comments, &mut errors), + }; + + for e in errors { + e.into_diagnostic(handler).emit(); + error = true; + } + + let mut res = program_result.map_err(|e| { + e.into_diagnostic(handler).emit(); + Error::msg("Syntax Error") + }); + + if error { + return Err(anyhow::anyhow!("Syntax Error")); + } + + if env::var("SWC_DEBUG").unwrap_or_default() == "1" { + res = res.with_context(|| format!("Parser config: {:?}", syntax)); + } + + res + } +} + +impl SwcCompiler { + pub fn new(resource_path: PathBuf, source: String, mut options: Options) -> Result { + let cm = Arc::new(SourceMap::new(FilePathMapping::empty())); + let globals = Globals::default(); + let unresolved_mark = GLOBALS.set(&globals, || { + let top_level_mark = Mark::new(); + let unresolved_mark = Mark::new(); + options.top_level_mark = Some(top_level_mark); + options.unresolved_mark = Some(unresolved_mark); + unresolved_mark + }); + + let fm = cm.new_source_file(Arc::new(FileName::Real(resource_path)), source); + let comments = SingleThreadedComments::default(); + let config = read_config(&options, &fm.name)? + .ok_or_else(|| anyhow!("cannot process file because it's ignored by .swcrc"))?; + + let helpers = GLOBALS.set(&globals, || { + let mut external_helpers = options.config.jsc.external_helpers; + external_helpers.merge(config.jsc.external_helpers); + Helpers::new(external_helpers.into()) + }); + + Ok(Self { + cm, + fm, + comments, + options, + globals, + helpers, + config, + unresolved_mark, + }) + } + + pub fn run(&self, op: impl FnOnce() -> R) -> R { + GLOBALS.set(&self.globals, op) + } + + pub fn parse<'a, P>( + &'a self, + program: Option, + before_pass: impl FnOnce(&Program) -> P + 'a, + ) -> Result, Error> + where + P: Pass + 'a, + { + let built = self.run(|| { + try_with_handler(self.cm.clone(), Default::default(), |handler| { + let built = self.options.build_as_input( + &self.cm, + &self.fm.name, + move |syntax, target, is_module| match program { + Some(v) => Ok(v), + _ => self.parse_js( + self.fm.clone(), + handler, + target, + syntax, + is_module, + Some(&self.comments), + ), + }, + self.options.output_path.as_deref(), + self.options.source_root.clone(), + self.options.source_file_name.clone(), + handler, + Some(self.config.clone()), + Some(&self.comments), + before_pass, + )?; + + Ok(Some(built)) + }) + })?; + + match built { + Some(v) => Ok(v), + None => { + anyhow::bail!("cannot process file because it's ignored by .swcrc") + } + } + } + + pub fn transform(&self, config: BuiltInput) -> Result { + let program = config.program; + let mut pass = config.pass; + + let program = self.run(|| { + helpers::HELPERS.set(&self.helpers, || { + let result = try_with_handler(self.cm.clone(), Default::default(), |_handler| { + let result = program.apply(&mut pass); + Ok(result) + }); + match result { + Ok(v) => Ok(v), + Err(err) => { + let error_msg = match err.downcast_ref::(){ + Some(msg) => { + msg + }, + None => "unknown error" + }; + let swc_core_version = env!("RSPACK_SWC_CORE_VERSION"); + // FIXME: with_help has bugs, use with_help when diagnostic print is fixed + let help_msg = formatdoc!{" + The version of the SWC Wasm plugin you're using might not be compatible with `builtin:swc-loader`. + The `swc_core` version of the current `rspack_core` is {swc_core_version}. + Please check the `swc_core` version of SWC Wasm plugin to make sure these versions are within the compatible range. + See this guide as a reference for selecting SWC Wasm plugin versions: https://rspack.dev/errors/swc-plugin-version"}; + let report: Report = MietteDiagnostic::new(format!("{}{}",error_msg,help_msg)).with_code("Builtin swc-loader error").into(); + Err(report) + } + } + }) + }); + + if let Some(comments) = &config.comments { + // TODO: Wait for https://github.com/swc-project/swc/blob/e6fc5327b1a309eae840fe1ec3a2367adab37430/crates/swc/src/config/mod.rs#L808 to land. + let preserve_annotations = match &config.preserve_comments { + BoolOr::Bool(true) | BoolOr::Data(JsMinifyCommentOption::PreserveAllComments) => true, + BoolOr::Data(JsMinifyCommentOption::PreserveSomeComments) => false, + BoolOr::Bool(false) => false, + }; + + minify_file_comments(comments, config.preserve_comments, preserve_annotations); + }; + + program + } + + pub fn input_source_map( + &self, + input_src_map: &InputSourceMap, + ) -> Result, Error> { + let fm = &self.fm; + let name = &self.fm.name; + + let read_inline_sourcemap = + |data_url: Option<&str>| -> Result, Error> { + match data_url { + Some(data_url) => { + let url = Url::parse(data_url) + .with_context(|| format!("failed to parse inline source map url\n{}", data_url))?; + + let idx = match url.path().find("base64,") { + Some(v) => v, + None => { + bail!("failed to parse inline source map: not base64: {:?}", url) + } + }; + + let content = url.path()[idx + "base64,".len()..].trim(); + + let res = BASE64_STANDARD + .decode(content.as_bytes()) + .context("failed to decode base64-encoded source map")?; + + Ok(Some(sourcemap::SourceMap::from_slice(&res).context( + "failed to read input source map from inlined base64 encoded \ + string", + )?)) + } + None => { + bail!("failed to parse inline source map: `sourceMappingURL` not found") + } + } + }; + + let read_file_sourcemap = + |data_url: Option<&str>| -> Result, Error> { + match name.as_ref() { + FileName::Real(filename) => { + let dir = match filename.parent() { + Some(v) => v, + None => { + bail!("unexpected: root directory is given as a input file") + } + }; + + let map_path = match data_url { + Some(data_url) => { + let mut map_path = dir.join(data_url); + if !map_path.exists() { + // Old behavior. This check would prevent + // regressions. + // Perhaps it shouldn't be supported. Sometimes + // developers don't want to expose their source + // code. + // Map files are for internal troubleshooting + // convenience. + map_path = PathBuf::from(format!("{}.map", filename.display())); + if !map_path.exists() { + bail!( + "failed to find input source map file {:?} in \ + {:?} file", + map_path.display(), + filename.display() + ) + } + } + + Some(map_path) + } + None => { + // Old behavior. + let map_path = PathBuf::from(format!("{}.map", filename.display())); + if map_path.exists() { + Some(map_path) + } else { + None + } + } + }; + + match map_path { + Some(map_path) => { + let path = map_path.display().to_string(); + let file = File::open(&path); + + // Old behavior. + let file = file?; + + Ok(Some(sourcemap::SourceMap::from_reader(file).with_context( + || { + format!( + "failed to read input source map + from file at {}", + path + ) + }, + )?)) + } + None => Ok(None), + } + } + _ => Ok(None), + } + }; + + let read_sourcemap = || -> Option { + let s = "sourceMappingURL="; + let idx = fm.src.rfind(s); + + let data_url = idx.map(|idx| { + let data_idx = idx + s.len(); + if let Some(end) = fm.src[data_idx..].find('\n').map(|i| i + data_idx + 1) { + &fm.src[data_idx..end] + } else { + &fm.src[data_idx..] + } + }); + + match read_inline_sourcemap(data_url) { + Ok(r) => r, + Err(_err) => { + // Load original source map if possible + read_file_sourcemap(data_url).unwrap_or(None) + } + } + }; + + // Load original source map + match input_src_map { + InputSourceMap::Bool(false) => Ok(None), + InputSourceMap::Bool(true) => Ok(read_sourcemap()), + InputSourceMap::Str(ref s) => { + if s == "inline" { + Ok(read_sourcemap()) + } else { + // Load source map passed by user + Ok(Some( + sourcemap::SourceMap::from_slice(s.as_bytes()) + .context("failed to read input source map from user-provided sourcemap")?, + )) + } + } + } + } +} + +pub(crate) trait IntoJsAst { + fn into_js_ast(self, program: Program) -> JsAst; +} + +impl IntoJsAst for SwcCompiler { + fn into_js_ast(self, program: Program) -> JsAst { + JsAst::default() + .with_program(JsProgram::new( + program, + Some(self.comments.into_swc_comments()), + )) + .with_context(JsAstContext { + globals: self.globals, + helpers: self.helpers.data(), + source_map: self.cm, + top_level_mark: self + .options + .top_level_mark + .expect("`top_level_mark` should be initialized"), + unresolved_mark: self + .options + .unresolved_mark + .expect("`unresolved_mark` should be initialized"), + }) + } +} + +trait IntoSwcComments { + fn into_swc_comments(self) -> SwcComments; +} + +impl IntoSwcComments for SingleThreadedComments { + fn into_swc_comments(self) -> SwcComments { + let (l, t) = { + let (l, t) = self.take_all(); + (l.take(), t.take()) + }; + SwcComments { + leading: Arc::new(FromIterator::<_>::from_iter(l)), + trailing: Arc::new(FromIterator::<_>::from_iter(t)), + } + } +} diff --git a/crates/rspack_loader_next_swc/src/lib.rs b/crates/rspack_loader_next_swc/src/lib.rs new file mode 100644 index 000000000000..16a11e905d69 --- /dev/null +++ b/crates/rspack_loader_next_swc/src/lib.rs @@ -0,0 +1,705 @@ +#![feature(let_chains)] +#[allow(unused_variables)] +#[allow(dead_code)] +mod compiler; +mod options; +mod transformer; + +use std::collections::HashMap; +use std::default::Default; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; + +use compiler::{IntoJsAst, SwcCompiler}; +use next_custom_transforms::chain_transforms::{custom_before_pass, TransformOptions}; +use next_custom_transforms::transforms::cjs_optimizer::PackageConfig; +use next_custom_transforms::transforms::{ + cjs_optimizer, fonts, named_import_transform, optimize_server_react, react_server_components, + server_actions, +}; +use once_cell::sync::Lazy; +use options::NextSwcLoaderJsOptions; +use preset_env_base::version::Version; +use regex::Regex; +use rspack_cacheable::{cacheable, cacheable_dyn}; +use rspack_core::{Mode, RunnerContext}; +use rspack_error::{error, AnyhowError, Result}; +use rspack_loader_runner::{Identifiable, Identifier, Loader, LoaderContext}; +use rspack_paths::Utf8PathBuf; +use rspack_plugin_javascript::ast::{self, SourceMapConfig}; +use rspack_plugin_javascript::TransformOutput; +use rustc_hash::FxHashMap; +use sugar_path::SugarPath; +use swc::config::{ + GlobalInliningPassEnvs, GlobalPassOption, JscConfig, JscExperimental, ModuleConfig, + OptimizerConfig, SimplifyOption, TransformConfig, +}; +use swc_config::config_types::MergingOption; +use swc_core::base::config::SourceMapsConfig; +use swc_core::base::config::{InputSourceMap, OutputCharset}; +use swc_core::ecma::atoms::Atom; +use swc_core::ecma::parser::{EsSyntax, Syntax, TsSyntax}; +use swc_core::ecma::transforms::compat::es2015::regenerator; +use swc_core::ecma::visit::VisitWith; +use transformer::IdentCollector; + +static NODE_MODULES_PATH: Lazy = Lazy::new(|| Regex::new("[\\/]node_modules[\\/]").unwrap()); + +static EXCLUDED_PATHS: Lazy = Lazy::new(|| { + Regex::new("[\\\\/](cache[\\\\/][^\\/]+\\.zip[\\\\/]node_modules|__virtual__)[\\\\/]").unwrap() +}); + +static BABEL_INCLUDE_REGEXES: Lazy> = Lazy::new(|| { + vec![ + Regex::new(r"next[\\/]dist[\\/](esm[\\/])?shared[\\/]lib").unwrap(), + Regex::new(r"next[\\/]dist[\\/](esm[\\/])?client").unwrap(), + Regex::new(r"next[\\/]dist[\\/](esm[\\/])?pages").unwrap(), + Regex::new(r"[\\/](strip-ansi|ansi-regex|styled-jsx)[\\/]").unwrap(), + ] +}); + +static CWD: Lazy = Lazy::new(|| ::std::env::current_dir().unwrap()); + +// these are exact code conditions checked +// for to force transpiling a `node_module` +static FORCE_TRANSPILE_CONDITIONS: Lazy = + Lazy::new(|| Regex::new("(next\\/font|next\\/dynamic|use server|use client)").unwrap()); + +fn is_type_script_file(path: &Path) -> bool { + if let Some(extension) = path.extension() { + let ext = extension.to_string_lossy().to_lowercase(); + ext == "ts" || ext == "tsx" + } else { + false + } +} + +fn is_common_js_file(path: &Path) -> bool { + if let Some(extension) = path.extension() { + let ext = extension.to_string_lossy().to_lowercase(); + ext == ".cjs" + } else { + false + } +} + +fn should_output_common_js(filename: &Path) -> bool { + is_common_js_file(filename) +} + +fn is_resource_in_packages(resource: &str, package_names: &[String]) -> bool { + package_names.iter().any(|p| { + let node_modules_path = + PathBuf::from("node_modules").join(p.replace("/", &std::path::MAIN_SEPARATOR.to_string())); + resource.contains(&format!( + "{}{}{}", + std::path::MAIN_SEPARATOR, + node_modules_path.to_string_lossy(), + std::path::MAIN_SEPARATOR + )) + }) +} + +fn may_be_exclude(exclude_path: &str, transpile_packages: &[String]) -> bool { + if BABEL_INCLUDE_REGEXES + .iter() + .any(|r| r.is_match(exclude_path)) + { + return false; + } + + let should_be_bundled = is_resource_in_packages(exclude_path, transpile_packages); + if should_be_bundled { + return false; + } + + exclude_path.contains("node_modules") +} + +fn get_transform_options( + filename: &Utf8PathBuf, + mode: Mode, + options: &NextSwcLoaderJsOptions, +) -> TransformOptions { + let NextSwcLoaderJsOptions { + root_dir, + pages_dir, + is_server, + bundle_layer, + app_dir, + esm, + server_components, + server_reference_hash_salt, + has_react_refresh, + optimize_server_react, + supported_browsers, + swc_cache_dir, + transpile_packages, + modularize_imports, + decorators, + emit_decorator_metadata, + regenerator_runtime_path, + .. + } = options; + + let pages_dir = pages_dir.clone().map(Utf8PathBuf::from); + + let is_page_file = match &pages_dir { + Some(pages_dir) => filename.starts_with(pages_dir), + None => false, + }; + + let disable_next_ssg = if *is_server { true } else { is_page_file }; + + let is_node_modules = NODE_MODULES_PATH.is_match(filename.as_str()); + let is_app_browser_layer = bundle_layer.as_deref() == Some("app-pages-browser"); + + let disable_page_config = if *is_server { + true + } else { + is_app_browser_layer && is_node_modules + }; + + let pages_dir = pages_dir.map(PathBuf::from); + let app_dir = app_dir.clone().map(PathBuf::from); + + let is_development = mode.is_development(); + + let is_react_server_layer = matches!( + bundle_layer.as_deref(), + Some("rsc") | Some("action-browser") | Some("middleware") | Some("instrument") + ); + + let styled_components = if !is_react_server_layer { + Some(Default::default()) + } else { + None + }; + + let emotion = if !is_react_server_layer { + Some(Default::default()) + } else { + None + }; + + let root_dir = PathBuf::from(root_dir); + let relative_file_path_from_root = filename + .as_std_path() + .relative(root_dir) + .to_string_lossy() + .to_string() + .into(); + + let is_app_router_pages_layer = matches!( + bundle_layer.as_deref(), + Some("rsc") | Some("ssr") | Some("action-browser") | Some("app-pages-browser") + ); + let server_actions = if is_app_router_pages_layer { + Some(server_actions::Config { + is_react_server_layer, + dynamic_io_enabled: false, + hash_salt: server_reference_hash_salt.to_string(), + cache_kinds: Default::default(), + }) + } else { + None + }; + + let mut packages: FxHashMap = Default::default(); + let mut transforms: FxHashMap = Default::default(); + transforms.insert( + "NextRequest".into(), + "next/dist/server/web/spec-extension/request".into(), + ); + transforms.insert( + "NextResponse".into(), + "next/dist/server/web/spec-extension/response".into(), + ); + transforms.insert( + "ImageResponse".into(), + "next/dist/server/web/spec-extension/image-response".into(), + ); + transforms.insert( + "userAgentFromString".into(), + "next/dist/server/web/spec-extension/user-agent".into(), + ); + transforms.insert( + "userAgent".into(), + "next/dist/server/web/spec-extension/user-agent".into(), + ); + let package_config = PackageConfig { transforms }; + packages.insert("next/server".to_string(), package_config); + let cjs_require_optimizer = Some(cjs_optimizer::Config { packages }); + + let env = if *is_server { + Some(swc_core::ecma::preset_env::Config { + targets: Some(swc_core::ecma::preset_env::Targets::Versions( + preset_env_base::BrowserData { + node: Some(Version::from_str("18.20.4").unwrap()), + ..Default::default() + }, + )), + path: CWD.clone(), + ..Default::default() + }) + } else { + if supported_browsers.is_empty() { + Some(Default::default()) + } else { + Some(swc_core::ecma::preset_env::Config { + targets: Some(swc_core::ecma::preset_env::Targets::Query( + supported_browsers.clone().into(), + )), + ..Default::default() + }) + } + }; + + let is_ts_file = if let Some(extension) = filename.as_std_path().extension() { + let ext = extension.to_string_lossy().to_lowercase(); + ext == "ts" + } else { + false + }; + let has_ts_syntax = is_type_script_file(&filename.as_std_path()); + let syntax = if has_ts_syntax { + Some(Syntax::Typescript(TsSyntax { + tsx: !is_ts_file, + decorators: *decorators, + ..Default::default() + })) + } else { + Some(Syntax::Es(EsSyntax { + jsx: true, + decorators: *decorators, + import_attributes: true, + ..Default::default() + })) + }; + + let module = if should_output_common_js(filename.as_std_path()) { + Some(ModuleConfig::CommonJs(Default::default())) + } else { + None + }; + + let mut typeofs: FxHashMap<_, _> = Default::default(); + if *is_server { + typeofs.insert("window".into(), "undefined".into()); + } else { + typeofs.insert("window".into(), "object".into()); + } + + let swc_options = swc::config::Options { + config: swc::config::Config { + env, + jsc: JscConfig { + syntax, + experimental: JscExperimental { + keep_import_attributes: true.into(), + emit_assert_for_import_attributes: true.into(), + cache_root: Some(swc_cache_dir.to_string()), + ..Default::default() + }, + external_helpers: true.into(), + transform: MergingOption::from(Some(TransformConfig { + react: swc_core::ecma::transforms::react::Options { + runtime: Some(swc_core::ecma::transforms::react::Runtime::Automatic), + import_source: Some("react".to_string().into()), + pragma_frag: Some("React.Fragment".to_string().into()), + throw_if_namespace: Some(true), + development: Some(is_development), + refresh: if *has_react_refresh { + Some(Default::default()) + } else { + None + }, + ..Default::default() + }, + optimizer: Some(OptimizerConfig { + globals: Some(GlobalPassOption { + envs: GlobalInliningPassEnvs::Map(HashMap::from_iter(vec![( + "NODE_ENV".into(), + if is_development { + "\"development\"".into() + } else { + "\"production\"".into() + }, + )])), + typeofs, + ..Default::default() + }), + simplify: Some(SimplifyOption::Bool(false)), + ..Default::default() + }), + legacy_decorator: (*decorators).into(), + decorator_metadata: (*emit_decorator_metadata).into(), + regenerator: regenerator::Config { + import_path: regenerator_runtime_path + .as_ref() + .map(|path| path.to_string().into()), + }, + ..Default::default() + })), + ..Default::default() + }, + module, + // input_source_map, + // source_maps, + // inline_sources_content, + // emit_source_map_columns, + ..Default::default() + }, + cwd: CWD.clone(), + filename: filename.to_string(), + ..Default::default() + }; + + let server_components = server_components.map(|_| { + react_server_components::Config::WithOptions(react_server_components::Options { + is_react_server_layer, + dynamic_io_enabled: false, + }) + }); + + let modularize_imports = + modularize_imports + .as_ref() + .map(|modularize_imports| modularize_imports::Config { + packages: modularize_imports + .clone() + .into_iter() + .map(|(key, value)| { + ( + key, + Arc::new(modularize_imports::PackageConfig { + transform: match value.transform { + options::Transform::String(s) => modularize_imports::Transform::String(s), + options::Transform::Vec(vec) => modularize_imports::Transform::Vec(vec), + }, + prevent_full_import: value.prevent_full_import, + handle_default_import: value.handle_default_import, + handle_namespace_import: value.handle_namespace_import, + skip_default_conversion: value.skip_default_conversion, + }), + ) + }) + .collect::>(), + }); + + let auto_modularize_imports = Some(named_import_transform::Config { + packages: transpile_packages.clone(), + }); + + let optimize_server_react = if *optimize_server_react { + Some(optimize_server_react::Config { + optimize_use_state: false, + }) + } else { + None + }; + + TransformOptions { + swc: swc_options.clone(), + disable_next_ssg, + disable_page_config, + pages_dir, + app_dir, + is_page_file, + is_development, + is_server_compiler: *is_server, + prefer_esm: *esm, + server_components, + styled_jsx: Default::default(), + styled_components, + remove_console: None, + react_remove_properties: None, + relay: None, + shake_exports: None, + emotion, + modularize_imports, + auto_modularize_imports, + optimize_barrel_exports: None, + font_loaders: Some(fonts::Config { + font_loaders: vec!["next/font/local".into(), "next/font/google".into()], + relative_file_path_from_root, + }), + server_actions, + cjs_require_optimizer, + optimize_server_react, + debug_function_name: is_development, + lint_codemod_comments: true, + } +} + +#[cacheable] +#[derive(Debug)] +pub struct NextSwcLoader { + identifier: Identifier, + options: NextSwcLoaderJsOptions, +} + +impl NextSwcLoader { + pub fn new(raw_options: &str) -> Result { + Ok(Self { + identifier: NEXT_SWC_LOADER_IDENTIFIER.into(), + options: raw_options.try_into()?, + }) + } + + /// Panics: + /// Panics if `identifier` passed in is not starting with `builtin:swc-loader`. + pub fn with_identifier(mut self, identifier: Identifier) -> Self { + assert!(identifier.starts_with(NEXT_SWC_LOADER_IDENTIFIER)); + self.identifier = identifier; + self + } + + fn loader_impl(&self, loader_context: &mut LoaderContext) -> Result<()> { + let resource_path = loader_context + .resource_path() + .map(|p| p.to_path_buf()) + .unwrap_or_default(); + + let filename = resource_path.clone(); + + // Ensure `.d.ts` are not processed. + if filename.as_str().ends_with(".d.ts") { + return Ok(()); + } + + let Some(content) = loader_context.take_content() else { + return Ok(()); + }; + let source = content.into_string_lossy(); + + let should_maybe_exclude = + may_be_exclude(resource_path.as_str(), &self.options.transpile_packages); + if should_maybe_exclude { + if source.is_empty() { + panic!("Invariant might be excluded but missing source"); + } + + if !FORCE_TRANSPILE_CONDITIONS.is_match(&source) { + let map = loader_context.take_source_map(); + loader_context.finish_with((source, map)); + return Ok(()); + } + } + + let mut opts = get_transform_options( + &filename, + loader_context.context.options.mode, + &self.options, + ); + + let input_source_map = loader_context + .source_map() + .map(|source_map| InputSourceMap::Str(source_map.clone().to_json().unwrap())); + + let source_map_kind = loader_context.context.module_source_map_kind; + + let source_maps = if source_map_kind.enabled() { + Some(SourceMapsConfig::Str("inline".to_string())) + } else { + None + }; + + let inline_sources_content = loader_context + .context + .module_source_map_kind + .enabled() + .into(); + + let emit_source_map_columns = (!source_map_kind.cheap()).into(); + + opts.swc.source_maps = source_maps.clone(); + opts.swc.source_file_name = Some(filename.to_string()); + opts.swc.config.input_source_map = input_source_map; + opts.swc.config.inline_sources_content = inline_sources_content; + opts.swc.config.emit_source_map_columns = emit_source_map_columns; + + let c = SwcCompiler::new(resource_path.into_std_path_buf(), source, opts.swc.clone()) + .map_err(AnyhowError::from)?; + + let c_ref = &c; + let built = c + .parse(None, |_| { + custom_before_pass( + c_ref.cm.clone(), + c_ref.fm.clone(), + &opts, + c_ref.comments.clone(), + Default::default(), + c_ref.unresolved_mark, + ) + }) + .map_err(AnyhowError::from)?; + + let input_source_map = c + .input_source_map(&built.input_source_map) + .map_err(|e| error!(e.to_string()))?; + let mut codegen_options = ast::CodegenOptions { + target: Some(built.target), + minify: Some(built.minify), + input_source_map: input_source_map.as_ref(), + ascii_only: built + .output + .charset + .as_ref() + .map(|v| matches!(v, OutputCharset::Ascii)), + source_map_config: SourceMapConfig { + enable: source_map_kind.source_map(), + inline_sources_content: source_map_kind.source_map(), + emit_columns: !source_map_kind.cheap(), + names: Default::default(), + }, + inline_script: Some(false), + keep_comments: Some(true), + }; + + let program = c.transform(built)?; + if source_map_kind.enabled() { + let mut v = IdentCollector { + names: Default::default(), + }; + program.visit_with(&mut v); + codegen_options.source_map_config.names = v.names; + } + let ast = c.into_js_ast(program); + let TransformOutput { code, map } = ast::stringify(&ast, codegen_options)?; + loader_context.finish_with((code, map)); + + Ok(()) + } +} + +pub const NEXT_SWC_LOADER_IDENTIFIER: &str = "builtin:next-swc-loader"; + +#[cacheable_dyn] +#[async_trait::async_trait] +impl Loader for NextSwcLoader { + async fn run(&self, loader_context: &mut LoaderContext) -> Result<()> { + #[allow(unused_mut)] + let mut inner = || self.loader_impl(loader_context); + #[cfg(debug_assertions)] + { + // Adjust stack to avoid stack overflow. + stacker::maybe_grow( + 2 * 1024 * 1024, /* 2mb */ + 4 * 1024 * 1024, /* 4mb */ + inner, + ) + } + #[cfg(not(debug_assertions))] + inner() + } + + async fn pitch(&self, loader_context: &mut LoaderContext) -> Result<()> { + if let Some(resource_path) = loader_context.resource_path() { + let NextSwcLoaderJsOptions { + transpile_packages, + pnp, + .. + } = &self.options; + + let should_maybe_exclude = may_be_exclude(resource_path.as_str(), transpile_packages); + + if + // if it might be excluded/no-op we can't use pitch loader + !should_maybe_exclude && + // TODO: investigate swc file reading in PnP mode? + !pnp && + !EXCLUDED_PATHS.is_match(resource_path.as_str()) && + loader_context.loader_items.len() as i32 - 1 == loader_context.loader_index && + resource_path.is_absolute() + // !(await isWasm()) + { + loader_context + .file_dependencies + .insert(resource_path.as_std_path().to_path_buf()); + return self.loader_impl(loader_context); + } + } + + Ok(()) + } +} + +impl Identifiable for NextSwcLoader { + fn identifier(&self) -> Identifier { + self.identifier + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, str::FromStr}; + + use rspack_core::Mode; + use rspack_paths::Utf8PathBuf; + + use crate::{ + get_transform_options, + options::{NextSwcLoaderJsOptions, PackageConfig, Transform}, + }; + + #[test] + fn test_get_transform_options() { + let input = NextSwcLoaderJsOptions { + root_dir: "/test/e2e/app-dir/app".to_string(), + is_server: true, + pages_dir: Some("/test/e2e/app-dir/app/pages".to_string()), + app_dir: Some("/test/e2e/app-dir/app/app".to_string()), + has_react_refresh: false, + optimize_server_react: true, + supported_browsers: vec![ + "chrome 64".to_string(), + "edge 79".to_string(), + "firefox 67".to_string(), + "opera 51".to_string(), + "safari 12".to_string(), + ], + swc_cache_dir: "/test/e2e/app-dir/app/.next/cache/swc".to_string(), + server_components: Some(true), + server_reference_hash_salt: "J6craDGodsVA0OsOU/auvoNP8Gqeux/F8i6gTX9XajA=".to_string(), + bundle_layer: Some("rsc".to_string()), + esm: true, + transpile_packages: vec!["geist".to_string(), "lucide-react".to_string()], + pnp: false, + modularize_imports: Some(HashMap::from_iter(vec![( + "@mui/icons-material".to_string(), + PackageConfig { + transform: Transform::String("@mui/icons-material/{{member}}".to_string()), + prevent_full_import: false, + handle_default_import: false, + handle_namespace_import: false, + skip_default_conversion: false, + }, + )])), + decorators: false, + emit_decorator_metadata: false, + regenerator_runtime_path: Some("next/dist/compiled/regenerator-runtime".to_string()), + }; + + let filename = Utf8PathBuf::from_str("/test/e2e/app-dir/app/middleware.js").unwrap(); + let output = get_transform_options(&filename, Mode::Production, &input); + + println!("{:#?}", output); + // disable_next_ssg + // disable_page_config + // pages_dir + // app_dir + // is_page_file + // is_development + // is_server_compiler + // prefer_esm + // server_components + + // TODO: styled_jsx + + // modularize_imports + // auto_modularize_imports + } +} diff --git a/crates/rspack_loader_next_swc/src/options.rs b/crates/rspack_loader_next_swc/src/options.rs new file mode 100644 index 000000000000..fcd2743d9d28 --- /dev/null +++ b/crates/rspack_loader_next_swc/src/options.rs @@ -0,0 +1,90 @@ +use std::collections::HashMap; + +use rspack_cacheable::cacheable; +use serde::Deserialize; + +pub type ModularizeImports = HashMap; + +#[cacheable] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageConfig { + pub transform: Transform, + #[serde(default)] + pub prevent_full_import: bool, + #[serde(default)] + pub handle_default_import: bool, + #[serde(default)] + pub handle_namespace_import: bool, + #[serde(default)] + pub skip_default_conversion: bool, +} + +#[cacheable] +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum Transform { + String(String), + Vec(Vec<(String, String)>), +} + +#[cacheable] +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NextSwcLoaderJsOptions { + pub root_dir: String, + + pub is_server: bool, + + pub pages_dir: Option, + + pub app_dir: Option, + + pub has_react_refresh: bool, + + #[serde(default)] + pub optimize_server_react: bool, + + // next_config + // js_config + #[serde(default)] + pub supported_browsers: Vec, + + pub swc_cache_dir: String, + + #[serde(default)] + pub server_components: Option, + + pub server_reference_hash_salt: String, + + pub bundle_layer: Option, + + #[serde(default)] + pub esm: bool, + + #[serde(default)] + pub transpile_packages: Vec, + + // rspack specific options + #[serde(default)] + pub pnp: bool, + + #[serde(default)] + pub modularize_imports: Option, + + #[serde(default)] + pub decorators: bool, + + #[serde(default)] + pub emit_decorator_metadata: bool, + + #[serde(default)] + pub regenerator_runtime_path: Option, +} + +impl TryFrom<&str> for NextSwcLoaderJsOptions { + type Error = serde_json::Error; + fn try_from(value: &str) -> Result { + serde_json::from_str(value) + } +} diff --git a/crates/rspack_loader_next_swc/src/transformer.rs b/crates/rspack_loader_next_swc/src/transformer.rs new file mode 100644 index 000000000000..f0a500b94ccc --- /dev/null +++ b/crates/rspack_loader_next_swc/src/transformer.rs @@ -0,0 +1,17 @@ +use rustc_hash::FxHashMap; +use swc_core::atoms::Atom; +use swc_core::common::BytePos; +use swc_core::ecma::ast::Ident; +use swc_core::ecma::visit::{noop_visit_type, Visit}; + +pub struct IdentCollector { + pub names: FxHashMap, +} + +impl Visit for IdentCollector { + noop_visit_type!(); + + fn visit_ident(&mut self, ident: &Ident) { + self.names.insert(ident.span.lo, ident.sym.clone()); + } +} diff --git a/crates/rspack_loader_runner/Cargo.toml b/crates/rspack_loader_runner/Cargo.toml index 41fc04b69db9..ad1d3efd90d4 100644 --- a/crates/rspack_loader_runner/Cargo.toml +++ b/crates/rspack_loader_runner/Cargo.toml @@ -12,6 +12,7 @@ derive_more = { workspace = true, features = ["debug"] } rustc-hash = { workspace = true } tokio = { workspace = true, features = ["test-util"] } +json = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } rspack_cacheable = { workspace = true } diff --git a/crates/rspack_loader_runner/src/context.rs b/crates/rspack_loader_runner/src/context.rs index d074ecb11595..9591babf81d8 100644 --- a/crates/rspack_loader_runner/src/context.rs +++ b/crates/rspack_loader_runner/src/context.rs @@ -1,6 +1,7 @@ use std::{path::PathBuf, sync::Arc}; use derive_more::Debug; +use json::object::Object; use rspack_error::Diagnostic; use rspack_paths::Utf8Path; use rspack_sources::SourceMap; @@ -58,6 +59,8 @@ pub struct LoaderContext { pub loader_items: Vec>, #[debug(skip)] pub plugin: Option>>, + + pub extra: Object, } impl LoaderContext { diff --git a/crates/rspack_loader_runner/src/runner.rs b/crates/rspack_loader_runner/src/runner.rs index e6c7c832394a..27feccac012b 100644 --- a/crates/rspack_loader_runner/src/runner.rs +++ b/crates/rspack_loader_runner/src/runner.rs @@ -1,5 +1,6 @@ use std::{fmt::Debug, path::PathBuf, sync::Arc}; +use json::object::Object; use rspack_error::{error, IntoTWithDiagnosticArray, Result, TWithDiagnosticArray}; use rspack_fs::ReadableFileSystem; use rspack_sources::SourceMap; @@ -99,6 +100,7 @@ async fn create_loader_context( plugin, resource_data, diagnostics: vec![], + extra: Object::new(), }; if let Some(plugin) = loader_context.plugin.clone() { @@ -209,6 +211,7 @@ pub struct LoaderResult { pub source_map: Option, pub additional_data: Option, pub parse_meta: HashMap, + pub extra: Object, } impl TryFrom> for TWithDiagnosticArray { @@ -234,6 +237,7 @@ impl TryFrom> for TWithDiagnosticArray napi::Result; +} + +impl JsonExt for Object { + fn to_js(&self, env: Env) -> napi::Result { + let mut object = env.create_object()?; + for (k, v) in self.iter() { + object.set_named_property(k, (*v).to_js(env)?)?; + } + Ok(object.into_unknown()) + } +} + +impl JsonExt for Short { + fn to_js(&self, env: Env) -> napi::Result { + env.create_string(self.as_str()).map(|v| v.into_unknown()) + } +} + +impl JsonExt for String { + fn to_js(&self, env: Env) -> napi::Result { + env.create_string(self.as_str()).map(|v| v.into_unknown()) + } +} + +impl JsonExt for Number { + fn to_js(&self, env: Env) -> napi::Result { + env.create_double((*self).into()).map(|v| v.into_unknown()) + } +} + +impl JsonExt for JsonValue { + fn to_js(&self, env: Env) -> napi::Result { + Ok(match self { + JsonValue::Null => env.get_null()?.into_unknown(), + JsonValue::Short(s) => s.to_js(env)?, + JsonValue::String(s) => env.create_string(s)?.into_unknown(), + JsonValue::Number(n) => n.to_js(env)?, + JsonValue::Boolean(b) => env.get_boolean(*b)?.into_unknown(), + JsonValue::Array(vec) => { + let mut array = env.create_array_with_length(vec.len())?; + for (i, v) in vec.iter().enumerate() { + array.set_element(i as u32, v.to_js(env)?)?; + } + array.into_unknown() + } + JsonValue::Object(o) => o.to_js(env)?, + }) + } +} diff --git a/crates/rspack_napi/src/lib.rs b/crates/rspack_napi/src/lib.rs index 4bedbab602f4..3ccc84f41f69 100644 --- a/crates/rspack_napi/src/lib.rs +++ b/crates/rspack_napi/src/lib.rs @@ -3,6 +3,7 @@ mod ext; mod js_values; +mod json; mod utils; mod errors; @@ -27,3 +28,4 @@ pub mod napi { pub use js_values::one_shot_instance_ref::*; pub use js_values::one_shot_value_ref::*; pub use js_values::value_ref::*; +pub use json::*; diff --git a/crates/rspack_plugin_next_flight_client_entry/Cargo.toml b/crates/rspack_plugin_next_flight_client_entry/Cargo.toml new file mode 100644 index 000000000000..576bcbb65dea --- /dev/null +++ b/crates/rspack_plugin_next_flight_client_entry/Cargo.toml @@ -0,0 +1,34 @@ +[package] +description = "rspack next flight client entry plugin" +edition = "2021" +license = "MIT" +name = "rspack_plugin_next_flight_client_entry" +repository = "https://github.com/web-infra-dev/rspack" +version = "0.2.0" +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rspack_collections = { workspace = true } +rspack_core = { workspace = true } +rspack_error = { workspace = true } +rspack_hook = { workspace = true } +rspack_paths = { workspace = true } +rspack_util = { workspace = true } + +async-trait = { workspace = true } +derive_more = { workspace = true } +form_urlencoded = "1.2.1" +futures = { workspace = true } +indexmap = { workspace = true } +lazy-regex = "3.4.1" +querystring = { version = "1.1.0" } +regex = { workspace = true } +rspack_plugin_javascript = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sugar_path = { workspace = true } +tracing = { workspace = true } + +[package.metadata.cargo-shear] +ignored = ["tracing"] diff --git a/crates/rspack_plugin_next_flight_client_entry/LICENSE b/crates/rspack_plugin_next_flight_client_entry/LICENSE new file mode 100644 index 000000000000..46310101ad8a --- /dev/null +++ b/crates/rspack_plugin_next_flight_client_entry/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2022-present Bytedance, Inc. and its affiliates. + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/rspack_plugin_next_flight_client_entry/src/constants.rs b/crates/rspack_plugin_next_flight_client_entry/src/constants.rs new file mode 100644 index 000000000000..c467d33cb6bc --- /dev/null +++ b/crates/rspack_plugin_next_flight_client_entry/src/constants.rs @@ -0,0 +1,121 @@ +use lazy_regex::Lazy; +use regex::Regex; + +pub struct WebpackResourceQueries { + pub edge_ssr_entry: &'static str, + pub metadata: &'static str, + pub metadata_route: &'static str, + pub metadata_image_meta: &'static str, +} + +pub const WEBPACK_RESOURCE_QUERIES: WebpackResourceQueries = WebpackResourceQueries { + edge_ssr_entry: "__next_edge_ssr_entry__", + metadata: "__next_metadata__", + metadata_route: "__next_metadata_route__", + metadata_image_meta: "__next_metadata_image_meta__", +}; + +pub const BARREL_OPTIMIZATION_PREFIX: &'static str = "__barrel_optimize__"; + +pub const UNDERSCORE_NOT_FOUND_ROUTE: &str = "/_not-found"; +pub const UNDERSCORE_NOT_FOUND_ROUTE_ENTRY: &str = "/_not-found/page"; + +pub static REGEX_CSS: Lazy = Lazy::new(|| Regex::new(r"\.(css|scss|sass)(\?.*)?$").unwrap()); + +/// The names of the webpack layers. These layers are the primitives for the +/// webpack chunks. +pub const WEBPACK_LAYERS_NAMES: WebpackLayersNames = WebpackLayersNames { + shared: "shared", + react_server_components: "rsc", + server_side_rendering: "ssr", + action_browser: "action-browser", + api: "api", + middleware: "middleware", + instrument: "instrument", + edge_asset: "edge-asset", + app_pages_browser: "app-pages-browser", +}; + +pub struct WebpackLayersNames { + pub shared: &'static str, + pub react_server_components: &'static str, + pub server_side_rendering: &'static str, + pub action_browser: &'static str, + pub api: &'static str, + pub middleware: &'static str, + pub instrument: &'static str, + pub edge_asset: &'static str, + pub app_pages_browser: &'static str, +} + +pub type WebpackLayerName = &'static str; + +pub const WEBPACK_LAYERS: WebpackLayers = WebpackLayers { + shared: WEBPACK_LAYERS_NAMES.shared, + react_server_components: WEBPACK_LAYERS_NAMES.react_server_components, + server_side_rendering: WEBPACK_LAYERS_NAMES.server_side_rendering, + action_browser: WEBPACK_LAYERS_NAMES.action_browser, + api: WEBPACK_LAYERS_NAMES.api, + middleware: WEBPACK_LAYERS_NAMES.middleware, + instrument: WEBPACK_LAYERS_NAMES.instrument, + edge_asset: WEBPACK_LAYERS_NAMES.edge_asset, + app_pages_browser: WEBPACK_LAYERS_NAMES.app_pages_browser, + group: WebpackLayersGroup { + builtin_react: [ + WEBPACK_LAYERS_NAMES.react_server_components, + WEBPACK_LAYERS_NAMES.action_browser, + ], + server_only: [ + WEBPACK_LAYERS_NAMES.react_server_components, + WEBPACK_LAYERS_NAMES.action_browser, + WEBPACK_LAYERS_NAMES.instrument, + WEBPACK_LAYERS_NAMES.middleware, + ], + neutral_target: [WEBPACK_LAYERS_NAMES.api], + client_only: [ + WEBPACK_LAYERS_NAMES.server_side_rendering, + WEBPACK_LAYERS_NAMES.app_pages_browser, + ], + bundled: [ + WEBPACK_LAYERS_NAMES.react_server_components, + WEBPACK_LAYERS_NAMES.action_browser, + WEBPACK_LAYERS_NAMES.server_side_rendering, + WEBPACK_LAYERS_NAMES.app_pages_browser, + WEBPACK_LAYERS_NAMES.shared, + WEBPACK_LAYERS_NAMES.instrument, + ], + app_pages: [ + WEBPACK_LAYERS_NAMES.react_server_components, + WEBPACK_LAYERS_NAMES.server_side_rendering, + WEBPACK_LAYERS_NAMES.app_pages_browser, + WEBPACK_LAYERS_NAMES.action_browser, + ], + }, +}; + +pub struct WebpackLayers { + pub shared: WebpackLayerName, + pub react_server_components: WebpackLayerName, + pub server_side_rendering: WebpackLayerName, + pub action_browser: WebpackLayerName, + pub api: WebpackLayerName, + pub middleware: WebpackLayerName, + pub instrument: WebpackLayerName, + pub edge_asset: WebpackLayerName, + pub app_pages_browser: WebpackLayerName, + pub group: WebpackLayersGroup, +} + +pub struct WebpackLayersGroup { + pub builtin_react: [WebpackLayerName; 2], + pub server_only: [WebpackLayerName; 4], + pub neutral_target: [WebpackLayerName; 1], + pub client_only: [WebpackLayerName; 2], + pub bundled: [WebpackLayerName; 6], + pub app_pages: [WebpackLayerName; 4], +} + +pub const APP_CLIENT_INTERNALS: &'static str = "app-pages-internals"; + +// server/server-reference-manifest +pub const SERVER_REFERENCE_MANIFEST: &str = "server-reference-manifest"; diff --git a/crates/rspack_plugin_next_flight_client_entry/src/for_each_entry_module.rs b/crates/rspack_plugin_next_flight_client_entry/src/for_each_entry_module.rs new file mode 100644 index 000000000000..3b8850f94d66 --- /dev/null +++ b/crates/rspack_plugin_next_flight_client_entry/src/for_each_entry_module.rs @@ -0,0 +1,93 @@ +use rspack_core::{Compilation, DependenciesBlock, EntryData, ModuleGraph, NormalModule}; + +pub struct EntryModuleIter<'a> { + module_graph: &'a ModuleGraph<'a>, + entries_iter: indexmap::map::Iter<'a, String, EntryData>, +} + +impl<'a> EntryModuleIter<'a> { + fn new(compilation: &'a Compilation, module_graph: &'a ModuleGraph<'a>) -> Self { + Self { + module_graph, + entries_iter: compilation.entries.iter(), + } + } +} + +impl<'a> Iterator for EntryModuleIter<'a> { + type Item = (&'a String, &'a NormalModule); + + fn next(&mut self) -> Option { + let module_graph = &self.module_graph; + + loop { + if let Some((name, entry)) = self.entries_iter.next() { + // Skip for entries under pages/ + if name.starts_with("pages/") { + continue; + } + + // Check if the page entry is a server component or not. + let Some(dependency_id) = entry.dependencies.get(0) else { + continue; + }; + + // Ensure only next-app-loader entries are handled. + let Some(entry_dependency) = module_graph.dependency_by_id(dependency_id) else { + continue; + }; + let Some(entry_dependency) = entry_dependency.as_module_dependency() else { + continue; + }; + + let request = entry_dependency.request(); + + if !request.starts_with("next-edge-ssr-loader?") + && !request.starts_with("next-edge-app-route-loader?") + && !(request.starts_with(&format!("{}?", "next-app-loader")) + || request.starts_with(&format!("{}?", "builtin:next-app-loader"))) + { + continue; + } + + let Some(entry_module) = module_graph.get_resolved_module(dependency_id) else { + continue; + }; + let Some(mut entry_module) = entry_module.as_normal_module() else { + continue; + }; + + if request.starts_with("next-edge-ssr-loader?") + || request.starts_with("next-edge-app-route-loader?") + { + for dependency_id in entry_module.get_dependencies() { + let Some(dependency) = module_graph.dependency_by_id(dependency_id) else { + continue; + }; + let Some(dependency) = dependency.as_module_dependency() else { + continue; + }; + if dependency.request().contains("next-app-loader") { + if let Some(module) = module_graph.get_resolved_module(dependency_id) { + if let Some(module) = module.as_normal_module() { + entry_module = module; + } + } + } + } + } + + return Some((name, entry_module)); + } else { + return None; + } + } + } +} + +pub fn for_each_entry_module<'a>( + compilation: &'a Compilation, + module_graph: &'a ModuleGraph<'a>, +) -> EntryModuleIter<'a> { + EntryModuleIter::new(compilation, module_graph) +} diff --git a/crates/rspack_plugin_next_flight_client_entry/src/get_module_build_info.rs b/crates/rspack_plugin_next_flight_client_entry/src/get_module_build_info.rs new file mode 100644 index 000000000000..cfc76730b4a5 --- /dev/null +++ b/crates/rspack_plugin_next_flight_client_entry/src/get_module_build_info.rs @@ -0,0 +1,46 @@ +use lazy_regex::Lazy; +use regex::Regex; +use rspack_core::Module; +use serde::Deserialize; + +static RSPACK_RSC_MODULE_INFORMATION: Lazy = Lazy::new(|| { + Regex::new(r"/\* __rspack_internal_rsc_module_information_do_not_use__ (\{[^}]+\}) \*/").unwrap() +}); + +const CLIENT_DIRECTIVE: &str = "use client"; +const SERVER_ACTION_DIRECTIVE: &str = "use server"; + +pub type RSCModuleType = &'static str; +pub const RSC_MODULE_TYPES: RSCModuleTypes = RSCModuleTypes { + client: "client", + server: "server", +}; + +pub struct RSCModuleTypes { + pub client: RSCModuleType, + pub server: RSCModuleType, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RSCMeta { + pub r#type: String, // RSCModuleType + pub actions: Option>, + pub action_ids: Option>, + pub client_refs: Option>, + pub client_entry_type: Option, + pub is_client_ref: bool, +} + +fn get_rsc_module_information(source: &str) -> Option { + RSPACK_RSC_MODULE_INFORMATION + .captures(source) + .and_then(|caps| caps.get(1).map(|m| m.as_str())) + .and_then(|info| serde_json::from_str(info).unwrap()) +} + +pub fn get_module_rsc_information(module: &dyn Module) -> Option { + module + .source() + .and_then(|s| get_rsc_module_information(s.source().as_ref())) +} diff --git a/crates/rspack_plugin_next_flight_client_entry/src/is_metadata_route.rs b/crates/rspack_plugin_next_flight_client_entry/src/is_metadata_route.rs new file mode 100644 index 000000000000..c03e153acf7a --- /dev/null +++ b/crates/rspack_plugin_next_flight_client_entry/src/is_metadata_route.rs @@ -0,0 +1,270 @@ +#[allow(unused_variables)] +use std::collections::HashMap; + +use lazy_regex::Lazy; +use regex::Regex; +use rspack_util::fx_hash::BuildFxHasher; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PossibleImageFileNameConvention { + Icon, + Apple, + Favicon, + Twitter, + OpenGraph, + Manifest, +} + +impl PossibleImageFileNameConvention { + pub fn as_str(&self) -> &'static str { + match self { + PossibleImageFileNameConvention::Icon => "icon", + PossibleImageFileNameConvention::Apple => "apple", + PossibleImageFileNameConvention::Favicon => "favicon", + PossibleImageFileNameConvention::Twitter => "twitter", + PossibleImageFileNameConvention::OpenGraph => "opengraph", + PossibleImageFileNameConvention::Manifest => "manifest", + } + } +} + +pub struct MetadataImage { + pub filename: &'static str, + pub extensions: &'static [&'static str], +} + +pub type StaticMetadataImages = + HashMap; + +pub static STATIC_METADATA_IMAGES: Lazy = Lazy::new(|| { + let mut map: StaticMetadataImages = Default::default(); + map.insert( + PossibleImageFileNameConvention::Icon, + MetadataImage { + filename: "icon", + extensions: &["ico", "jpg", "jpeg", "png", "svg"], + }, + ); + map.insert( + PossibleImageFileNameConvention::Apple, + MetadataImage { + filename: "apple-icon", + extensions: &["jpg", "jpeg", "png"], + }, + ); + map.insert( + PossibleImageFileNameConvention::Favicon, + MetadataImage { + filename: "favicon", + extensions: &["ico"], + }, + ); + map.insert( + PossibleImageFileNameConvention::OpenGraph, + MetadataImage { + filename: "opengraph-image", + extensions: &["jpg", "jpeg", "png", "gif"], + }, + ); + map.insert( + PossibleImageFileNameConvention::Twitter, + MetadataImage { + filename: "twitter-image", + extensions: &["jpg", "jpeg", "png", "gif"], + }, + ); + map +}); + +pub fn get_extension_regex_string( + static_extensions: &[&str], + dynamic_extensions: Option<&[&str]>, +) -> String { + if dynamic_extensions.is_none() { + return format!("\\.(?:{})", static_extensions.join("|")); + } + let dynamic_extensions = dynamic_extensions.unwrap(); + format!( + "(?:\\.({})|((\\[\\])?\\.({})))", + static_extensions.join("|"), + dynamic_extensions.join("|") + ) +} + +pub fn is_metadata_route_file( + app_dir_relative_path: &str, + page_extensions: &[&str], + with_extension: bool, +) -> bool { + let metadata_route_files_regex = vec![ + { + let mut page_extensions = page_extensions.to_vec(); + page_extensions.push("txt"); + Regex::new(&format!( + r"^[\\/]robots{}", + if with_extension { + get_extension_regex_string(&page_extensions, None) + } else { + String::new() + } + )) + .unwrap() + }, + { + let mut page_extensions = page_extensions.to_vec(); + page_extensions.push("webmanifest"); + page_extensions.push("json"); + Regex::new(&format!( + r"^[\\/]manifest{}", + if with_extension { + get_extension_regex_string(&page_extensions, None) + } else { + String::new() + } + )) + .unwrap() + }, + Regex::new(r"^[\\/]favicon\.ico$").unwrap(), + Regex::new(&format!( + r"[\\/]sitemap{}", + if with_extension { + get_extension_regex_string(&["xml"], Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap(), + { + let metadata = STATIC_METADATA_IMAGES + .get(&PossibleImageFileNameConvention::Icon) + .unwrap(); + Regex::new(&format!( + r"[\\/]{}\\d?{}", + metadata.filename, + if with_extension { + get_extension_regex_string(metadata.extensions, Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap() + }, + { + let metadata = STATIC_METADATA_IMAGES + .get(&PossibleImageFileNameConvention::Apple) + .unwrap(); + Regex::new(&format!( + r"[\\/]{}\\d?{}", + metadata.filename, + if with_extension { + get_extension_regex_string(metadata.extensions, Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap() + }, + { + let metadata = STATIC_METADATA_IMAGES + .get(&PossibleImageFileNameConvention::OpenGraph) + .unwrap(); + Regex::new(&format!( + r"[\\/]{}\\d?{}", + metadata.filename, + if with_extension { + get_extension_regex_string(metadata.extensions, Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap() + }, + { + let metadata = STATIC_METADATA_IMAGES + .get(&PossibleImageFileNameConvention::Twitter) + .unwrap(); + Regex::new(&format!( + r"[\\/]{}\\d?{}", + metadata.filename, + if with_extension { + get_extension_regex_string(metadata.extensions, Some(page_extensions)) + } else { + String::new() + } + )) + .unwrap() + }, + ]; + + let normalized_app_dir_relative_path = normalize_path_sep(app_dir_relative_path); + metadata_route_files_regex + .iter() + .any(|r| r.is_match(&normalized_app_dir_relative_path)) +} + +pub fn is_metadata_route(route: &str) -> bool { + let mut page = route.replace("^/?app/", "").replace("/route$", ""); + if !page.starts_with('/') { + page = format!("/{}", page); + } + + !page.ends_with("/page") && is_metadata_route_file(&page, &DEFAULT_EXTENSIONS, false) +} + +pub static DEFAULT_EXTENSIONS: Lazy> = + Lazy::new(|| vec!["js", "jsx", "ts", "tsx"]); + +fn normalize_path_sep(path: &str) -> String { + path.replace("\\", "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_extension_match_regex( + static_extensions: &[&str], + dynamic_extensions: Option<&[&str]>, + ) -> Regex { + Regex::new(&format!( + "^{}$", + get_extension_regex_string(static_extensions, dynamic_extensions) + )) + .unwrap() + } + + #[test] + fn test_with_dynamic_extensions() { + let regex = create_extension_match_regex(&["png", "jpg"], Some(&["tsx", "ts"])); + assert!(regex.is_match(".png")); + assert!(regex.is_match(".jpg")); + assert!(!regex.is_match(".webp")); + + assert!(regex.is_match(".tsx")); + assert!(regex.is_match(".ts")); + assert!(!regex.is_match(".js")); + } + + #[test] + fn test_match_dynamic_multi_routes_with_dynamic_extensions() { + let regex = create_extension_match_regex(&["png"], Some(&["ts"])); + assert!(regex.is_match(".png")); + assert!(!regex.is_match("[].png")); + + assert!(regex.is_match(".ts")); + assert!(regex.is_match("[].ts")); + assert!(!regex.is_match(".tsx")); + assert!(!regex.is_match("[].tsx")); + } + + #[test] + fn test_without_dynamic_extensions() { + let regex = create_extension_match_regex(&["png", "jpg"], None); + assert!(regex.is_match(".png")); + assert!(regex.is_match(".jpg")); + assert!(!regex.is_match(".webp")); + + assert!(!regex.is_match(".tsx")); + assert!(!regex.is_match(".js")); + } +} diff --git a/crates/rspack_plugin_next_flight_client_entry/src/lib.rs b/crates/rspack_plugin_next_flight_client_entry/src/lib.rs new file mode 100644 index 000000000000..e7af5a7a6b46 --- /dev/null +++ b/crates/rspack_plugin_next_flight_client_entry/src/lib.rs @@ -0,0 +1,1612 @@ +#![feature(let_chains)] + +mod constants; +mod for_each_entry_module; +mod get_module_build_info; +mod is_metadata_route; +mod loader_util; + +use std::{ + cmp::Ordering, + mem, + ops::DerefMut, + path::Path, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use constants::{ + APP_CLIENT_INTERNALS, BARREL_OPTIMIZATION_PREFIX, REGEX_CSS, SERVER_REFERENCE_MANIFEST, + UNDERSCORE_NOT_FOUND_ROUTE_ENTRY, WEBPACK_LAYERS, WEBPACK_RESOURCE_QUERIES, +}; +use derive_more::Debug; +use for_each_entry_module::for_each_entry_module; +use futures::future::BoxFuture; +use get_module_build_info::get_module_rsc_information; +use is_metadata_route::is_metadata_route; +use lazy_regex::Lazy; +use loader_util::{get_actions_from_build_info, is_client_component_entry_module, is_css_mod}; +use regex::Regex; +use rspack_collections::Identifiable; +use rspack_core::{ + rspack_sources::{RawSource, SourceExt}, + ApplyContext, AssetInfo, BoxDependency, ChunkGraph, Compilation, CompilationAsset, + CompilationProcessAssets, CompilerAfterEmit, CompilerFinishMake, CompilerOptions, Dependency, + DependencyId, EntryDependency, EntryOptions, Logger, Module, ModuleGraph, ModuleId, + ModuleIdentifier, NormalModule, Plugin, PluginContext, RuntimeSpec, +}; +use rspack_error::Result; +use rspack_hook::{plugin, plugin_hook}; +use rspack_paths::Utf8PathBuf; +use rspack_plugin_javascript::dependency::{ + CommonJsExportRequireDependency, ESMExportImportedSpecifierDependency, + ESMImportSpecifierDependency, +}; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use serde::Serialize; +use serde_json::json; +use sugar_path::SugarPath; + +static NEXT_DIST_ESM_REGEX: Lazy = + Lazy::new(|| Regex::new(r"[\\/]next[\\/]dist[\\/]esm[\\/]").unwrap()); + +static NEXT_DIST: Lazy = Lazy::new(|| { + format!( + "{}next{}dist{}", + std::path::MAIN_SEPARATOR, + std::path::MAIN_SEPARATOR, + std::path::MAIN_SEPARATOR + ) +}); + +#[derive(Clone, Serialize)] +pub struct Action { + pub workers: HashMap, + pub layer: HashMap, +} + +type Actions = HashMap; + +#[derive(Clone, Serialize)] +pub struct ModuleInfo { + pub module_id: String, + pub is_async: bool, +} + +#[derive(Default, Clone)] +pub struct ModulePair { + pub server: Option, + pub client: Option, +} + +#[derive(Default)] +pub struct State { + // A map to track "action" -> "list of bundles". + pub server_actions: Actions, + pub edge_server_actions: Actions, + + pub server_action_modules: HashMap, + pub edge_server_action_modules: HashMap, + + pub ssr_modules: HashMap, + pub edge_ssr_modules: HashMap, + + pub rsc_modules: HashMap, + pub edge_rsc_modules: HashMap, + + pub injected_client_entries: HashMap, +} + +pub type StateCb = Box BoxFuture<'static, Result<()>> + Sync + Send>; + +pub struct ShouldInvalidateCbCtx { + pub entry_name: String, + pub absolute_page_path: String, + pub bundle_path: String, + pub client_browser_loader: String, +} + +pub type ShouldInvalidateCb = Box bool + Sync + Send>; + +pub type InvalidateCb = Box; + +pub struct Options { + pub dev: bool, + pub app_dir: Utf8PathBuf, + pub is_edge_server: bool, + pub encryption_key: String, + pub builtin_app_loader: bool, + pub should_invalidate_cb: ShouldInvalidateCb, + pub invalidate_cb: InvalidateCb, + pub state_cb: StateCb, +} + +/// { [client import path]: [exported names] } +pub type ClientComponentImports = HashMap>; +pub type CssImports = HashMap>; + +type ActionIdNamePair = (String, String); + +struct ComponentInfo { + css_imports: CssImports, + client_component_imports: ClientComponentImports, + action_imports: Vec<(String, Vec)>, +} + +fn get_metadata_route_resource(request: &str) -> MetadataRouteLoaderOptions { + // e.g. next-metadata-route-loader?filePath=&isDynamicRouteExtension=1!?__next_metadata_route__ + let query = request + .split('!') + .next() + .unwrap() + .split("next-metadata-route-loader?") + .nth(1) + .unwrap(); + + parse(query) +} + +fn deduplicate_css_imports_for_entry(merged_css_imports: CssImports) -> CssImports { + // If multiple entry module connections are having the same CSS import, + // we only need to have one module to keep track of that CSS import. + // It is based on the fact that if a page or a layout is rendered in the + // given entry, all its parent layouts are always rendered too. + // This can avoid duplicate CSS imports in the generated CSS manifest, + // for example, if a page and its parent layout are both using the same + // CSS import, we only need to have the layout to keep track of that CSS + // import. + // To achieve this, we need to first collect all the CSS imports from + // every connection, and deduplicate them in the order of layers from + // top to bottom. The implementation can be generally described as: + // - Sort by number of `/` in the request path (the more `/`, the deeper) + // - When in the same depth, sort by the filename (template < layout < page and others) + + // Sort the connections as described above. + let mut sorted_css_imports: Vec<(String, Vec)> = merged_css_imports.into_iter().collect(); + sorted_css_imports.sort_by(|a, b| { + let (a_path, _) = a; + let (b_path, _) = b; + + let a_depth = a_path.split('/').count(); + let b_depth = b_path.split('/').count(); + + if a_depth != b_depth { + return a_depth.cmp(&b_depth); + } + + let a_name = std::path::Path::new(a_path) + .file_stem() + .unwrap() + .to_str() + .unwrap(); + let b_name = std::path::Path::new(b_path) + .file_stem() + .unwrap() + .to_str() + .unwrap(); + + let index_a = ["template", "layout"] + .iter() + .position(|&x| x == a_name) + .unwrap_or(usize::MAX); + let index_b = ["template", "layout"] + .iter() + .position(|&x| x == b_name) + .unwrap_or(usize::MAX); + + if index_a == usize::MAX { + return std::cmp::Ordering::Greater; + } + if index_b == usize::MAX { + return std::cmp::Ordering::Less; + } + index_a.cmp(&index_b) + }); + + let mut deduped_css_imports: CssImports = HashMap::default(); + let mut tracked_css_imports = HashSet::default(); + for (entry_name, css_imports) in sorted_css_imports { + for css_import in css_imports { + if tracked_css_imports.contains(&css_import) { + continue; + } + + // Only track CSS imports that are in files that can inherit CSS. + let filename = std::path::Path::new(&entry_name) + .file_stem() + .unwrap() + .to_str() + .unwrap(); + if ["template", "layout"].contains(&filename) { + tracked_css_imports.insert(css_import.clone()); + } + + deduped_css_imports + .entry(entry_name.clone()) + .or_insert_with(Vec::new) + .push(css_import.clone()); + } + } + + deduped_css_imports +} + +fn parse(query: &str) -> MetadataRouteLoaderOptions { + let params = querystring::querify(query); + let mut file_path = ""; + let mut is_dynamic_route_extension = "0"; + for (key, value) in params { + if key == "filePath" { + file_path = value; + } + if key == "isDynamicRouteExtension" { + is_dynamic_route_extension = value; + } + } + // Implement the parsing logic here + MetadataRouteLoaderOptions { + file_path, + is_dynamic_route_extension, + } +} + +/// For a given page path, this function ensures that there is no backslash +/// escaping slashes in the path. Example: +/// - `foo\/bar\/baz` -> `foo/bar/baz` +pub fn normalize_path_sep(path: &str) -> String { + path.replace("\\", "/") +} + +fn get_module_resource(module: &dyn Module) -> String { + if let Some(module) = module.as_normal_module() { + let resource_resolved_data = module.resource_resolved_data(); + let mod_path = resource_resolved_data + .resource_path + .as_ref() + .map(|path| path.as_str()) + .unwrap_or(""); + let mod_query = resource_resolved_data + .resource_query + .as_ref() + .map(|query| query.as_str()) + .unwrap_or(""); + // We have to always use the resolved request here to make sure the + // server and client are using the same module path (required by RSC), as + // the server compiler and client compiler have different resolve configs. + let mut mod_resource = format!("{}{}", mod_path, mod_query); + + // For the barrel optimization, we need to use the match resource instead + // because there will be 2 modules for the same file (same resource path) + // but they're different modules and can't be deduped via `visitedModule`. + // The first module is a virtual re-export module created by the loader. + if let Some(match_resource) = module.match_resource() { + if match_resource + .resource + .starts_with(BARREL_OPTIMIZATION_PREFIX) + { + mod_resource = format!("{}:{}", &match_resource.resource, mod_resource); + } + } + + if resource_resolved_data.resource == format!("?{}", WEBPACK_RESOURCE_QUERIES.metadata_route) { + return get_metadata_route_resource(module.raw_request()) + .file_path + .to_string(); + } + + mod_resource + } else if let Some(module) = module.as_context_module() { + module.identifier().to_string() + } else { + "".to_string() + } +} + +pub fn get_assumed_source_type(module: &dyn Module, source_type: String) -> String { + let rsc = get_module_rsc_information(module); + let detected_client_entry_type = rsc + .as_ref() + .and_then(|rsc| rsc.client_entry_type.as_deref()); + let client_refs: &[String] = rsc + .as_ref() + .and_then(|rsc| rsc.client_refs.as_ref().map(|r| r.as_slice())) + .unwrap_or_default(); + + // It's tricky to detect the type of a client boundary, but we should always + // use the `module` type when we can, to support `export *` and `export from` + // syntax in other modules that import this client boundary. + + if source_type == "auto" { + if detected_client_entry_type == Some("auto") { + if client_refs.is_empty() { + // If there's zero export detected in the client boundary, and it's the + // `auto` type, we can safely assume it's a CJS module because it doesn't + // have ESM exports. + return "commonjs".to_string(); + } else if !client_refs.iter().any(|e| e == "*") { + // Otherwise, we assume it's an ESM module. + return "module".to_string(); + } + } else if detected_client_entry_type == Some("cjs") { + return "commonjs".to_string(); + } + } + + source_type.to_string() +} + +fn add_client_import( + module: &dyn Module, + mod_request: &str, + client_component_imports: &mut ClientComponentImports, + imported_identifiers: &[String], + is_first_visit_module: bool, +) { + let rsc = get_module_rsc_information(module); + let client_entry_type = rsc.and_then(|rsc| rsc.client_entry_type); + let is_cjs_module = matches!(client_entry_type, Some(t) if t == "cjs"); + let assumed_source_type = get_assumed_source_type( + module, + if is_cjs_module { + "commonjs".to_string() + } else { + "auto".to_string() + }, + ); + + let client_imports_set = client_component_imports + .entry(mod_request.to_string()) + .or_insert_with(HashSet::default); + + if imported_identifiers + .get(0) + .map(|identifier| identifier.as_str()) + == Some("*") + { + // If there's collected import path with named import identifiers, + // or there's nothing in collected imports are empty. + // we should include the whole module. + if !is_first_visit_module && !client_imports_set.contains("*") { + client_component_imports.insert( + mod_request.to_string(), + HashSet::from_iter(["*".to_string()]), + ); + } + } else { + let is_auto_module_source_type = assumed_source_type == "auto"; + if is_auto_module_source_type { + client_component_imports.insert( + mod_request.to_string(), + HashSet::from_iter(["*".to_string()]), + ); + } else { + // If it's not analyzed as named ESM exports, e.g. if it's mixing `export *` with named exports, + // We'll include all modules since it's not able to do tree-shaking. + for name in imported_identifiers { + // For cjs module default import, we include the whole module since + let is_cjs_default_import = is_cjs_module && name == "default"; + + // Always include __esModule along with cjs module default export, + // to make sure it works with client module proxy from React. + if is_cjs_default_import { + client_imports_set.insert("__esModule".to_string()); + } + + client_imports_set.insert(name.clone()); + } + } + } +} + +fn collect_actions_in_dep( + module: &NormalModule, + module_graph: &ModuleGraph, + collected_actions: &mut HashMap>, + visited_module: &mut HashSet, +) { + let mod_resource = get_module_resource(module); + if mod_resource.is_empty() { + return; + } + + if visited_module.contains(&mod_resource) { + return; + } + visited_module.insert(mod_resource.clone()); + + let actions = get_actions_from_build_info(module); + if let Some(actions) = actions { + collected_actions.insert(mod_resource.clone(), actions.into_iter().collect()); + } + + // Collect used exported actions transversely. + for dependency_id in module_graph.get_outgoing_connections_in_order(&module.identifier()) { + let Some(connection) = module_graph.connection_by_dependency_id(dependency_id) else { + continue; + }; + let Some(resolved_module) = module_graph.module_by_identifier(&connection.resolved_module) + else { + continue; + }; + let Some(resolved_module) = resolved_module.as_normal_module() else { + continue; + }; + collect_actions_in_dep( + resolved_module, + module_graph, + collected_actions, + visited_module, + ); + } +} + +struct MetadataRouteLoaderOptions<'a> { + file_path: &'a str, + is_dynamic_route_extension: &'a str, +} + +struct ClientEntry { + // compiler: Compiler, + // compilation: &'a Compilation, + entry_name: String, + client_imports: ClientComponentImports, + bundle_path: String, + absolute_page_path: String, +} + +struct ActionEntry<'a> { + actions: HashMap>, + entry_name: String, + bundle_path: String, + from_client: bool, + created_action_ids: &'a mut HashSet, +} + +#[derive(Serialize)] +pub struct Manifest { + // Assign a unique encryption key during production build. + pub encryption_key: String, + pub node: Actions, + pub edge: Actions, +} + +#[plugin] +#[derive(Debug)] +pub struct FlightClientEntryPlugin { + dev: bool, + app_dir: Utf8PathBuf, + is_edge_server: bool, + encryption_key: String, + asset_prefix: &'static str, + webpack_runtime: RuntimeSpec, + app_loader: &'static str, + #[debug(skip)] + should_invalidate_cb: ShouldInvalidateCb, + #[debug(skip)] + invalidate_cb: InvalidateCb, + #[debug(skip)] + state_cb: StateCb, + + #[debug(skip)] + plugin_state: Mutex, +} + +impl FlightClientEntryPlugin { + pub fn new(options: Options) -> Self { + let asset_prefix = if !options.dev && !options.is_edge_server { + "../" + } else { + "" + }; + let webpack_runtime = if options.is_edge_server { + "edge-runtime-webpack" + } else { + "webpack-runtime" + }; + let app_loader = if options.builtin_app_loader { + "builtin:next-app-loader" + } else { + "next-app-loader" + }; + let mut set: HashSet> = HashSet::default(); + set.insert(Arc::from(webpack_runtime.to_string())); + let webpack_runtime = RuntimeSpec::new(set); + + Self::new_inner( + options.dev, + options.app_dir, + options.is_edge_server, + options.encryption_key, + asset_prefix, + webpack_runtime, + app_loader, + options.should_invalidate_cb, + options.invalidate_cb, + options.state_cb, + Mutex::new(Default::default()), + ) + } + + fn filter_client_components( + &self, + module: &dyn Module, + imported_identifiers: &[String], + visited: &mut HashSet, + client_component_imports: &mut ClientComponentImports, + action_imports: &mut Vec<(String, Vec)>, + css_imports: &mut HashSet, + compilation: &Compilation, + ) { + let mod_resource = get_module_resource(module); + if mod_resource.is_empty() { + return; + } + if visited.contains(&mod_resource) { + if client_component_imports.contains_key(&mod_resource) { + add_client_import( + module, + &mod_resource, + client_component_imports, + imported_identifiers, + false, + ); + } + return; + } + visited.insert(mod_resource.clone()); + + let actions = get_actions_from_build_info(module); + if let Some(actions) = actions { + action_imports.push((mod_resource.clone(), actions.into_iter().collect())); + } + + let module_graph = compilation.get_module_graph(); + if is_css_mod(module) { + let side_effect_free = module + .factory_meta() + .map_or(false, |meta| meta.side_effect_free.unwrap_or(false)); + + if side_effect_free { + let unused = !module_graph + .get_exports_info(&module.identifier()) + .is_module_used(&module_graph, Some(&self.webpack_runtime)); + + if unused { + return; + } + } + + css_imports.insert(mod_resource); + } else if is_client_component_entry_module(module) { + if !client_component_imports.contains_key(&mod_resource) { + client_component_imports.insert(mod_resource.clone(), HashSet::default()); + } + add_client_import( + module, + &mod_resource, + client_component_imports, + imported_identifiers, + true, + ); + + return; + } + + for dependency_id in module_graph.get_outgoing_connections_in_order(&module.identifier()) { + let Some(connection) = module_graph.connection_by_dependency_id(dependency_id) else { + continue; + }; + let mut dependency_ids = Vec::new(); + + // `ids` are the identifiers that are imported from the dependency, + // if it's present, it's an array of strings. + let Some(dependency) = module_graph.dependency_by_id(&connection.dependency_id) else { + continue; + }; + let ids = + if let Some(dependency) = dependency.downcast_ref::() { + Some(dependency.get_ids(&module_graph)) + } else if let Some(dependency) = + dependency.downcast_ref::() + { + Some(dependency.get_ids(&module_graph)) + } else if let Some(dependency) = dependency.downcast_ref::() { + Some(dependency.get_ids(&module_graph)) + } else { + None + }; + if let Some(ids) = ids { + for id in ids { + dependency_ids.push(id.to_string()); + } + } else { + dependency_ids.push("*".into()); + } + + let Some(resolved_module) = module_graph.module_by_identifier(&connection.resolved_module) + else { + continue; + }; + self.filter_client_components( + resolved_module.as_ref(), + &dependency_ids, + visited, + client_component_imports, + action_imports, + css_imports, + compilation, + ); + } + } + + fn inject_client_entry_and_ssr_modules( + &self, + compilation: &Compilation, + client_entry: ClientEntry, + ) -> InjectedClientEntry { + let ClientEntry { + entry_name, + client_imports, + bundle_path, + absolute_page_path, + } = client_entry; + + let mut should_invalidate = false; + + let mut modules: Vec<_> = client_imports + .keys() + .map(|client_import_path| { + let ids: Vec<_> = client_imports[client_import_path].iter().cloned().collect(); + (client_import_path.clone(), ids) + }) + .collect(); + + modules.sort_unstable_by(|a, b| { + let a_is_css = REGEX_CSS.is_match(&a.0); + let b_is_css = REGEX_CSS.is_match(&b.0); + match (a_is_css, b_is_css) { + (false, true) => Ordering::Less, + (true, false) => Ordering::Greater, + (_, _) => a.0.cmp(&b.0), + } + }); + + // For the client entry, we always use the CJS build of Next.js. If the + // server is using the ESM build (when using the Edge runtime), we need to + // replace them. + let client_browser_loader = { + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (request, ids) in &modules { + let module_json = if self.is_edge_server { + serde_json::to_string(&json!({ + "request": NEXT_DIST_ESM_REGEX.replace(request, &*NEXT_DIST), + "ids": ids + })) + .unwrap() + } else { + serde_json::to_string(&json!({ + "request": request, + "ids": ids + })) + .unwrap() + }; + serializer.append_pair("modules", &module_json); + } + serializer.append_pair("server", "false"); + + format!("next-flight-client-entry-loader?{}!", serializer.finish()) + }; + + let client_server_loader = { + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (request, ids) in &modules { + let module_json = serde_json::to_string(&json!({ + "request": request, + "ids": ids + })) + .unwrap(); + serializer.append_pair("modules", &module_json); + } + serializer.append_pair("server", "true"); + format!("next-flight-client-entry-loader?{}!", serializer.finish()) + }; + + // Add for the client compilation + // Inject the entry to the client compiler. + if self.dev { + let should_invalidate_cb_ctx = ShouldInvalidateCbCtx { + entry_name: entry_name.to_string(), + absolute_page_path, + bundle_path, + client_browser_loader: client_browser_loader.to_string(), + }; + let should_invalidate_cb = &self.should_invalidate_cb; + should_invalidate = should_invalidate_cb(should_invalidate_cb_ctx); + } else { + let mut plugin_state = self.plugin_state.lock().unwrap(); + plugin_state + .injected_client_entries + .insert(bundle_path, client_browser_loader.clone()); + } + + let client_component_ssr_entry_dep = EntryDependency::new( + client_server_loader.to_string(), + compilation.options.context.clone(), + Some(WEBPACK_LAYERS.server_side_rendering.to_string()), + false, + ); + let ssr_dep = *(client_component_ssr_entry_dep.id()); + + let client_component_rsc_entry_dep = EntryDependency::new( + client_server_loader, + compilation.options.context.clone(), + Some(WEBPACK_LAYERS.react_server_components.to_string()), + false, + ); + + InjectedClientEntry { + should_invalidate, + add_ssr_entry: ( + Box::new(client_component_ssr_entry_dep), + EntryOptions { + name: Some(entry_name.to_string()), + layer: Some(WEBPACK_LAYERS.server_side_rendering.to_string()), + ..Default::default() + }, + ), + add_rsc_entry: ( + Box::new(client_component_rsc_entry_dep), + EntryOptions { + name: Some(entry_name.to_string()), + layer: Some(WEBPACK_LAYERS.react_server_components.to_string()), + ..Default::default() + }, + ), + ssr_dep, + } + } + + fn inject_action_entry( + &self, + compilation: &Compilation, + action_entry: ActionEntry, + ) -> Option { + let ActionEntry { + actions, + entry_name, + bundle_path, + from_client, + created_action_ids, + } = action_entry; + + if actions.is_empty() { + return None; + } + + for (_, actions_from_module) in &actions { + for (id, _) in actions_from_module { + created_action_ids.insert(format!("{}@{}", entry_name, id)); + } + } + + let action_loader = format!( + "next-flight-action-entry-loader?{}!", + serde_json::to_string(&json!({ + "actions": serde_json::to_string(&actions).unwrap(), + "__client_imported__": from_client, + })) + .unwrap() + ); + + let mut plugin_state = self.plugin_state.lock().unwrap(); + let current_compiler_server_actions = if self.is_edge_server { + &mut plugin_state.edge_server_actions + } else { + &mut plugin_state.server_actions + }; + + for (_, actions_from_module) in &actions { + for (id, _) in actions_from_module { + if !current_compiler_server_actions.contains_key(id) { + current_compiler_server_actions.insert( + id.clone(), + Action { + workers: HashMap::default(), + layer: HashMap::default(), + }, + ); + } + let action = current_compiler_server_actions.get_mut(id).unwrap(); + action.workers.insert( + bundle_path.to_string(), + ModuleInfo { + module_id: "".to_string(), // TODO: What's the meaning of this? + is_async: false, + }, + ); + + action.layer.insert( + bundle_path.to_string(), + if from_client { + WEBPACK_LAYERS.action_browser.to_string() + } else { + WEBPACK_LAYERS.react_server_components.to_string() + }, + ); + } + } + + // Inject the entry to the server compiler + let layer = if from_client { + WEBPACK_LAYERS.action_browser.to_string() + } else { + WEBPACK_LAYERS.react_server_components.to_string() + }; + let action_entry_dep = EntryDependency::new( + action_loader, + compilation.options.context.clone(), + Some(layer.to_string()), + false, + ); + + Some(( + Box::new(action_entry_dep), + EntryOptions { + name: Some(entry_name.to_string()), + layer: Some(layer), + ..Default::default() + }, + )) + } + + fn collect_component_info_from_server_entry_dependency( + &self, + entry_request: &str, + compilation: &Compilation, + resolved_module: &dyn Module, + ) -> ComponentInfo { + // Keep track of checked modules to avoid infinite loops with recursive imports. + let mut visited_of_client_components_traverse: HashSet = HashSet::default(); + + // Info to collect. + let mut client_component_imports: ClientComponentImports = HashMap::default(); + let mut action_imports: Vec<(String, Vec)> = Vec::new(); + let mut css_imports: HashSet = Default::default(); + + // Traverse the module graph to find all client components. + self.filter_client_components( + resolved_module, + &[], + &mut visited_of_client_components_traverse, + &mut client_component_imports, + &mut action_imports, + &mut css_imports, + compilation, + ); + + let mut css_imports_map: CssImports = HashMap::default(); + css_imports_map.insert(entry_request.to_string(), css_imports.into_iter().collect()); + + ComponentInfo { + css_imports: css_imports_map, + client_component_imports, + action_imports, + } + } + + fn collect_client_actions_from_dependencies( + &self, + compilation: &Compilation, + dependencies: Vec, + ) -> Vec<(String, Vec)> { + // action file path -> action names + let mut collected_actions = HashMap::default(); + + // Keep track of checked modules to avoid infinite loops with recursive imports. + let mut visited_module = HashSet::default(); + let mut visited_entry = HashSet::default(); + + let module_graph = compilation.get_module_graph(); + + for entry_dependency_id in &dependencies { + let Some(ssr_entry_module) = module_graph.get_resolved_module(entry_dependency_id) else { + continue; + }; + for dependency_id in + module_graph.get_outgoing_connections_in_order(&ssr_entry_module.identifier()) + { + let Some(dep_module) = module_graph.dependency_by_id(dependency_id) else { + continue; + }; + let Some(dep_module) = dep_module.as_module_dependency() else { + continue; + }; + let request = dep_module.request(); + + // It is possible that the same entry is added multiple times in the + // connection graph. We can just skip these to speed up the process. + if visited_entry.contains(request) { + continue; + } + visited_entry.insert(request); + + let Some(connection) = module_graph.connection_by_dependency_id(dependency_id) else { + continue; + }; + let Some(resolved_module) = module_graph.module_by_identifier(&connection.resolved_module) + else { + continue; + }; + let Some(resolved_module) = resolved_module.as_normal_module() else { + continue; + }; + // Don't traverse the module graph anymore once hitting the action layer. + if !request.contains("next-flight-action-entry-loader") { + // Traverse the module graph to find all client components. + collect_actions_in_dep( + resolved_module, + &module_graph, + &mut collected_actions, + &mut visited_module, + ); + } + } + } + + collected_actions.into_iter().collect() + } + + async fn create_client_entries(&self, compilation: &mut Compilation) -> Result<()> { + let mut add_client_entry_and_ssr_modules_list: Vec = Vec::new(); + + let mut created_ssr_dependencies_for_entry: HashMap> = + HashMap::default(); + + let mut add_action_entry_list: Vec = Vec::new(); + + let mut action_maps_per_entry: HashMap>> = + HashMap::default(); + + let mut created_action_ids: HashSet = HashSet::default(); + + let module_graph = compilation.get_module_graph(); + for (name, entry_module) in for_each_entry_module(&compilation, &module_graph) { + let mut internal_client_component_entry_imports = ClientComponentImports::default(); + let mut action_entry_imports: HashMap> = HashMap::default(); + let mut client_entries_to_inject = Vec::new(); + let mut merged_css_imports: CssImports = CssImports::default(); + + for dependency_id in module_graph.get_outgoing_connections_in_order(&entry_module.id()) { + let Some(connection) = module_graph.connection_by_dependency_id(dependency_id) else { + continue; + }; + let Some(dependency) = module_graph.dependency_by_id(&connection.dependency_id) else { + continue; + }; + let Some(dependency) = dependency.as_module_dependency() else { + continue; + }; + // Entry can be any user defined entry files such as layout, page, error, loading, etc. + let mut entry_request = dependency.request(); + + if entry_request.ends_with(WEBPACK_RESOURCE_QUERIES.metadata_route) { + let metadata_route_resource = get_metadata_route_resource(&entry_request); + if metadata_route_resource.is_dynamic_route_extension == "1" { + entry_request = metadata_route_resource.file_path; + } + } + + let Some(resolved_module) = module_graph.module_by_identifier(&connection.resolved_module) + else { + continue; + }; + let component_info = self.collect_component_info_from_server_entry_dependency( + &entry_request, + &compilation, + resolved_module.as_ref(), + ); + + for (dep, actions) in component_info.action_imports { + action_entry_imports.insert(dep, actions); + } + + let entry_request = Path::new(entry_request); + + let is_absolute_request = entry_request.is_absolute(); + + // Next.js internals are put into a separate entry. + if !is_absolute_request { + for value in component_info.client_component_imports.keys() { + internal_client_component_entry_imports.insert(value.to_string(), HashSet::default()); + } + continue; + } + + // TODO-APP: Enable these lines. This ensures no entrypoint is created for layout/page when there are no client components. + // Currently disabled because it causes test failures in CI. + // if client_imports.is_empty() && action_imports.is_empty() { + // continue; + // } + + let relative_request = if is_absolute_request { + entry_request + .relative(&compilation.options.context) + .to_string_lossy() + .to_string() + } else { + entry_request.to_string_lossy().to_string() + }; + let re1 = Regex::new(r"\.[^.\\/]+$").unwrap(); + let re2 = Regex::new(r"^src[\\/]").unwrap(); + let replaced_path = &re1.replace_all(&relative_request, ""); + let replaced_path = re2.replace_all(&replaced_path, ""); + + // Replace file suffix as `.js` will be added. + let mut bundle_path = normalize_path_sep(&replaced_path); + + // For metadata routes, the entry name can be used as the bundle path, + // as it has been normalized already. + if is_metadata_route(&bundle_path) { + bundle_path = name.to_string(); + } + + merged_css_imports.extend(component_info.css_imports); + + client_entries_to_inject.push(ClientEntry { + entry_name: name.to_string(), + client_imports: component_info.client_component_imports, + bundle_path: bundle_path.clone(), + absolute_page_path: entry_request.to_string_lossy().to_string(), + }); + + // The webpack implementation of writing the client reference manifest relies on all entrypoints writing a page.js even when there is no client components in the page. + // It needs the file in order to write the reference manifest for the path in the `.next/server` folder. + // TODO-APP: This could be better handled, however Turbopack does not have the same problem as we resolve client components in a single graph. + if *name == format!("app{}", UNDERSCORE_NOT_FOUND_ROUTE_ENTRY) + && bundle_path == "app/not-found" + { + client_entries_to_inject.push(ClientEntry { + entry_name: name.to_string(), + client_imports: HashMap::default(), + bundle_path: format!("app{}", UNDERSCORE_NOT_FOUND_ROUTE_ENTRY), + absolute_page_path: entry_request.to_string_lossy().to_string(), + }); + } + } + + // Make sure CSS imports are deduplicated before injecting the client entry + // and SSR modules. + let deduped_css_imports = deduplicate_css_imports_for_entry(merged_css_imports); + for mut client_entry_to_inject in client_entries_to_inject { + let client_imports = &mut client_entry_to_inject.client_imports; + if let Some(css_imports) = + deduped_css_imports.get(&client_entry_to_inject.absolute_page_path) + { + for curr in css_imports { + client_imports.insert(curr.clone(), HashSet::default()); + } + } + + let entry_name = client_entry_to_inject.entry_name.to_string(); + let injected = + self.inject_client_entry_and_ssr_modules(compilation, client_entry_to_inject); + + // Track all created SSR dependencies for each entry from the server layer. + created_ssr_dependencies_for_entry + .entry(entry_name) + .or_insert_with(Vec::new) + .push(injected.ssr_dep); + + add_client_entry_and_ssr_modules_list.push(injected); + } + + if !is_app_route_route(name.as_str()) { + // Create internal app + add_client_entry_and_ssr_modules_list.push(self.inject_client_entry_and_ssr_modules( + compilation, + ClientEntry { + entry_name: name.to_string(), + client_imports: internal_client_component_entry_imports, + bundle_path: APP_CLIENT_INTERNALS.to_string(), + absolute_page_path: "".to_string(), + }, + )); + } + + if !action_entry_imports.is_empty() { + if !action_maps_per_entry.contains_key(name) { + action_maps_per_entry.insert(name.to_string(), HashMap::default()); + } + let entry = action_maps_per_entry.get_mut(name).unwrap(); + for (key, value) in action_entry_imports { + entry.insert(key, value); + } + } + } + + for (name, action_entry_imports) in action_maps_per_entry { + self + .inject_action_entry( + compilation, + ActionEntry { + actions: action_entry_imports, + entry_name: name.clone(), + bundle_path: name, + from_client: false, + created_action_ids: &mut created_action_ids, + }, + ) + .map(|injected| add_action_entry_list.push(injected)); + } + + // Invalidate in development to trigger recompilation + if self.dev { + // Check if any of the entry injections need an invalidation + if add_client_entry_and_ssr_modules_list + .iter() + .any(|injected| injected.should_invalidate) + { + let invalidate_cb = self.invalidate_cb.as_ref(); + invalidate_cb(); + } + } + + // Client compiler is invalidated before awaiting the compilation of the SSR + // and RSC client component entries so that the client compiler is running + // in parallel to the server compiler. + + // Wait for action entries to be added. + let args = add_client_entry_and_ssr_modules_list + .into_iter() + .flat_map(|add_client_entry_and_ssr_modules| { + vec![ + add_client_entry_and_ssr_modules.add_rsc_entry, + add_client_entry_and_ssr_modules.add_ssr_entry, + ] + }) + .chain(add_action_entry_list.into_iter()) + .collect::>(); + let included_deps: Vec<_> = args.iter().map(|(dep, _)| *dep.id()).collect(); + compilation.add_include(args).await?; + for dep in included_deps { + let mut mg = compilation.get_module_graph_mut(); + let Some(m) = mg.get_module_by_dependency_id(&dep) else { + continue; + }; + let info = mg.get_exports_info(&m.identifier()); + info.set_used_in_unknown_way(&mut mg, Some(&self.webpack_runtime)); + } + + let mut added_client_action_entry_list: Vec = Vec::new(); + let mut action_maps_per_client_entry: HashMap>> = + HashMap::default(); + + // We need to create extra action entries that are created from the + // client layer. + // Start from each entry's created SSR dependency from our previous step. + for (name, ssr_entry_dependencies) in created_ssr_dependencies_for_entry { + // Collect from all entries, e.g. layout.js, page.js, loading.js, ... + // add aggregate them. + let action_entry_imports = + self.collect_client_actions_from_dependencies(compilation, ssr_entry_dependencies); + + if !action_entry_imports.is_empty() { + if !action_maps_per_client_entry.contains_key(&name) { + action_maps_per_client_entry.insert(name.clone(), HashMap::default()); + } + let entry = action_maps_per_client_entry.get_mut(&name).unwrap(); + for (key, value) in action_entry_imports { + entry.insert(key.clone(), value); + } + } + } + + for (entry_name, action_entry_imports) in action_maps_per_client_entry { + // If an action method is already created in the server layer, we don't + // need to create it again in the action layer. + // This is to avoid duplicate action instances and make sure the module + // state is shared. + let mut remaining_client_imported_actions = false; + let mut remaining_action_entry_imports = HashMap::default(); + for (dep, actions) in action_entry_imports { + let mut remaining_action_names = Vec::new(); + for (id, name) in actions { + // `action` is a [id, name] pair. + if !created_action_ids.contains(&format!("{}@{}", entry_name, &id)) { + remaining_action_names.push((id, name)); + } + } + if !remaining_action_names.is_empty() { + remaining_action_entry_imports.insert(dep.clone(), remaining_action_names); + remaining_client_imported_actions = true; + } + } + + if remaining_client_imported_actions { + self + .inject_action_entry( + compilation, + ActionEntry { + actions: remaining_action_entry_imports, + entry_name: entry_name.clone(), + bundle_path: entry_name.clone(), + from_client: true, + created_action_ids: &mut created_action_ids, + }, + ) + .map(|injected| added_client_action_entry_list.push(injected)); + } + } + let included_deps: Vec<_> = added_client_action_entry_list + .iter() + .map(|(dep, _)| *dep.id()) + .collect(); + compilation + .add_include(added_client_action_entry_list) + .await?; + for dep in included_deps { + let mut mg = compilation.get_module_graph_mut(); + let Some(m) = mg.get_module_by_dependency_id(&dep) else { + continue; + }; + let info = mg.get_exports_info(&m.identifier()); + info.set_used_in_unknown_way(&mut mg, Some(&self.webpack_runtime)); + } + Ok(()) + } + + fn create_action_assets(&self, compilation: &mut Compilation) { + let mut server_manifest = Manifest { + encryption_key: self.encryption_key.to_string(), + node: HashMap::default(), + edge: HashMap::default(), + }; + + let mut plugin_state = self.plugin_state.lock().unwrap(); + + // traverse modules + for chunk_group in compilation.chunk_group_by_ukey.values() { + for chunk_ukey in &chunk_group.chunks { + let chunk_modules = compilation + .chunk_graph + .get_chunk_modules_identifier(chunk_ukey); + for module_identifier in chunk_modules { + // Go through all action entries and record the module ID for each entry. + let Some(chunk_group_name) = chunk_group.name() else { + continue; + }; + let module = compilation.module_by_identifier(module_identifier); + let Some(module) = module else { + continue; + }; + let Some(module) = module.as_normal_module() else { + continue; + }; + let mod_request = module.request(); + let Some(module_id) = + ChunkGraph::get_module_id(&compilation.module_ids_artifact, *module_identifier) + else { + continue; + }; + if mod_request.contains("next-flight-action-entry-loader") { + let from_client = mod_request.contains("&__client_imported__=true"); + + let mapping = if self.is_edge_server { + &mut plugin_state.edge_server_action_modules + } else { + &mut plugin_state.server_action_modules + }; + + let module_pair = mapping + .entry(chunk_group_name.to_string()) + .or_insert_with(Default::default); + let module_info = ModuleInfo { + module_id: module_id.to_string(), + is_async: ModuleGraph::is_async(&compilation, module_identifier), + }; + if from_client { + module_pair.client = Some(module_info); + } else { + module_pair.server = Some(module_info); + } + } + } + } + } + + for (id, mut action) in plugin_state.server_actions.clone() { + for (name, workers) in &mut action.workers { + let module_pair = plugin_state + .server_action_modules + .entry(name.to_string()) + .or_insert_with(Default::default); + let module_info = if action.layer.get(name).map(|layer| layer.as_str()) + == Some(WEBPACK_LAYERS.action_browser) + { + module_pair.client.clone() + } else { + module_pair.server.clone() + }; + if let Some(module_info) = module_info { + plugin_state + .server_actions + .get_mut(&id) + .unwrap() + .workers + .insert(name.to_string(), module_info.clone()); + *workers = module_info; + } + } + server_manifest.node.insert(id.clone(), action); + } + + for (id, mut action) in plugin_state.edge_server_actions.clone() { + for (name, workers) in &mut action.workers { + let module_pair = plugin_state + .edge_server_action_modules + .entry(name.to_string()) + .or_insert_with(Default::default); + let module_info = if action.layer.get(name).map(|layer| layer.as_str()) + == Some(WEBPACK_LAYERS.action_browser) + { + module_pair.client.clone() + } else { + module_pair.server.clone() + }; + if let Some(module_info) = module_info { + plugin_state + .server_actions + .get_mut(&id) + .unwrap() + .workers + .insert(name.to_string(), module_info.clone()); + *workers = module_info; + } + } + server_manifest.edge.insert(id.clone(), action); + } + + let edge_server_manifest = Manifest { + encryption_key: "process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY".to_string(), + node: server_manifest.node.clone(), + edge: server_manifest.edge.clone(), + }; + + let json = if self.dev { + serde_json::to_string_pretty(&server_manifest).unwrap() + } else { + serde_json::to_string(&server_manifest).unwrap() + }; + let edge_json = if self.dev { + serde_json::to_string_pretty(&edge_server_manifest).unwrap() + } else { + serde_json::to_string(&edge_server_manifest).unwrap() + }; + + let assets = compilation.assets_mut(); + assets.insert( + format!("{}{}.js", self.asset_prefix, SERVER_REFERENCE_MANIFEST), + CompilationAsset::new( + Some( + RawSource::from(format!( + "self.__RSC_SERVER_MANIFEST={}", + serde_json::to_string(&edge_json).unwrap() + )) + .boxed(), + ), + AssetInfo::default(), + ), + ); + + assets.insert( + format!("{}{}.json", self.asset_prefix, SERVER_REFERENCE_MANIFEST), + CompilationAsset::new(Some(RawSource::from(json).boxed()), AssetInfo::default()), + ); + } +} + +#[plugin_hook(CompilerFinishMake for FlightClientEntryPlugin)] +async fn finish_make(&self, compilation: &mut Compilation) -> Result<()> { + let logger = compilation.get_logger("rspack.FlightClientEntryPlugin"); + + let start = logger.time("create client entries"); + self.create_client_entries(compilation).await?; + logger.time_end(start); + + Ok(()) +} + +// Next.js uses the after compile hook, but after emit should achieve the same result +#[plugin_hook(CompilerAfterEmit for FlightClientEntryPlugin)] +async fn after_emit(&self, compilation: &mut Compilation) -> Result<()> { + let logger = compilation.get_logger("rspack.FlightClientEntryPlugin"); + + let start = logger.time("after emit"); + let state = { + let module_graph = compilation.get_module_graph(); + let mut plugin_state = self.plugin_state.lock().unwrap(); + + let record_module = &mut |module_id: &ModuleId, module_identifier: &ModuleIdentifier| { + let Some(module) = module_graph.module_by_identifier(module_identifier) else { + return; + }; + let Some(module) = module.as_normal_module() else { + return; + }; + // Match Resource is undefined unless an import is using the inline match resource syntax + // https://webpack.js.org/api/loaders/#inline-matchresource + let resource_resolved_data = module.resource_resolved_data(); + let mod_path = module + .match_resource() + .map(|match_resource| match_resource.resource.clone()) + .or_else(|| { + resource_resolved_data + .resource_path + .as_ref() + .map(|path| path.to_string()) + }); + let mod_query = resource_resolved_data + .resource_query + .as_ref() + .map(|query| query.as_str()) + .unwrap_or_default(); + // query is already part of mod.resource + // so it's only necessary to add it for matchResource or mod.resourceResolveData + let mod_resource = if let Some(mod_path) = mod_path { + if mod_path.starts_with(BARREL_OPTIMIZATION_PREFIX) { + todo!() + // format_barrel_optimized_resource(&module.resource, &mod_path) + } else { + format!("{}{}", mod_path, mod_query) + } + } else { + resource_resolved_data.resource.to_string() + }; + + if !mod_resource.is_empty() { + if module.get_layer().map(|layer| layer.as_str()) + == Some(WEBPACK_LAYERS.react_server_components) + { + let key = Path::new(&mod_resource) + .relative(&compilation.options.context) + .to_string_lossy() + .to_string() + .replace("/next/dist/esm/", "/next/dist/"); + + let module_info = ModuleInfo { + module_id: module_id.to_string(), + is_async: ModuleGraph::is_async(&compilation, &module.identifier()), + }; + + if self.is_edge_server { + plugin_state.edge_rsc_modules.insert(key, module_info); + } else { + plugin_state.rsc_modules.insert(key, module_info); + } + } + } + + if module.get_layer().map(|layer| layer.as_str()) + != Some(WEBPACK_LAYERS.server_side_rendering) + { + return; + } + + // Check mod resource to exclude the empty resource module like virtual module created by next-flight-client-entry-loader + if !mod_resource.is_empty() { + // Note that this isn't that reliable as webpack is still possible to assign + // additional queries to make sure there's no conflict even using the `named` + // module ID strategy. + let mut ssr_named_module_id = Path::new(&mod_resource) + .relative(&compilation.options.context) + .to_string_lossy() + .to_string(); + + if !ssr_named_module_id.starts_with('.') { + // TODO use getModuleId instead + ssr_named_module_id = format!("./{}", normalize_path_sep(&ssr_named_module_id)); + } + + let module_info = ModuleInfo { + module_id: module_id.to_string(), + is_async: ModuleGraph::is_async(&compilation, &module.identifier()), + }; + + if self.is_edge_server { + plugin_state.edge_ssr_modules.insert( + ssr_named_module_id.replace("/next/dist/esm/", "/next/dist/"), + module_info, + ); + } else { + plugin_state + .ssr_modules + .insert(ssr_named_module_id, module_info); + } + } + }; + + for chunk_group in compilation.chunk_group_by_ukey.values() { + for chunk_ukey in &chunk_group.chunks { + let chunk_modules = compilation + .chunk_graph + .get_chunk_modules_identifier(chunk_ukey); + for module_identifier in chunk_modules { + if let Some(module_id) = + ChunkGraph::get_module_id(&compilation.module_ids_artifact, *module_identifier) + { + record_module(module_id, module_identifier); + + if let Some(module) = module_graph.module_by_identifier(module_identifier) + && let Some(module) = module.as_concatenated_module() + { + for m in module.get_modules() { + record_module(module_id, &m.id); + } + } + } + } + } + } + + mem::take(plugin_state.deref_mut()) + }; + + let state_cb = self.state_cb.as_ref(); + state_cb(state).await?; + logger.time_end(start); + + Ok(()) +} + +#[plugin_hook(CompilationProcessAssets for FlightClientEntryPlugin, stage = Compilation::PROCESS_ASSETS_STAGE_OPTIMIZE_HASH)] +async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { + let logger = compilation.get_logger("rspack.FlightClientEntryPlugin"); + + let start = logger.time("process assets"); + self.create_action_assets(compilation); + logger.time_end(start); + + Ok(()) +} + +#[async_trait] +impl Plugin for FlightClientEntryPlugin { + fn name(&self) -> &'static str { + "rspack.FlightClientEntryPlugin" + } + + fn apply(&self, ctx: PluginContext<&mut ApplyContext>, _options: &CompilerOptions) -> Result<()> { + ctx + .context + .compiler_hooks + .finish_make + .tap(finish_make::new(self)); + + ctx + .context + .compiler_hooks + .after_emit + .tap(after_emit::new(self)); + + ctx + .context + .compilation_hooks + .process_assets + .tap(process_assets::new(self)); + + Ok(()) + } +} + +pub fn is_app_route_route(route: &str) -> bool { + route.ends_with("/route") +} + +struct InjectedClientEntry { + should_invalidate: bool, + add_ssr_entry: (BoxDependency, EntryOptions), + add_rsc_entry: (BoxDependency, EntryOptions), + ssr_dep: DependencyId, +} + +type InjectedActionEntry = (BoxDependency, EntryOptions); diff --git a/crates/rspack_plugin_next_flight_client_entry/src/loader_util.rs b/crates/rspack_plugin_next_flight_client_entry/src/loader_util.rs new file mode 100644 index 000000000000..71e2a3a47328 --- /dev/null +++ b/crates/rspack_plugin_next_flight_client_entry/src/loader_util.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; + +use lazy_regex::Lazy; +use regex::Regex; +use rspack_core::Module; + +use crate::get_module_build_info::{get_module_rsc_information, RSC_MODULE_TYPES}; + +// Gives { id: name } record of actions from the build info. +pub fn get_actions_from_build_info(module: &dyn Module) -> Option> { + let rsc = get_module_rsc_information(module)?; + rsc.action_ids +} + +pub static REGEX_CSS: Lazy = Lazy::new(|| Regex::new(r"\.(css|scss|sass)(\?.*)?$").unwrap()); + +// This function checks if a module is able to emit CSS resources. You should +// never only rely on a single regex to do that. +pub fn is_css_mod(module: &dyn Module) -> bool { + if module.module_type().as_str() == "css/mini-extract" { + return true; + } + let Some(module) = module.as_normal_module() else { + return false; + }; + REGEX_CSS.is_match(&module.resource_resolved_data().resource) + || module.loaders().iter().any(|loader| { + let loader_ident = loader.identifier(); + loader_ident.contains("next-style-loader/index.js") + || loader_ident.contains("rspack.CssExtractRspackPlugin.loader") + || loader_ident.contains("mini-css-extract-plugin/loader.js") + || loader_ident.contains("@vanilla-extract/webpack-plugin/loader/") + }) +} + +pub static IMAGE_REGEX: Lazy = Lazy::new(|| { + let image_extensions = vec!["jpg", "jpeg", "png", "webp", "avif", "ico", "svg"]; + Regex::new(&format!(r"\.({})$", image_extensions.join("|"))).unwrap() +}); + +pub fn is_client_component_entry_module(module: &dyn Module) -> bool { + let rsc = get_module_rsc_information(module); + let has_client_directive = matches!(rsc, Some(rsc) if rsc.is_client_ref); + let is_action_layer_entry = is_action_client_layer_module(module); + let is_image = if let Some(module) = module.as_normal_module() { + IMAGE_REGEX.is_match(&module.resource_resolved_data().resource) + } else { + false + }; + has_client_directive || is_action_layer_entry || is_image +} + +// Determine if the whole module is client action, 'use server' in nested closure in the client module +fn is_action_client_layer_module(module: &dyn Module) -> bool { + let rsc = get_module_rsc_information(module); + matches!(&rsc, Some(rsc) if rsc.actions.is_some()) + && matches!(&rsc, Some(rsc) if rsc.r#type == RSC_MODULE_TYPES.client) +} diff --git a/packages/rspack/etc/core.api.md b/packages/rspack/etc/core.api.md index fd94a8ce5dda..acf25901e826 100644 --- a/packages/rspack/etc/core.api.md +++ b/packages/rspack/etc/core.api.md @@ -68,6 +68,7 @@ import * as liteTapable from '@rspack/lite-tapable'; import { Logger as Logger_2 } from './logging/Logger'; import { RawCopyPattern } from '@rspack/binding'; import { RawCssExtractPluginOption } from '@rspack/binding'; +import { RawFlightClientEntryPluginOptions } from '@rspack/binding'; import type { RawFuncUseCtx } from '@rspack/binding'; import { RawIgnorePluginOptions } from '@rspack/binding'; import { RawOptions } from '@rspack/binding'; @@ -2233,6 +2234,20 @@ export type FilterItemTypes = RegExp | string | ((value: string) => boolean); // @public export type FilterTypes = FilterItemTypes | FilterItemTypes[]; +// @public (undocumented) +export const FlightClientEntryPlugin: { + new (options: RawFlightClientEntryPluginOptions): { + name: BuiltinPluginName; + _args: [options: RawFlightClientEntryPluginOptions]; + affectedHooks: "done" | "environment" | "make" | "compile" | "emit" | "afterEmit" | "invalid" | "thisCompilation" | "afterDone" | "compilation" | "normalModuleFactory" | "contextModuleFactory" | "initialize" | "shouldEmit" | "infrastructureLog" | "beforeRun" | "run" | "assetEmitted" | "failed" | "shutdown" | "watchRun" | "watchClose" | "afterEnvironment" | "afterPlugins" | "afterResolvers" | "beforeCompile" | "afterCompile" | "finishMake" | "entryOption" | "additionalPass" | undefined; + raw(compiler: Compiler_2): BuiltinPlugin; + apply(compiler: Compiler_2): void; + }; +}; + +// @public (undocumented) +export type FlightClientEntryPluginOptions = RawFlightClientEntryPluginOptions; + // @public export type GeneratorOptionsByModuleType = GeneratorOptionsByModuleTypeKnown | GeneratorOptionsByModuleTypeUnknown; @@ -5341,6 +5356,7 @@ declare namespace rspackExports { EvalDevToolModulePluginOptions, CssExtractRspackLoaderOptions, CssExtractRspackPluginOptions, + FlightClientEntryPluginOptions, HtmlRspackPlugin, SwcJsMinimizerRspackPlugin, LightningCssMinimizerRspackPlugin, @@ -5350,6 +5366,7 @@ declare namespace rspackExports { EvalDevToolModulePlugin, CssExtractRspackPlugin, ContextReplacementPlugin, + FlightClientEntryPlugin, SwcLoaderEnvConfig, SwcLoaderEsParserConfig, SwcLoaderJscConfig, diff --git a/packages/rspack/scripts/check-documentation-coverage.ts b/packages/rspack/scripts/check-documentation-coverage.ts index 7cfb8707f00c..1fd681f0504e 100644 --- a/packages/rspack/scripts/check-documentation-coverage.ts +++ b/packages/rspack/scripts/check-documentation-coverage.ts @@ -111,7 +111,8 @@ function checkPluginsDocumentationCoverage() { const excludedPlugins = [ "OriginEntryPlugin", "RuntimePlugin", // This plugin only provides hooks, should not be used separately - "RsdoctorPlugin" // This plugin is not stable yet + "RsdoctorPlugin", // This plugin is not stable yet + "FlightClientEntryPlugin" ]; const undocumentedPlugins = Array.from(implementedPlugins).filter( diff --git a/packages/rspack/src/Module.ts b/packages/rspack/src/Module.ts index cfba8323cb5e..b34e70df4785 100644 --- a/packages/rspack/src/Module.ts +++ b/packages/rspack/src/Module.ts @@ -234,10 +234,17 @@ export class Module { constructor(module: JsModule) { this.#inner = module; - this.buildInfo = {}; + + this.buildInfo = module.buildInfo; this.buildMeta = {}; Object.defineProperties(this, { + constructorName: { + enumerable: true, + get: (): string => { + return module.constructorName; + } + }, type: { enumerable: true, get(): string | null { diff --git a/packages/rspack/src/builtin-plugin/FlightClientEntryPlugin.ts b/packages/rspack/src/builtin-plugin/FlightClientEntryPlugin.ts new file mode 100644 index 000000000000..ed2074cf2848 --- /dev/null +++ b/packages/rspack/src/builtin-plugin/FlightClientEntryPlugin.ts @@ -0,0 +1,13 @@ +import { + BuiltinPluginName, + type RawFlightClientEntryPluginOptions +} from "@rspack/binding"; + +import { create } from "./base"; + +export type FlightClientEntryPluginOptions = RawFlightClientEntryPluginOptions; + +export const FlightClientEntryPlugin = create( + BuiltinPluginName.FlightClientEntryPlugin, + (options: FlightClientEntryPluginOptions) => options +); diff --git a/packages/rspack/src/builtin-plugin/index.ts b/packages/rspack/src/builtin-plugin/index.ts index df8f5fb0de92..e044b5e659bf 100644 --- a/packages/rspack/src/builtin-plugin/index.ts +++ b/packages/rspack/src/builtin-plugin/index.ts @@ -74,3 +74,4 @@ export * from "./DllReferenceAgencyPlugin"; export * from "./RsdoctorPlugin"; export * from "./SubresourceIntegrityPlugin"; export * from "./ModuleInfoHeaderPlugin"; +export * from "./FlightClientEntryPlugin"; diff --git a/packages/rspack/src/exports.ts b/packages/rspack/src/exports.ts index c1082fa6a11e..00e5aa497f88 100644 --- a/packages/rspack/src/exports.ts +++ b/packages/rspack/src/exports.ts @@ -269,6 +269,7 @@ export type { CssExtractRspackLoaderOptions, CssExtractRspackPluginOptions } from "./builtin-plugin"; +export type { FlightClientEntryPluginOptions } from "./builtin-plugin"; export { HtmlRspackPlugin } from "./builtin-plugin"; export { SwcJsMinimizerRspackPlugin } from "./builtin-plugin"; export { LightningCssMinimizerRspackPlugin } from "./builtin-plugin"; @@ -278,6 +279,7 @@ export { EvalSourceMapDevToolPlugin } from "./builtin-plugin"; export { EvalDevToolModulePlugin } from "./builtin-plugin"; export { CssExtractRspackPlugin } from "./builtin-plugin"; export { ContextReplacementPlugin } from "./builtin-plugin"; +export { FlightClientEntryPlugin } from "./builtin-plugin"; ///// Rspack Postfixed Internal Loaders ///// export type {