Current section

Files

Jump to
rekindle lib rekindle dev_server.ex
Raw

lib/rekindle/dev_server.ex

defmodule Rekindle.DevServer do
@moduledoc """
Plug that serves Rekindle Web generations during development.
Add it to a Plug pipeline with the OTP application that owns the Rekindle
configuration:
plug Rekindle.DevServer, otp_app: :my_app
The first request starts the Web development runtime unless `watch: false` is
supplied. The Phoenix installer adds this Plug automatically.
"""
@behaviour Plug
import Plug.Conn
require Logger
alias Rekindle.Config
alias Rekindle.Development
alias Rekindle.Development.State
@entry "app.js"
@prefix ["__rekindle"]
@generation ~r/\A[0-9a-f]{32}\z/
@runtime_path Path.expand("../../priv/runtime/dev_server.js", __DIR__)
@external_resource @runtime_path
@runtime File.read!(@runtime_path)
@graphics_placeholder "/* __REKINDLE_GRAPHICS_CHECK__ */"
@impl Plug
def init(options), do: options
@impl Plug
def call(conn, options) do
ensure_development(options)
dispatch(conn, options)
end
defp dispatch(
%Plug.Conn{method: "GET", path_info: @prefix ++ ["runtime.js"]} = conn,
options
) do
with {:ok, project} <- project(options),
{:ok, {_module, _plugin_options, plugin}} <- Rekindle.Plugin.load(project.plugin) do
conn
|> no_store()
|> put_resp_content_type("text/javascript")
|> send_resp(200, runtime(plugin.web.graphics))
|> halt()
else
_error -> unavailable(conn)
end
end
defp dispatch(
%Plug.Conn{method: "GET", path_info: @prefix ++ ["current"]} = conn,
options
) do
with {:ok, project} <- project(options) do
case State.build_error(project) do
{:ok, message} ->
json(conn, 409, %{"error" => message})
:none ->
case current(project) do
{:ok, selection} ->
json(conn, 200, %{
"generation" => selection.generation,
"entry" => path(selection.generation, selection.entry)
})
_error ->
unavailable(conn)
end
end
else
_error -> unavailable(conn)
end
end
defp dispatch(
%Plug.Conn{
method: "GET",
path_info: @prefix ++ ["web", generation | member]
} = conn,
options
) do
requested = Enum.join(member, "/")
with true <- Regex.match?(@generation, generation),
true <- safe_member?(requested),
{:ok, project} <- project(options),
root = Path.join([project.root, ".rekindle", "dev", "web", generation]),
{:ok, contents} <- File.read(Path.join(root, requested)) do
conn
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_asset_content_type(requested)
|> send_resp(200, contents)
|> halt()
else
_error -> not_found(conn)
end
end
defp dispatch(conn, _options), do: conn
defp project(options) do
Config.load(
Keyword.fetch!(options, :otp_app),
project_root: Keyword.get(options, :project_root, File.cwd!())
)
end
defp ensure_development(options) do
if Keyword.get(options, :watch, true) do
development_options =
options
|> Keyword.take([:otp_app, :project_root])
|> Keyword.put(:targets, [:web])
case Development.ensure_started(development_options) do
{:ok, _pid} ->
:ok
{:error, reason} ->
Logger.warning(
"could not start Rekindle Web development runtime: #{error_message(reason)}"
)
end
end
end
defp error_message(reason) do
if is_exception(reason), do: Exception.message(reason), else: inspect(reason)
end
defp current(project) do
selector_path = Path.join([project.root, ".rekindle", "dev", "web-current.json"])
with {:ok, contents} <- File.read(selector_path),
{:ok, %{"generation" => generation}} <- Jason.decode(contents),
true <- Regex.match?(@generation, generation),
true <-
File.regular?(Path.join([project.root, ".rekindle", "dev", "web", generation, @entry])) do
{:ok, %{generation: generation, entry: @entry}}
end
end
defp safe_member?(member) when is_binary(member) and member != "" do
root = "/generation"
expanded = Path.expand(member, root)
Path.type(member) == :relative and expanded != root and
String.starts_with?(expanded, root <> "/") and
Path.relative_to(expanded, root) == member
end
defp safe_member?(_member), do: false
defp path(generation, entry), do: "/__rekindle/web/#{generation}/#{entry}"
defp put_asset_content_type(conn, path) do
if Path.extname(path) == ".wasm" do
put_resp_header(conn, "content-type", "application/wasm")
else
put_resp_content_type(conn, MIME.from_path(path))
end
end
defp runtime(graphics) do
String.replace(@runtime, @graphics_placeholder, graphics_check(graphics))
end
defp graphics_check(:webgpu) do
"""
if (!window.isSecureContext) {
throw new Error("WebGPU requires HTTPS or a loopback origin.");
}
if (!navigator.gpu) {
throw new Error("This browser does not expose WebGPU.");
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No WebGPU graphics adapter is available.");
}
"""
end
defp graphics_check(:webgl2) do
"""
const probe = document.createElement("canvas");
if (!probe.getContext("webgl2")) {
throw new Error("No WebGL2 graphics context is available.");
}
"""
end
defp no_store(conn), do: put_resp_header(conn, "cache-control", "no-store")
defp json(conn, status, body) do
conn
|> no_store()
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(body))
|> halt()
end
defp unavailable(conn) do
conn
|> no_store()
|> send_resp(503, "Rekindle Web output is not available")
|> halt()
end
defp not_found(conn) do
conn
|> send_resp(404, "Not found")
|> halt()
end
end