Skip to content

Commit 8c2ea23

Browse files
committed
Release 0.4.3
1 parent 7f6160d commit 8c2ea23

17 files changed

Lines changed: 381 additions & 76 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.4.3
4+
5+
- Fix `mix npm.exec` to resolve binaries via `NPM.Exec.which/2` and run them through Node instead of shell string spawning
6+
- Preserve `optional_dependencies` in `npm.lock`
7+
- Skip linking missing optional packages instead of crashing during install
8+
- Add focused test coverage for exec environment, cached Node runner execution, optional runtime linking, and resolver optional dependency handling
9+
310
## 0.4.2
411

512
- Speculative parallel prefetch of transitive dependencies before solving — fetches the full dep tree breadth-first with 16 concurrent requests

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ mix npm.config
107107
9. Warns about unmet peer dependencies and deprecated packages
108108
10. Retries failed downloads with exponential backoff
109109

110+
## Why `npm.lock` instead of `package-lock.json`?
111+
112+
`npm_ex` is not npm, so it keeps its own lockfile. `package.json` is the shared manifest; `npm.lock` is the reproducibility file for the `npm_ex` installer.
113+
110114
## Configuration
111115

112116
Set environment variables to customize behavior:

lib/mix/tasks/npm.exec.ex

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ defmodule Mix.Tasks.Npm.Exec do
1616
def run([command | args]) do
1717
Application.ensure_all_started(:req)
1818

19-
bin_path = Path.join("node_modules/.bin", command)
19+
case NPM.Exec.which(command, "node_modules") do
20+
{:ok, bin_path} ->
21+
execute(bin_path, args)
2022

21-
if File.exists?(bin_path) do
22-
execute(bin_path, args)
23-
else
24-
Mix.shell().error("Binary #{command} not found in node_modules/.bin/")
25-
Mix.shell().info("Run `mix npm.install` to install packages.")
23+
{:error, :not_found} ->
24+
Mix.shell().error("Binary #{command} not found in node_modules/.bin/")
25+
Mix.shell().info("Run `mix npm.install` to install packages.")
2626
end
2727
end
2828

@@ -44,30 +44,20 @@ defmodule Mix.Tasks.Npm.Exec do
4444
end
4545

4646
defp execute(bin_path, args) do
47-
full_command = Enum.join([bin_path | args], " ")
48-
49-
port =
50-
Port.open({:spawn, full_command}, [
51-
:binary,
52-
:exit_status,
53-
:stderr_to_stdout
54-
])
55-
56-
stream_port(port)
57-
end
58-
59-
defp stream_port(port) do
60-
receive do
61-
{^port, {:data, data}} ->
62-
IO.write(data)
63-
stream_port(port)
47+
{output, status} =
48+
NPM.NodeRunner.run(Path.expand(bin_path), args,
49+
node_modules_dir: "node_modules",
50+
cd: File.cwd!()
51+
)
6452

65-
{^port, {:exit_status, 0}} ->
66-
:ok
53+
if output != "", do: IO.write(output)
6754

68-
{^port, {:exit_status, code}} ->
55+
case status do
56+
0 -> :ok
57+
code ->
6958
Mix.shell().error("Exited with code #{code}")
7059
{:error, {:exit, code}}
7160
end
7261
end
62+
7363
end

lib/npm.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,8 @@ defmodule NPM do
277277
version: version_str,
278278
integrity: info.dist.integrity,
279279
tarball: info.dist.tarball,
280-
dependencies: info.dependencies
280+
dependencies: info.dependencies,
281+
optional_dependencies: info.optional_dependencies
281282
}}
282283
end
283284

