diff --git a/.reach.exs b/.reach.exs index 05c02fd..25ee114 100644 --- a/.reach.exs +++ b/.reach.exs @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe2254..2cf233c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/guides/features/configuration.md b/guides/features/configuration.md index 36764ca..1677065 100644 --- a/guides/features/configuration.md +++ b/guides/features/configuration.md @@ -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: @@ -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. diff --git a/guides/features/web-target.md b/guides/features/web-target.md index 79a658d..4129d57 100644 --- a/guides/features/web-target.md +++ b/guides/features/web-target.md @@ -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 diff --git a/lib/rekindle/cargo.ex b/lib/rekindle/cargo.ex index 1a157e1..651f14c 100644 --- a/lib/rekindle/cargo.ex +++ b/lib/rekindle/cargo.ex @@ -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 @@ -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) diff --git a/lib/rekindle/config.ex b/lib/rekindle/config.ex index 15f73fb..d3fdc90 100644 --- a/lib/rekindle/config.ex +++ b/lib/rekindle/config.ex @@ -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" @@ -139,7 +141,8 @@ 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, @@ -147,11 +150,93 @@ defmodule Rekindle.Config do 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 diff --git a/lib/rekindle/config/target.ex b/lib/rekindle/config/target.ex index 95a89de..470871a 100644 --- a/lib/rekindle/config/target.ex +++ b/lib/rekindle/config/target.ex @@ -2,7 +2,7 @@ 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, @@ -10,6 +10,7 @@ defmodule Rekindle.Config.Target do 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 diff --git a/lib/rekindle/config/worker.ex b/lib/rekindle/config/worker.ex new file mode 100644 index 0000000..f2956ef --- /dev/null +++ b/lib/rekindle/config/worker.ex @@ -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 diff --git a/lib/rekindle/web/builder.ex b/lib/rekindle/web/builder.ex index 4f8f8ca..c6162f8 100644 --- a/lib/rekindle/web/builder.ex +++ b/lib/rekindle/web/builder.ex @@ -23,6 +23,7 @@ defmodule Rekindle.Web.Builder do defp build(project, target, profile, options, temporary) do generation = generation() + main_out = if target.workers == [], do: "app", else: "main" with {:ok, cargo} <- Rekindle.Cargo.build(project, target, profile, cargo_options(options)), @@ -31,7 +32,10 @@ defmodule Rekindle.Web.Builder do Rekindle.Toolchain.wasm_bindgen_version(), toolchain_options(options) ), - :ok <- bindgen(wasm_bindgen, cargo.artifact, temporary, options), + :ok <- bindgen(wasm_bindgen, cargo.artifact, temporary, main_out, options), + {:ok, workers} <- + build_workers(project, target, profile, wasm_bindgen, temporary, options), + :ok <- write_wrapper(temporary, workers), :ok <- copy_public(project.client_root, temporary), :ok <- ensure_entry(temporary), {:ok, generation_root} <- publish(project, profile, temporary, generation) do @@ -45,7 +49,8 @@ defmodule Rekindle.Web.Builder do binary: cargo.binary, rust_target: cargo.target, target_directory: cargo.target_directory, - diagnostics: cargo.diagnostics + diagnostics: cargo.diagnostics, + workers: workers } } @@ -73,8 +78,8 @@ defmodule Rekindle.Web.Builder do end end - defp bindgen(executable, artifact, output, options) do - arguments = [artifact, "--target", "web", "--out-dir", output, "--out-name", "app"] + defp bindgen(executable, artifact, output, out_name, options) do + arguments = [artifact, "--target", "web", "--out-dir", output, "--out-name", out_name] case Process.run(executable, arguments, cd: Path.dirname(artifact), @@ -104,6 +109,77 @@ defmodule Rekindle.Web.Builder do end end + defp build_workers(project, target, profile, wasm_bindgen, temporary, options) do + Enum.reduce_while(target.workers, {:ok, []}, fn worker, {:ok, workers} -> + case build_worker(project, target, profile, worker, wasm_bindgen, temporary, options) do + {:ok, worker_result} -> {:cont, {:ok, [worker_result | workers]}} + {:error, error} -> {:halt, {:error, error}} + end + end) + |> case do + {:ok, workers} -> {:ok, Enum.reverse(workers)} + {:error, error} -> {:error, error} + end + end + + defp build_worker(project, target, profile, worker, wasm_bindgen, temporary, options) do + directory = Path.join([temporary, "workers", worker.name]) + + with {:ok, cargo} <- + Rekindle.Cargo.build_worker(project, target, worker, profile, cargo_options(options)), + :ok <- create_worker_directory(directory), + :ok <- bindgen(wasm_bindgen, cargo.artifact, directory, "worker", options), + :ok <- ensure_worker(directory) do + {:ok, + %{ + name: worker.name, + url: "workers/" <> worker.name <> "/worker.js", + package: cargo.package, + binary: cargo.binary + }} + end + end + + defp create_worker_directory(directory) do + case File.mkdir_p(directory) do + :ok -> :ok + {:error, reason} -> file_error(:worker_directory, directory, reason) + end + end + + defp ensure_worker(directory) do + if File.regular?(Path.join(directory, "worker.js")) and + File.regular?(Path.join(directory, "worker_bg.wasm")) do + :ok + else + error(:missing_worker, "wasm-bindgen did not produce worker.js and worker_bg.wasm") + end + end + + defp write_wrapper(_temporary, []), do: :ok + + defp write_wrapper(temporary, workers) do + entries = + Enum.map_join(workers, ",\n", fn worker -> + " " <> + inspect(worker.name) <> + ": new URL(" <> inspect(worker.url) <> ", import.meta.url).href" + end) + + wrapper = + "import init from \"./main.js\";\n\n" <> + "export const workers = Object.freeze({\n" <> + entries <> + "\n});\n\n" <> + "globalThis[Symbol.for(\"rekindle.workers\")] = workers;\n\n" <> + "export default init;\n" + + case File.write(Path.join(temporary, @entry), wrapper) do + :ok -> :ok + {:error, reason} -> file_error(:wrapper_write, Path.join(temporary, @entry), reason) + end + end + defp copy_public(client_root, destination) do source = Path.join(client_root, "public") @@ -123,12 +199,17 @@ defmodule Rekindle.Web.Builder do case File.stat(from) do {:ok, %{type: :directory}} -> - with :ok <- File.mkdir(to), - :ok <- copy_directory(from, to) do - {:cont, :ok} + if File.exists?(to) do + {:halt, + error(:asset_collision, "public asset collides with generated output: #{name}")} else - {:error, %Error{} = error} -> {:halt, {:error, error}} - {:error, reason} -> {:halt, file_error(:copy_public, to, reason)} + with :ok <- File.mkdir(to), + :ok <- copy_directory(from, to) do + {:cont, :ok} + else + {:error, %Error{} = error} -> {:halt, {:error, error}} + {:error, reason} -> {:halt, file_error(:copy_public, to, reason)} + end end {:ok, %{type: :regular}} -> diff --git a/test/integration/development_test.exs b/test/integration/development_test.exs index 458c055..777be16 100644 --- a/test/integration/development_test.exs +++ b/test/integration/development_test.exs @@ -319,6 +319,19 @@ defmodule Rekindle.DevelopmentTest do assert wasm.status == 200 assert Plug.Conn.get_resp_header(wasm, "content-type") == ["application/wasm"] + worker = request("/@rekindle/web/#{generation}/workers/storage/worker.js", options) + assert worker.status == 200 + + assert Plug.Conn.get_resp_header(worker, "content-type") == [ + "text/javascript; charset=utf-8" + ] + + worker_wasm = + request("/@rekindle/web/#{generation}/workers/storage/worker_bg.wasm", options) + + assert worker_wasm.status == 200 + assert Plug.Conn.get_resp_header(worker_wasm, "content-type") == ["application/wasm"] + runtime = request("/@rekindle/runtime.js", options) assert runtime.status == 200 assert runtime.resp_body =~ "navigator.gpu" @@ -625,6 +638,17 @@ defmodule Rekindle.DevelopmentTest do File.mkdir_p!(temporary) File.write!(Path.join(temporary, "app.js"), source) File.write!(Path.join(temporary, "app_bg.wasm"), <<0, 97, 115, 109>>) + File.mkdir_p!(Path.join(temporary, "workers/storage")) + + File.write!( + Path.join(temporary, "workers/storage/worker.js"), + "export default async function init() {}" + ) + + File.write!( + Path.join(temporary, "workers/storage/worker_bg.wasm"), + <<0, 97, 115, 109>> + ) generation = 16 diff --git a/test/integration/web_build_test.exs b/test/integration/web_build_test.exs index 6febc00..6aab9cc 100644 --- a/test/integration/web_build_test.exs +++ b/test/integration/web_build_test.exs @@ -140,6 +140,125 @@ defmodule Rekindle.WebBuildTest do assert {:error, %Rekindle.Web.Error{kind: :asset_collision}} = build(root, tools) end + test "publishes auxiliary Web workers with the main generation", %{root: root} do + configure_workers() + tools = fake_tools(root, "first", workers: ["storage"]) + + assert {:ok, result} = build(root, tools) + generation_root = Path.dirname(result.artifact) + + assert result.metadata.workers == [ + %{ + name: "storage", + url: "workers/storage/worker.js", + package: "fixture_ui", + binary: "storage-worker" + } + ] + + assert File.regular?(Path.join(generation_root, "main.js")) + assert File.regular?(Path.join(generation_root, "main_bg.wasm")) + assert File.regular?(Path.join(generation_root, "workers/storage/worker.js")) + assert File.regular?(Path.join(generation_root, "workers/storage/worker_bg.wasm")) + + wrapper = File.read!(result.artifact) + assert wrapper =~ ~s(import init from "./main.js";) + assert wrapper =~ ~s|"storage": new URL("workers/storage/worker.js", import.meta.url).href| + assert wrapper =~ ~s|globalThis[Symbol.for("rekindle.workers")] = workers;| + assert wrapper =~ "export const workers = Object.freeze" + assert wrapper =~ "export default init;" + end + + test "publishes auxiliary Web workers in release output", %{root: root} do + configure_workers() + tools = fake_tools(root, "first", workers: ["storage"]) + + assert {:ok, result} = build(root, tools, profile: :release) + generation_root = Path.dirname(result.artifact) + + assert result.artifact =~ "/priv/static/rekindle/web/" + assert File.regular?(Path.join(generation_root, "main.js")) + assert File.regular?(Path.join(generation_root, "main_bg.wasm")) + assert File.regular?(Path.join(generation_root, "workers/storage/worker.js")) + assert File.regular?(Path.join(generation_root, "workers/storage/worker_bg.wasm")) + + entry = File.read!(Path.join(root, "priv/static/rekindle/entry.js")) + assert entry =~ "// Rekindle generation: #{result.metadata.generation}" + assert entry =~ ~s(import init from "./web/#{result.metadata.generation}/app.js";) + end + + test "keeps no-worker output backward compatible", %{root: root} do + tools = fake_tools(root, "first") + + assert {:ok, result} = build(root, tools) + generation_root = Path.dirname(result.artifact) + + assert result.metadata.workers == [] + assert File.regular?(Path.join(generation_root, "app.js")) + assert File.regular?(Path.join(generation_root, "app_bg.wasm")) + refute File.exists?(Path.join(generation_root, "main.js")) + refute File.exists?(Path.join(generation_root, "workers")) + + wrapper = File.read!(result.artifact) + refute wrapper =~ "rekindle.workers" + refute wrapper =~ "Object.freeze" + end + + test "fails the whole generation when a worker build fails", %{root: root} do + configure_workers() + tools = fake_tools(root, "first", workers: ["storage"]) + File.write!(tools.mode, "missing-worker") + + assert {:error, %Rekindle.Web.Error{kind: :missing_worker}} = build(root, tools) + refute File.exists?(Path.join(root, ".rekindle/dev/web-current.json")) + end + + test "preserves the selected generation when a worker build fails", %{root: root} do + configure_workers() + tools = fake_tools(root, "first", workers: ["storage"]) + assert {:ok, selected} = build(root, tools) + selector = Path.join(root, ".rekindle/dev/web-current.json") + selected_contents = File.read!(selector) + + File.write!(tools.mode, "missing-worker") + + assert {:error, %Rekindle.Web.Error{kind: :missing_worker}} = build(root, tools) + assert File.read!(selector) == selected_contents + assert File.regular?(selected.artifact) + end + + test "preserves the selected release generation when a worker build fails", %{root: root} do + configure_workers() + tools = fake_tools(root, "first", workers: ["storage"]) + assert {:ok, selected} = build(root, tools, profile: :release) + selector = Path.join(root, "priv/static/rekindle/entry.js") + selected_contents = File.read!(selector) + + File.write!(tools.mode, "missing-worker") + + assert {:error, %Rekindle.Web.Error{kind: :missing_worker}} = + build(root, tools, profile: :release) + + assert File.read!(selector) == selected_contents + assert File.regular?(selected.artifact) + end + + test "rejects a public workers directory that collides with generated output", %{root: root} do + configure_workers() + File.mkdir_p!(Path.join(root, "client/public/workers")) + File.write!(Path.join(root, "client/public/workers/keep.txt"), "keep") + tools = fake_tools(root, "first", workers: ["storage"]) + + assert {:error, %Rekindle.Web.Error{kind: :asset_collision}} = build(root, tools) + end + + defp configure_workers do + Application.put_env(:rekindle_web_build_test, Rekindle, + plugin: Rekindle.Plugin.GPUI, + targets: [web: [features: ["web"], workers: [storage: [binary: "storage-worker"]]]] + ) + end + defp build(root, tools, options \\ []) do Rekindle.build( :web, @@ -152,7 +271,7 @@ defmodule Rekindle.WebBuildTest do ) end - defp fake_tools(root, mode) do + defp fake_tools(root, mode, options \\ []) do mode_path = Path.join(root, "bindgen-mode") File.write!(mode_path, mode) @@ -167,6 +286,25 @@ defmodule Rekindle.WebBuildTest do package_id = "fixture_ui 0.1.0" artifact = Path.join(root, "client/target/wasm32-unknown-unknown/debug/web.wasm") + worker_artifact = + Path.join(root, "client/target/wasm32-unknown-unknown/debug/storage-worker.wasm") + + targets = + [ + %{ + "name" => "web", + "kind" => ["bin"], + "src_path" => Path.join(root, "client/src/bin/web.rs") + } + ] ++ + Enum.map(Keyword.get(options, :workers, []), fn name -> + %{ + "name" => name <> "-worker", + "kind" => ["bin"], + "src_path" => Path.join(root, "client/src/bin/#{name}-worker.rs") + } + end) + metadata = Jason.encode!(%{ "packages" => [ @@ -174,13 +312,7 @@ defmodule Rekindle.WebBuildTest do "id" => package_id, "name" => "fixture_ui", "manifest_path" => Path.join(root, "client/Cargo.toml"), - "targets" => [ - %{ - "name" => "web", - "kind" => ["bin"], - "src_path" => Path.join(root, "client/src/bin/web.rs") - } - ], + "targets" => targets, "dependencies" => [%{"name" => "gpui"}] } ], @@ -197,15 +329,37 @@ defmodule Rekindle.WebBuildTest do "executable" => nil }) + worker_compiler_artifact = + Jason.encode!(%{ + "reason" => "compiler-artifact", + "package_id" => package_id, + "target" => %{"name" => "storage-worker", "kind" => ["bin"]}, + "filenames" => [worker_artifact], + "executable" => nil + }) + write_executable(cargo, """ #!/bin/sh if [ "$1" = "metadata" ]; then printf '%s\\n' '#{metadata}' exit 0 fi + binary="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--bin" ]; then + binary="$2" + break + fi + shift + done mkdir -p '#{Path.dirname(artifact)}' - printf 'wasm' > '#{artifact}' - printf '%s\\n' '#{compiler_artifact}' + if [ "$binary" = "storage-worker" ]; then + printf 'wasm' > '#{worker_artifact}' + printf '%s\\n' '#{worker_compiler_artifact}' + else + printf 'wasm' > '#{artifact}' + printf '%s\\n' '#{compiler_artifact}' + fi """) write_executable(wasm_bindgen, """ @@ -214,21 +368,31 @@ defmodule Rekindle.WebBuildTest do echo "wasm-bindgen 0.2.126" exit 0 fi + out_name="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out-dir" ]; then output="$2" - break + shift 2 + continue + fi + if [ "$1" = "--out-name" ]; then + out_name="$2" + shift 2 + continue fi shift done mkdir -p "$output" mode=$(cat '#{mode_path}') - if [ "$mode" = "missing-entry" ]; then - printf 'wasm' > "$output/app_bg.wasm" + if [ "$mode" = "missing-entry" ] && [ "$out_name" = "app" ]; then + printf 'wasm' > "$output/${out_name}_bg.wasm" + exit 0 + fi + if [ "$mode" = "missing-worker" ] && [ "$out_name" = "worker" ]; then exit 0 fi - printf "const wasm = new URL('app_bg.wasm', import.meta.url);\\n/* %s */\\nexport default async function init() { return wasm; }\\n" "$mode" > "$output/app.js" - printf 'wasm-%s' "$mode" > "$output/app_bg.wasm" + printf "const wasm = new URL('${out_name}_bg.wasm', import.meta.url);\\n/* %s */\\nexport default async function init() { return wasm; }\\n" "$mode" > "$output/${out_name}.js" + printf 'wasm-%s' "$mode" > "$output/${out_name}_bg.wasm" """) %{cargo: cargo, env: env, mode: mode_path} diff --git a/test/rekindle/cargo_test.exs b/test/rekindle/cargo_test.exs index a8169fc..831ff22 100644 --- a/test/rekindle/cargo_test.exs +++ b/test/rekindle/cargo_test.exs @@ -224,6 +224,60 @@ defmodule Rekindle.CargoTest do Cargo.build(project, target(:desktop), :dev, cargo: cargo, timeout: 100) end + test "builds a configured auxiliary Web worker", %{project: project} do + {cargo, arguments_file, _started_file, _metadata_cwd_file, _build_cwd_file} = + fake_cargo(project, :worker_artifact) + + worker = %Rekindle.Config.Worker{ + name: "storage", + package: "fixture_ui", + binary: "storage-worker", + features: ["web"] + } + + assert {:ok, result} = + Cargo.build_worker(project, target(:web), worker, :dev, cargo: cargo) + + assert result.package == "fixture_ui" + assert result.binary == "storage-worker" + assert result.target == "wasm32-unknown-unknown" + assert String.ends_with?(result.artifact, ".wasm") + + arguments = File.read!(arguments_file) |> String.split("\n", trim: true) + + assert arguments == [ + "build", + "--manifest-path", + Path.join(project.client_root, "Cargo.toml"), + "--message-format=json-render-diagnostics", + "--package", + "fixture_ui", + "--bin", + "storage-worker", + "--profile", + "dev", + "--target", + "wasm32-unknown-unknown", + "--features", + "web" + ] + end + + test "rejects a worker whose binary is missing from Cargo metadata", %{project: project} do + {cargo, _arguments_file, _started_file, _metadata_cwd_file, _build_cwd_file} = + fake_cargo(project, :artifact) + + worker = %Rekindle.Config.Worker{ + name: "storage", + package: "fixture_ui", + binary: "missing-worker", + features: ["web"] + } + + assert {:error, %Cargo.Error{kind: :binary_not_found}} = + Cargo.build_worker(project, target(:web), worker, :dev, cargo: cargo) + end + defp target(name) do %Config.Target{ name: name, @@ -271,6 +325,7 @@ defmodule Rekindle.CargoTest do case mode do :mismatched_artifact -> "other" :desktop_artifact -> "desktop" + :worker_artifact -> "storage-worker" _ -> "web" end @@ -363,6 +418,11 @@ defmodule Rekindle.CargoTest do "name" => "web", "kind" => ["bin"], "src_path" => Path.join(project.client_root, "src/bin/web.rs") + }, + %{ + "name" => "storage-worker", + "kind" => ["bin"], + "src_path" => Path.join(project.client_root, "src/bin/storage-worker.rs") } ], dependencies: ["gpui"] diff --git a/test/rekindle/config_test.exs b/test/rekindle/config_test.exs index 1093b3d..764359b 100644 --- a/test/rekindle/config_test.exs +++ b/test/rekindle/config_test.exs @@ -82,6 +82,91 @@ defmodule Rekindle.ConfigTest do assert project.targets.desktop.features == [] end + test "loads configured auxiliary Web workers" do + Application.put_env(:rekindle_config_test, Rekindle, + plugin: Rekindle.Plugin.GPUI, + targets: [ + web: [ + workers: [ + storage: [ + package: "storage_worker", + binary: "storage-worker", + features: ["web"] + ], + search: [] + ] + ] + ] + ) + + assert {:ok, project} = Config.load(:rekindle_config_test) + + assert [ + %Config.Worker{ + name: "storage", + package: "storage_worker", + binary: "storage-worker", + features: ["web"] + }, + %Config.Worker{ + name: "search", + package: nil, + binary: nil, + features: ["web"] + } + ] = project.targets.web.workers + end + + test "rejects invalid worker configuration" do + Application.put_env(:rekindle_config_test, Rekindle, + plugin: Rekindle.Plugin.GPUI, + targets: [web: [workers: [storage: [features: "web"]]]] + ) + + assert {:error, %Config.Error{kind: :invalid_features}} = + Config.load(:rekindle_config_test) + + Application.put_env(:rekindle_config_test, Rekindle, + plugin: Rekindle.Plugin.GPUI, + targets: [web: [workers: [storage: [unknown: true]]]] + ) + + assert {:error, %Config.Error{kind: :unknown_key}} = + Config.load(:rekindle_config_test) + + Application.put_env(:rekindle_config_test, Rekindle, + plugin: Rekindle.Plugin.GPUI, + targets: [web: [workers: [{:"bad name", []}]]] + ) + + assert {:error, %Config.Error{kind: :invalid_worker_name}} = + Config.load(:rekindle_config_test) + + Application.put_env(:rekindle_config_test, Rekindle, + plugin: Rekindle.Plugin.GPUI, + targets: [web: [workers: [storage: [], storage: []]]] + ) + + assert {:error, %Config.Error{kind: :duplicate_key}} = + Config.load(:rekindle_config_test) + + Application.put_env(:rekindle_config_test, Rekindle, + plugin: Rekindle.Plugin.GPUI, + targets: [desktop: [workers: [storage: []]]] + ) + + assert {:error, %Config.Error{kind: :desktop_workers}} = + Config.load(:rekindle_config_test) + + Application.put_env(:rekindle_config_test, Rekindle, + plugin: Rekindle.Plugin.GPUI, + targets: [desktop: [workers: []]] + ) + + assert {:error, %Config.Error{kind: :desktop_workers}} = + Config.load(:rekindle_config_test) + end + test "rejects unknown configuration values" do Application.put_env(:rekindle_config_test, Rekindle, plugin: :other,