Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions lib/volt/js/vendor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions lib/volt/plugin.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -105,5 +119,6 @@ defmodule Volt.Plugin do
define: 1,
prebundle_alias: 1,
prebundle_entry: 1,
prebundle_externals: 0,
render_chunk: 2
end
10 changes: 10 additions & 0 deletions lib/volt/plugin/react.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions lib/volt/plugin_runner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions test/volt/js/vendor_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down