diff --git a/lib/volt/js/vendor.ex b/lib/volt/js/vendor.ex index e9349a7..860e285 100644 --- a/lib/volt/js/vendor.ex +++ b/lib/volt/js/vendor.ex @@ -106,6 +106,10 @@ defmodule Volt.JS.Vendor do node_modules = Keyword.get(opts, :node_modules) module_dirs = module_dirs(node_modules, resolve_dirs) + browser_hash(module_dirs, plugins, module_types) + end + + defp browser_hash(module_dirs, plugins, module_types) do :crypto.hash( :sha256, :erlang.term_to_binary(browser_signature(module_dirs, plugins, module_types)) @@ -202,11 +206,22 @@ defmodule Volt.JS.Vendor do preserve_entry_signatures: :strict ] ++ if(module_types != %{}, do: [module_types: module_types], else: []) + externals = prebundle_externals_for(specifier, plugins) + bundle_opts = put_external_imports(bundle_opts, externals) + case OXC.bundle(entry_path, bundle_opts) do {:ok, result} -> write_cache_files!( output_path, - extract_code(result), + result + |> extract_code() + |> rewrite_external_imports( + entry_path, + externals, + plugins, + module_dirs, + module_types + ), specifier, module_dirs, plugins, @@ -269,6 +284,46 @@ defmodule Volt.JS.Vendor do # ── Helpers ─────────────────────────────────────────────────────── + defp prebundle_externals_for(specifier, plugins) do + plugins + |> Volt.PluginRunner.prebundle_externals() + |> Enum.reject(fn external -> + Volt.PluginRunner.prebundle_alias(plugins, external) == specifier + end) + end + + defp put_external_imports(bundle_opts, externals) do + Keyword.update(bundle_opts, :external, externals, fn existing -> + Enum.uniq(List.wrap(existing) ++ externals) + end) + end + + defp rewrite_external_imports(code, entry_path, externals, plugins, module_dirs, module_types) do + rewrites = + Map.new(externals, fn external -> + canonical = Volt.PluginRunner.prebundle_alias(plugins, external) + {external, vendor_url_for_signature(canonical, module_dirs, plugins, module_types)} + end) + + case Volt.JS.Transforms.Imports.rewrite_map(code, entry_path, rewrites) do + {:ok, rewritten} -> + rewritten + + {:error, errors} -> + Logger.debug( + "[Volt] Vendor external import AST rewrite skipped for #{entry_path}: #{inspect(errors)}" + ) + + code + end + end + + defp vendor_url_for_signature(specifier, module_dirs, plugins, module_types) do + specifier + |> vendor_url() + |> Volt.URL.append_query("v=#{browser_hash(module_dirs, plugins, module_types)}") + end + defp extract_code(result) when is_binary(result), do: result defp extract_code(%{code: code}), do: code @@ -443,7 +498,8 @@ defmodule Volt.JS.Vendor do lockfiles: lockfile_signature(module_dirs), module_dirs: module_dirs, module_types: module_types, - plugins: Enum.map(plugins, &base_plugin_signature/1) + plugins: Enum.map(plugins, &base_plugin_signature/1), + prebundle_externals: Volt.PluginRunner.prebundle_externals(plugins) } end diff --git a/lib/volt/plugin.ex b/lib/volt/plugin.ex index 2b27338..7bf3644 100644 --- a/lib/volt/plugin.ex +++ b/lib/volt/plugin.ex @@ -91,6 +91,20 @@ defmodule Volt.Plugin do imports: [prebundle_import()], exports: [prebundle_export()]} | nil + @doc """ + Return bare specifiers that should stay external while dev vendor packages are pre-bundled. + + Use this advanced framework-integration hook when third-party packages must + share a canonical dev vendor module instead of inlining their own copy of a + peer dependency. Volt rewrites preserved external imports through + `prebundle_alias/1`, so related entrypoints can still resolve to one vendor + URL. + + The canonical prebundle entry itself is built without its aliased externals so + it can aggregate the shared dependency graph without importing itself. + """ + @callback prebundle_externals() :: [String.t()] + @doc "Transform a final output chunk before writing." @callback render_chunk(code :: String.t(), chunk_info :: map()) :: {:ok, String.t()} | nil @@ -105,5 +119,6 @@ defmodule Volt.Plugin do define: 1, prebundle_alias: 1, prebundle_entry: 1, + prebundle_externals: 0, render_chunk: 2 end diff --git a/lib/volt/plugin/react.ex b/lib/volt/plugin/react.ex index 2ddd5e8..ae7c6f8 100644 --- a/lib/volt/plugin/react.ex +++ b/lib/volt/plugin/react.ex @@ -54,6 +54,16 @@ defmodule Volt.Plugin.React do def prebundle_alias("react/jsx-dev-runtime"), do: "react" def prebundle_alias(_specifier), do: nil + @impl true + def prebundle_externals do + [ + "react", + "react-dom/client", + "react/jsx-runtime", + "react/jsx-dev-runtime" + ] + end + @impl true def prebundle_entry("react") do {:proxy, "react.js", diff --git a/lib/volt/plugin_runner.ex b/lib/volt/plugin_runner.ex index d2778e1..87169c8 100644 --- a/lib/volt/plugin_runner.ex +++ b/lib/volt/plugin_runner.ex @@ -109,6 +109,20 @@ defmodule Volt.PluginRunner do end) end + @doc "Collect plugin-provided dev prebundle externals." + @spec prebundle_externals([module() | {module(), keyword()}]) :: [String.t()] + def prebundle_externals(plugins) do + plugins + |> plugins() + |> Enum.flat_map(fn plugin -> + plugin + |> call_optional(:prebundle_externals, [], []) + |> List.wrap() + end) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + @doc "Run render_chunk hooks in sequence." @spec render_chunk([module() | {module(), keyword()}], String.t(), map()) :: String.t() def render_chunk(plugins, code, chunk_info) do diff --git a/test/volt/js/vendor_test.exs b/test/volt/js/vendor_test.exs index db4c2f9..824e59d 100644 --- a/test/volt/js/vendor_test.exs +++ b/test/volt/js/vendor_test.exs @@ -145,6 +145,40 @@ defmodule Volt.JS.VendorTest do assert code =~ "greet" refute code =~ "module.exports" end + + test "keeps third-party React imports external and rewrites them to canonical vendor URL" do + File.mkdir_p!(Path.join(@node_modules, "react-using-lib")) + + File.write!( + Path.join(@node_modules, "react-using-lib/package.json"), + :json.encode(%{"name" => "react-using-lib", "main" => "index.js", "type" => "module"}) + ) + + File.write!( + Path.join(@node_modules, "react-using-lib/index.js"), + "import { useState } from 'react'; export function useThing() { return useState(null); }" + ) + + File.write!( + Path.join(@fixture_dir, "src/app.ts"), + "import { useThing } from 'react-using-lib'; console.log(useThing)" + ) + + {:ok, vendor_map} = + Volt.JS.Vendor.prebundle( + root: Path.join(@fixture_dir, "src"), + node_modules: @node_modules, + plugins: [Volt.Plugin.React] + ) + + assert Map.has_key?(vendor_map, "react-using-lib") + + {:ok, code} = Volt.JS.Vendor.read("react-using-lib") + + assert code =~ ~r/from "\/@vendor\/react\.js\?v=[a-f0-9]+"/ + refute code =~ ~s(from "react") + refute code =~ ~s(from 'react') + end end describe "CJS package bundling" do