lib/npm/cache.ex

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,23 @@ defmodule NPM.Cache do
3333
Downloads and extracts the tarball if not already cached.
3434
Returns `{:ok, cache_path}` or `{:error, reason}`.
3535
"""
36-
@spec ensure(String.t(), String.t(), String.t(), String.t()) ::
36+
@spec ensure(String.t(), String.t(), String.t(), String.t(), keyword()) ::
3737
{:ok, String.t()} | {:error, term()}
38-
def ensure(name, version, tarball_url, integrity) do
38+
def ensure(name, version, tarball_url, integrity, opts \\ []) do
3939
dest = package_dir(name, version)
4040

4141
if cached?(name, version) do
4242
{:ok, dest}
4343
else
4444
case NPM.Tarball.fetch_and_extract(tarball_url, integrity, dest) do
4545
{:ok, _count} -> {:ok, dest}
46-
error -> error
46+
{:error, reason} ->
47+
if Keyword.get(opts, :optional?, false) do
48+
File.rm_rf(dest)
49+
{:ok, :missing_optional}
50+
else
51+
{:error, reason}
52+
end
4753
end
4854
end
4955
end

lib/npm/exec.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,16 @@ defmodule NPM.Exec do
7272
@spec env(String.t()) :: [{String.t(), String.t()}]
7373
def env(node_modules_dir \\ "node_modules") do
7474
bin_dir = Path.expand(Path.join(node_modules_dir, ".bin"))
75+
node_modules_dir = Path.expand(node_modules_dir)
7576
current_path = System.get_env("PATH") || ""
76-
[{"PATH", "#{bin_dir}:#{current_path}"}]
77+
node_path = [node_modules_dir, System.get_env("NODE_PATH")]
78+
|> Enum.reject(&is_nil/1)
79+
|> Enum.join(":")
80+
81+
[
82+
{"PATH", "#{bin_dir}:#{current_path}"},
83+
{"NODE_PATH", node_path}
84+
]
7785
end
7886

7987
defp find_in_packages(command, node_modules_dir) do

lib/npm/linker.ex

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ defmodule NPM.Linker do
2121
"""
2222
@spec link(resolved(), String.t(), strategy()) :: :ok | {:error, term()}
2323
def link(lockfile, node_modules_dir \\ "node_modules", strategy \\ default_strategy()) do
24-
with :ok <- populate_cache(lockfile) do
25-
create_node_modules(lockfile, node_modules_dir, strategy)
24+
with {:ok, skipped} <- populate_cache(lockfile) do
25+
create_node_modules(lockfile, node_modules_dir, strategy, skipped)
2626
end
2727
end
2828

@@ -49,21 +49,32 @@ defmodule NPM.Linker do
4949
lockfile
5050
|> Task.async_stream(
5151
fn {name, entry} ->
52-
NPM.Cache.ensure(name, entry.version, entry.tarball, entry.integrity)
52+
optional? = optional_dependency?(name, lockfile)
53+
54+
case NPM.Cache.ensure(name, entry.version, entry.tarball, entry.integrity, optional?: optional?) do
55+
{:ok, :missing_optional} -> {:skip, name}
56+
other -> other
57+
end
5358
end,
5459
max_concurrency: 8,
5560
timeout: 60_000
5661
)
57-
|> Enum.reduce(:ok, fn
58-
{:ok, {:ok, _}}, acc -> acc
59-
{:ok, {:error, reason}}, _ -> {:error, reason}
60-
{:exit, reason}, _ -> {:error, reason}
62+
|> Enum.reduce({:ok, MapSet.new()}, fn
63+
{:ok, {:ok, _}}, {status, skipped} -> {status, skipped}
64+
{:ok, {:skip, name}}, {status, skipped} -> {status, MapSet.put(skipped, name)}
65+
{:ok, {:error, reason}}, {_status, _skipped} -> {{:error, reason}, MapSet.new()}
66+
{:exit, reason}, {_status, _skipped} -> {{:error, reason}, MapSet.new()}
6167
end)
6268
end
6369

64-
defp create_node_modules(lockfile, node_modules_dir, strategy) do
70+
defp create_node_modules(lockfile, node_modules_dir, strategy, skipped) do
6571
File.mkdir_p!(node_modules_dir)
66-
tree = hoist(lockfile)
72+
73+
tree =
74+
lockfile
75+
|> hoist()
76+
|> Enum.reject(fn {name, _version} -> MapSet.member?(skipped, name) end)
77+
6778
expected_names = MapSet.new(tree, &elem(&1, 0))
6879

6980
prune(node_modules_dir, expected_names)
@@ -259,17 +270,24 @@ defmodule NPM.Linker do
259270
end
260271
end
261272

273+
defp optional_dependency?(name, lockfile) do
274+
Enum.any?(lockfile, fn {_pkg, entry} ->
275+
Map.has_key?(Map.get(entry, :optional_dependencies, %{}), name)
276+
end)
277+
end
278+
262279
defp install_single_nested(_pkg, nil, _parent, _nm_dir, _strategy), do: :ok
263280

264281
defp install_single_nested(pkg, version, parent, nm_dir, strategy) do
265282
with {:ok, packument} <- NPM.Registry.get_packument(pkg),
266-
%{} = info <- Map.get(packument.versions, version) do
267-
tarball = info.tarball
268-
integrity = info.integrity
269-
NPM.Cache.ensure(pkg, version, tarball, integrity)
270-
cache_path = NPM.Cache.package_dir(pkg, version)
271-
target = Path.join([nm_dir, parent, "node_modules", pkg])
272-
link_package(cache_path, target, strategy)
283+
%{} = info <- Map.get(packument.versions, version),
284+
{:ok, cache_result} <-
285+
NPM.Cache.ensure(pkg, version, info.dist.tarball, info.dist.integrity) do
286+
if cache_result != :missing_optional do
287+
cache_path = NPM.Cache.package_dir(pkg, version)
288+
target = Path.join([nm_dir, parent, "node_modules", pkg])
289+
link_package(cache_path, target, strategy)
290+
end
273291
end
274292

275293
:ok

lib/npm/lockfile.ex

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ defmodule NPM.Lockfile do
1212
version: String.t(),
1313
integrity: String.t(),
1414
tarball: String.t(),
15-
dependencies: %{String.t() => String.t()}
15+
dependencies: %{String.t() => String.t()},
16+
optional_dependencies: %{String.t() => String.t()}
1617
}
1718

