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
4 changes: 3 additions & 1 deletion lib/onchain_js/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ defmodule OnchainJs.Application do

@impl true
def start(_type, _args) do
children = []
children = [
OnchainJs.RuntimeSupervisor
]

opts = [strategy: :one_for_one, name: OnchainJs.Supervisor]
Supervisor.start_link(children, opts)
Expand Down
157 changes: 157 additions & 0 deletions lib/onchain_js/runtime.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
defmodule OnchainJs.Runtime do
@moduledoc """
Thin wrapper over `QuickBEAM` providing supervised JavaScript runtimes for
`onchain_js`.

Each runtime is a `GenServer` holding a persistent QuickJS-NG context.
Functions, modules, and globals survive across `eval/2` and `call/3` calls,
so the typical pattern is to start a runtime once, load a JS bundle, then
invoke library functions on demand.

Defaults to the `:browser` API surface (`fetch`, `crypto`, `WebSocket`,
`URL`, `TextEncoder`, `document`) since the npm packages this project
targets — solc-js, Uniswap SDK, DeFiSaver, merkletreejs — ship browser
bundles and assume those globals exist. Callers may override any option
by passing them through to `start_link/1`.

## Browser-bundle stubs

Browser bundles routinely reference `self`, `window`, `navigator`, and
`location`. The `:browser` API surface does NOT define these — they must
be stubbed before the bundle is loaded. `apply_browser_stubs/1` performs
the standard QuickBEAM-recommended stub sequence in one call.

## Example

{:ok, rt} = OnchainJs.Runtime.start_link()
:ok = OnchainJs.Runtime.apply_browser_stubs(rt)
{:ok, 2} = OnchainJs.Runtime.eval(rt, "1 + 1")
:ok = OnchainJs.Runtime.stop(rt)

## Supervision

Runtimes are typically spawned dynamically via `OnchainJs.RuntimeSupervisor`:

{:ok, pid} =
DynamicSupervisor.start_child(
OnchainJs.RuntimeSupervisor,
{OnchainJs.Runtime, []}
)
"""

@type runtime :: GenServer.server()
@type js_result :: {:ok, term()} | {:error, QuickBEAM.JS.Error.t()}

@doc false
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
%{
id: Keyword.get(opts, :id, Keyword.get(opts, :name, __MODULE__)),
start: {__MODULE__, :start_link, [opts]},
restart: :transient
}
end
Comment on lines +45 to +53

@doc """
Start a new supervised QuickBEAM runtime.

Defaults `apis: :browser`. Callers may override any QuickBEAM option by
including it in `opts` (the caller's value wins).

See `QuickBEAM.start/1` for the full list of options (`:name`, `:script`,
`:handlers`, `:define`, `:memory_limit`, `:max_stack_size`, etc.).
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
opts
|> Keyword.put_new(:apis, :browser)
|> QuickBEAM.start()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install browser stubs before startup scripts run

When callers use the documented :script option here to load a browser bundle, QuickBEAM.start/1 evaluates that script before start_link/1 returns, so there is no opportunity to call apply_browser_stubs/1 first. Bundles that touch self, window, navigator, or location at top level will fail during startup despite this wrapper being the foundation for browser-bundle loading; the stubs need to be applied before loading the script, e.g. by starting the runtime without the script, stubbing, then loading/evaluating it.

Useful? React with 👍 / 👎.

end

@doc """
Evaluate JavaScript source in `runtime`.

Equivalent to `QuickBEAM.eval/2`. Top-level `await` is supported; the
promise is awaited before returning.
"""
@spec eval(runtime(), String.t()) :: js_result()
def eval(runtime, code) when is_binary(code) do
QuickBEAM.eval(runtime, code)
end

@doc """
Evaluate JavaScript source in `runtime` with options.

Equivalent to `QuickBEAM.eval/3`. Supports `:timeout` (ms) and `:vars`
(map of globals injected for the duration of the evaluation).
"""
@spec eval(runtime(), String.t(), keyword()) :: js_result()
def eval(runtime, code, opts) when is_binary(code) and is_list(opts) do
QuickBEAM.eval(runtime, code, opts)
end

@doc """
Call a global JavaScript function by name.

Equivalent to `QuickBEAM.call/3`. Promise-returning functions are
awaited automatically.
"""
@spec call(runtime(), String.t(), [term()]) :: js_result()
def call(runtime, fn_name, args) when is_binary(fn_name) and is_list(args) do
QuickBEAM.call(runtime, fn_name, args)
end

@doc """
Call a global JavaScript function by name with options.

Equivalent to `QuickBEAM.call/4`. Supports `:timeout` (ms).
"""
@spec call(runtime(), String.t(), [term()], keyword()) :: js_result()
def call(runtime, fn_name, args, opts) when is_binary(fn_name) and is_list(args) and is_list(opts) do
QuickBEAM.call(runtime, fn_name, args, opts)
end

@doc """
Stop `runtime` and free its resources.

Equivalent to `QuickBEAM.stop/1`. Returns `:ok`.
"""
@spec stop(runtime()) :: :ok
def stop(runtime) do
QuickBEAM.stop(runtime)
end

@doc """
Apply the standard browser-global stub sequence to `runtime`.

Stubs:

