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
101 changes: 99 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,87 @@ 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, []), do: bundle_opts

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, [], _plugins, _module_dirs, _module_types),
do: code

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)

if external_import_references?(code, Map.keys(rewrites)) do
case Volt.JS.Transforms.Imports.rewrite(code, entry_path, fn specifier ->
case Map.fetch(rewrites, specifier) do
{:ok, replacement} -> {:rewrite, replacement}
:error -> :keep
end
end) do
{:ok, rewritten} ->
rewritten

{:error, errors} ->
Logger.debug(
"[Volt] Vendor external import AST rewrite failed for #{entry_path}: #{inspect(errors)}"
)

rewrite_external_import_reference_literals(code, rewrites)
end
else
code
end
end

defp external_import_references?(code, specifiers) do
Enum.any?(specifiers, fn specifier ->
Enum.any?(external_import_reference_literals(specifier), &String.contains?(code, &1))
end)
end

defp rewrite_external_import_reference_literals(code, rewrites) do
Enum.reduce(rewrites, code, fn {specifier, replacement}, acc ->
specifier
|> external_import_reference_literals()
|> Enum.reduce(acc, fn literal, literal_acc ->
String.replace(literal_acc, literal, String.replace(literal, specifier, replacement))
end)
end)
end

defp external_import_reference_literals(specifier) do
[
~s(from "#{specifier}"),
~s(from '#{specifier}'),
~s(import "#{specifier}"),
~s(import '#{specifier}'),
~s(import("#{specifier}"),
~s(import('#{specifier}')
]
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 +539,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
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