1819
@type t :: %{String.t() => entry()}
@@ -52,7 +53,8 @@ defmodule NPM.Lockfile do
5253
version: Map.get(info, "version", ""),
5354
integrity: Map.get(info, "integrity", ""),
5455
tarball: Map.get(info, "tarball", ""),
55-
dependencies: Map.get(info, "dependencies", %{})
56+
dependencies: Map.get(info, "dependencies", %{}),
57+
optional_dependencies: Map.get(info, "optional_dependencies", %{})
5658
}}
5759
end
5860
end
@@ -106,7 +108,8 @@ defmodule NPM.Lockfile do
106108
"version" => entry.version,
107109
"integrity" => entry.integrity,
108110
"tarball" => entry.tarball,
109-
"dependencies" => entry.dependencies
111+
"dependencies" => entry.dependencies,
112+
"optional_dependencies" => Map.get(entry, :optional_dependencies, %{})
110113
}}
111114
end
112115
end

lib/npm/node_runner.ex

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
defmodule NPM.NodeRunner do
2+
@moduledoc false
3+
4+
@spec run(String.t(), [String.t()], keyword()) :: {String.t(), non_neg_integer()}
5+
def run(entrypoint, args, opts \\ []) do
6+
node_modules_dir = Keyword.get(opts, :node_modules_dir, "node_modules")
7+
env = Keyword.get(opts, :env, []) ++ NPM.Exec.env(node_modules_dir)
8+
9+
loader_path = write_loader(Path.expand(node_modules_dir), Path.expand(entrypoint))
10+
11+
try do
12+
System.cmd("node", [loader_path | args],
13+
env: env,
14+
stderr_to_stdout: true,
15+
cd: Keyword.get(opts, :cd, File.cwd!())
16+
)
17+
after
18+
File.rm(loader_path)
19+
end
20+
end
21+
22+
defp write_loader(node_modules_dir, entrypoint) do
23+
path = Path.join(System.tmp_dir!(), "npm-node-runner-#{System.unique_integer([:positive])}.mjs")
24+
25+
File.write!(path, """
26+
import { createRequire } from 'node:module'
27+
const require = createRequire(import.meta.url)
28+
globalThis.require = require
29+
process.env.NODE_PATH = #{inspect(node_modules_dir)}
30+
require('node:module').Module._initPaths()
31+
await import(#{inspect(entrypoint)})
32+
""")
33+
34+
path
35+
end
36+
end

lib/npm/registry.ex

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,21 @@ defmodule NPM.Registry do
1616

1717
@type version_info :: %{
1818
dependencies: %{String.t() => String.t()},
19-
dist: %{tarball: String.t(), integrity: String.t()}
19+
optional_dependencies: %{String.t() => String.t()},
20+
peer_dependencies: %{String.t() => String.t()},
21+
peer_dependencies_meta: %{String.t() => map()},
22+
bin: %{optional(String.t()) => String.t()},
23+
engines: %{String.t() => String.t()},
24+
os: [String.t()],
25+
cpu: [String.t()],
26+
has_install_script: boolean(),
27+
deprecated: String.t() | nil,
28+
dist: %{
29+
tarball: String.t(),
30+
integrity: String.t(),
31+
file_count: integer() | nil,
32+
unpacked_size: integer() | nil
33+
}
2034
}
2135

2236
@doc "Get the configured registry URL."

0 commit comments

Comments
 (0)