* `globalThis.self = globalThis;`
* `globalThis.window = globalThis;`
* `navigator = %{"userAgent" => "OnchainJs"}`
* `location = %{"protocol" => "https:"}`

`self` and `window` MUST literally BE `globalThis` (not the string
`"globalThis"`), so they're set via `eval/2` rather than `set_global/3` —
the latter would convert the atom value to a string and break libraries
that perform `self === globalThis` identity checks.

Returns `:ok` on success, `{:error, reason}` on failure.
"""
@spec apply_browser_stubs(runtime()) :: :ok | {:error, term()}
def apply_browser_stubs(runtime) do
with {:ok, _} <-
QuickBEAM.eval(
runtime,
"globalThis.self = globalThis; globalThis.window = globalThis;"
),
:ok <-
QuickBEAM.set_global(runtime, "navigator", %{"userAgent" => "OnchainJs"}),
:ok <-
QuickBEAM.set_global(runtime, "location", %{"protocol" => "https:"}) do
:ok
else
{:error, _} = error -> error
end
end
end
37 changes: 37 additions & 0 deletions lib/onchain_js/runtime_supervisor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
defmodule OnchainJs.RuntimeSupervisor do
@moduledoc """
`DynamicSupervisor` for `OnchainJs.Runtime` processes.

Started as part of the `OnchainJs.Application` supervision tree under the
registered name `OnchainJs.RuntimeSupervisor`. No runtimes are started by
default — consumers spawn supervised runtimes on demand:

{:ok, pid} =
DynamicSupervisor.start_child(
OnchainJs.RuntimeSupervisor,
{OnchainJs.Runtime, []}
)

:ok = DynamicSupervisor.terminate_child(OnchainJs.RuntimeSupervisor, pid)

Strategy is `:one_for_one`: a crashed runtime is not auto-restarted because
child specs from `OnchainJs.Runtime.child_spec/1` use `restart: :transient`,
matching the lifetime of the loaded JS bundle.
Comment on lines +17 to +19
"""

use DynamicSupervisor

@doc """
Start the supervisor under the registered name `OnchainJs.RuntimeSupervisor`.
"""
@spec start_link(keyword()) :: Supervisor.on_start()
def start_link(init_arg \\ []) do
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end

@impl true
@spec init(term()) :: {:ok, DynamicSupervisor.sup_flags()}
def init(_init_arg) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end
84 changes: 84 additions & 0 deletions test/onchain_js/runtime_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
defmodule OnchainJs.RuntimeTest do
use ExUnit.Case, async: false

alias OnchainJs.Runtime
alias OnchainJs.RuntimeSupervisor

@moduletag :integration

describe "lifecycle" do
test "start_link/1, eval/2, stop/1 round-trip" do
assert {:ok, rt} = Runtime.start_link()
assert is_pid(rt)
assert Process.alive?(rt)

assert {:ok, 2} = Runtime.eval(rt, "1 + 1")

assert :ok = Runtime.stop(rt)
refute Process.alive?(rt)
end
end

describe "apply_browser_stubs/1" do
setup do
{:ok, rt} = Runtime.start_link()
on_exit(fn -> if Process.alive?(rt), do: Runtime.stop(rt) end)
{:ok, rt: rt}
end

test "stubs self/window so that self === globalThis", %{rt: rt} do
assert :ok = Runtime.apply_browser_stubs(rt)

assert {:ok, true} =
Runtime.eval(rt, "typeof self === 'object' && self === globalThis")

assert {:ok, true} =
Runtime.eval(rt, "typeof window === 'object' && window === globalThis")

assert {:ok, "OnchainJs"} = Runtime.eval(rt, "navigator.userAgent")
assert {:ok, "https:"} = Runtime.eval(rt, "location.protocol")
end
end

describe "eval/3 and call/3,4" do
setup do
{:ok, rt} = Runtime.start_link()
on_exit(fn -> if Process.alive?(rt), do: Runtime.stop(rt) end)
{:ok, rt: rt}
end

test "eval/3 supports :vars", %{rt: rt} do
assert {:ok, "ONCHAINJS"} =
Runtime.eval(rt, "name.toUpperCase()", vars: %{"name" => "onchainJs"})
end

test "call/3 invokes a registered global function", %{rt: rt} do
assert {:ok, _} =
Runtime.eval(rt, "function add(a, b) { return a + b }")

assert {:ok, 5} = Runtime.call(rt, "add", [2, 3])
end

test "call/4 forwards options (e.g. :timeout)", %{rt: rt} do
assert {:ok, _} =
Runtime.eval(rt, "function ident(x) { return x }")

assert {:ok, "abc"} = Runtime.call(rt, "ident", ["abc"], timeout: 5_000)
end
end

describe "supervised lifecycle" do
test "DynamicSupervisor.start_child/2 spawns a runtime; terminate_child/2 stops it" do
assert {:ok, pid} =
DynamicSupervisor.start_child(RuntimeSupervisor, {Runtime, []})

assert is_pid(pid)
assert Process.alive?(pid)

assert {:ok, 4} = Runtime.eval(pid, "2 + 2")

assert :ok = DynamicSupervisor.terminate_child(RuntimeSupervisor, pid)
refute Process.alive?(pid)
end
end
end