Skip to content
Merged
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
1 change: 1 addition & 0 deletions .reach.exs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ model = [
"Rekindle.Cargo.Error",
"Rekindle.Config.Error",
"Rekindle.Config.Target",
"Rekindle.Config.Worker",
"Rekindle.Desktop.Error",
"Rekindle.Diagnostic",
"Rekindle.Toolchain.Check",
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Build and publish configured auxiliary WebAssembly worker binaries in the
same immutable Web generation as the main application.

## 0.1.12 - 2026-08-26

- Resolve Web and desktop binaries from the selected Cargo workspace package
Expand Down
31 changes: 31 additions & 0 deletions guides/features/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ Each target accepts:
entry.
- `:profiles` — Cargo profile names for Rekindle's `:dev` and `:release`
modes. Defaults to `[dev: "dev", release: "release"]`.
- `:workers` — Web-only auxiliary WebAssembly workers keyed by a safe URL
segment. Each worker accepts `:package`, `:binary`, and `:features`; its
features default to `["web"]`.

For example:

Expand All @@ -62,3 +65,31 @@ config :my_app, Rekindle,

`mix rekindle.doctor` validates the effective configuration and Cargo metadata
without changing the project.

## Auxiliary Web workers

Configure a dedicated Rust binary when browser APIs require a Web Worker:

```elixir
config :my_app, Rekindle,
plugin: Rekindle.Plugin.GPUI,
targets: [
web: [
package: "editor_client",
workers: [
storage: [
package: "storage_worker",
binary: "storage-worker",
features: ["web"]
]
]
]
]
```

Rekindle builds each worker for `wasm32-unknown-unknown`, runs wasm-bindgen,
and publishes it in the same immutable generation as the main module. The
generated entry module exports a frozen `workers` object whose values are
module URL strings. It also exposes the same object at
`globalThis[Symbol.for("rekindle.workers")]` for Rust applications that start
through the default module export.
4 changes: 4 additions & 0 deletions guides/features/web-target.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ builds are published below `.rekindle/dev`. The browser runtime detects a new
generation only after it is complete. A failed build leaves the previous
generation active.

Auxiliary Web workers configured on the target are built and published in the
same generation. Worker build or binding failure prevents the generation from
becoming active.

Run one build without the development server:

```console
Expand Down
116 changes: 116 additions & 0 deletions lib/rekindle/cargo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,39 @@ defmodule Rekindle.Cargo do
end
end

@spec build_worker(
Rekindle.Config.t(),
Target.t(),
Rekindle.Config.Worker.t(),
:dev | :release,
keyword()
) :: {:ok, t()} | {:error, Error.t()}
def build_worker(
project,
%Target{} = target,
%Rekindle.Config.Worker{} = worker,
profile,
options \\ []
) do
with {:ok, metadata} <- Metadata.load(project, options),
{:ok, package, binary} <- resolve_worker(metadata, project, target, worker),
{:ok, process, rust_target} <-
execute_worker(project, target, profile, package, binary, worker.features, options),
{:ok, artifact, diagnostics, output} <-
Messages.decode(process, package.id, binary, :web) do
{:ok,
%__MODULE__{
artifact: artifact,
package: package.name,
binary: binary,
target: rust_target,
target_directory: metadata.target_directory,
diagnostics: diagnostics,
output: output
}}
end
end

@spec resolve(Metadata.t(), Rekindle.Config.t(), Target.t()) ::
{:ok, Metadata.package(), String.t()} | {:error, Error.t()}
def resolve(metadata, _project, target) do
Expand Down Expand Up @@ -107,6 +140,89 @@ defmodule Rekindle.Cargo do
end
end

defp resolve_worker(metadata, _project, _target, worker) do
workspace_packages =
Enum.filter(metadata.packages, &MapSet.member?(metadata.workspace_members, &1.id))

with {:ok, package} <- package(workspace_packages, worker.package),
{:ok, binary} <- worker_binary(package, worker) do
{:ok, package, binary}
end
end

defp worker_binary(package, worker) do
name = worker.binary || worker.name

candidates =
Enum.filter(package.targets, fn candidate ->
"bin" in candidate["kind"] and candidate["name"] == name
end)

case candidates do
[candidate] ->
{:ok, candidate["name"]}

[] ->
error(
:binary_not_found,
"Cargo package #{inspect(package.name)} has no binary named #{inspect(name)}"
)

_ ->
error(
:ambiguous_binary,
"Cargo metadata returned multiple binaries named #{inspect(name)}"
)
end
end

defp execute_worker(project, target, profile, package, binary, features, options) do
options = Keyword.put(options, :cd, project.client_root)
executable = Rekindle.Toolchain.cargo_path(options)

base_arguments = [
"build",
"--manifest-path",
Path.join(project.client_root, "Cargo.toml"),
"--message-format=json-render-diagnostics",
"--package",
package.name,
"--bin",
binary,
"--profile",
Map.fetch!(target.profiles, profile)
]

process_options = [
cd: project.client_root,
timeout: Keyword.get(options, :timeout, :infinity),
output_limit: Keyword.get(options, :output_limit, 8_000_000),
env: Rekindle.Toolchain.cargo_environment(options)
]

with {:ok, arguments, rust_target} <-
target_arguments(base_arguments, :web, options) do
arguments = feature_arguments(arguments, features)

case Process.run(executable, arguments, process_options) do
{:ok, result} ->
{:ok, result, rust_target}

{:error, :timeout} ->
error(:timeout, "cargo build timed out")

{:error, :output_limit} ->
error(:output_limit, "cargo build exceeded the output limit")

{:error, {:start, reason}} ->
error(:start_failed, "cargo build could not start: #{Exception.message(reason)}")

{:error, {:invalid_option, option}} ->
error(:invalid_option, "invalid cargo process option: #{option}")
end
end
end

defp execute(project, target, profile, package, binary, options) do
options = Keyword.put(options, :cd, project.client_root)
executable = Rekindle.Toolchain.cargo_path(options)
Expand Down
91 changes: 88 additions & 3 deletions lib/rekindle/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ defmodule Rekindle.Config do
@moduledoc false

alias Rekindle.Config.{Error, Target}
alias Rekindle.Config.Worker
alias Rekindle.Plugin

@target_names [:web, :desktop]
@config_keys [:plugin, :targets, :public_dir]
@target_keys [:package, :binary, :features, :profiles]
@target_keys [:package, :binary, :features, :profiles, :workers]
@worker_keys [:package, :binary, :features]
@profile_names [:dev, :release]
@application_config_key :"Elixir.Rekindle"

Expand Down Expand Up @@ -139,19 +141,102 @@ defmodule Rekindle.Config do
{:ok, package} <- optional_string(config, :package, name),
{:ok, binary} <- optional_string(config, :binary, name),
{:ok, features} <- features(config, name),
{:ok, profiles} <- profiles(config, name) do
{:ok, profiles} <- profiles(config, name),
{:ok, workers} <- workers(config, name) do
{:ok,
%Target{
name: name,
entry: Path.join(["client", "src", "bin", "#{name}.rs"]),
package: package,
binary: binary,
features: features,
profiles: profiles
profiles: profiles,
workers: workers
}}
end
end

defp workers(config, :desktop) do
if Keyword.has_key?(config, :workers) do
error(:desktop_workers, "auxiliary workers are only supported for the :web target")
else
{:ok, []}
end
end

defp workers(config, :web) do
case Keyword.get(config, :workers, []) do
[] ->
{:ok, []}

value ->
with :ok <- keyword(value, "web :workers"),
:ok <- unique_keys(value, "web :workers") do
Enum.reduce_while(value, {:ok, []}, fn {name, worker_config}, {:ok, workers} ->
case worker(name, worker_config, :web) do
{:ok, worker} -> {:cont, {:ok, [worker | workers]}}
{:error, error} -> {:halt, {:error, error}}
end
end)
|> case do
{:ok, workers} -> {:ok, Enum.reverse(workers)}
{:error, error} -> {:error, error}
end
end
end
end

defp worker(name, config, target) do
with {:ok, name} <- worker_name(name, target),
:ok <- keyword(config, "#{target} worker #{inspect(name)}"),
:ok <- unique_keys(config, "#{target} worker #{inspect(name)}"),
:ok <- known_keys(config, @worker_keys, "#{target} worker #{inspect(name)}"),
{:ok, package} <- optional_string(config, :package, "#{target} worker #{inspect(name)}"),
{:ok, binary} <- optional_string(config, :binary, "#{target} worker #{inspect(name)}"),
{:ok, features} <- worker_features(config, target, name) do
{:ok,
%Worker{
name: name,
package: package,
binary: binary,
features: features
}}
end
end

defp worker_name(name, target) when is_atom(name) do
name = Atom.to_string(name)

if Regex.match?(~r/\A[a-z][a-z0-9-]*\z/, name) do
{:ok, name}
else
error(
:invalid_worker_name,
"expected #{target} worker names to match [a-z][a-z0-9-]* and be non-empty, got: #{inspect(name)}"
)
end
end

defp worker_name(name, target) do
error(
:invalid_worker_name,
"expected #{target} worker names to be atoms, got: #{inspect(name)}"
)
end

defp worker_features(config, target, name) do
value = Keyword.get(config, :features, [Atom.to_string(target)])

if is_list(value) and Enum.all?(value, &(is_binary(&1) and &1 != "")) do
{:ok, value}
else
error(
:invalid_features,
"expected #{target} worker #{inspect(name)} :features to be a list of non-empty strings"
)
end
end

defp features(config, target) do
value =
case Keyword.fetch(config, :features) do
Expand Down
5 changes: 3 additions & 2 deletions lib/rekindle/config/target.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ defmodule Rekindle.Config.Target do
@moduledoc false

@enforce_keys [:name, :entry, :features, :profiles]
defstruct [:name, :entry, :package, :binary, :features, :profiles]
defstruct [:name, :entry, :package, :binary, :features, :profiles, workers: []]

@type t :: %__MODULE__{
name: :web | :desktop,
entry: Path.t(),
package: String.t() | nil,
binary: String.t() | nil,
features: [String.t()],
profiles: %{dev: String.t(), release: String.t()}
profiles: %{dev: String.t(), release: String.t()},
workers: [Rekindle.Config.Worker.t()]
}
end
13 changes: 13 additions & 0 deletions lib/rekindle/config/worker.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Rekindle.Config.Worker do
@moduledoc false

@enforce_keys [:name, :features]
defstruct [:name, :package, :binary, :features]

@type t :: %__MODULE__{
name: String.t(),
package: String.t() | nil,
binary: String.t() | nil,
features: [String.t()]
}
end
Loading
Loading