Skip to content

Commit 95d0ee3

Browse files
authored
Merge pull request #5 from tuist/feat/async-host-callbacks
feat: support asynchronous Elixir callbacks from JavaScript
2 parents b257639 + 7ae22f3 commit 95d0ee3

25 files changed

Lines changed: 2462 additions & 156 deletions

‎.agents/setup‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
packages=(build-essential autoconf libncurses-dev libssl-dev pkg-config clang libclang-dev lld wasi-libc)
5+
missing=()
6+
for package in "${packages[@]}"; do
7+
if ! dpkg-query -W -f='${Status}' "$package" 2>/dev/null | grep -q 'install ok installed'; then
8+
missing+=("$package")
9+
fi
10+
done
11+
if ((${#missing[@]})); then
12+
sudo apt-get update -qq
13+
sudo apt-get install -y "${missing[@]}"
14+
fi
15+
16+
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
17+
if ! command -v mise >/dev/null; then
18+
curl -fsSL https://mise.run | sh
19+
fi
20+
if ! grep -Fq '# Javex orb toolchains' "$HOME/.bash_profile" 2>/dev/null; then
21+
cat >> "$HOME/.bash_profile" <<'PROFILE'
22+
23+
# Javex orb toolchains
24+
export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
25+
PROFILE
26+
fi
27+
mise trust
28+
mise install
29+
mise exec -- rustup target add wasm32-wasip1
30+
mise exec -- mix local.hex --force
31+
mise exec -- mix local.rebar --force
32+
mise exec -- mix deps.get
33+
34+
# Only needed to rebuild the bundled custom provider. Verify the pinned CLI.
35+
if [[ ! -x "$HOME/.local/bin/javy" ]] || [[ "$("$HOME/.local/bin/javy" --version)" != 'javy 8.1.1' ]]; then
36+
download="$(mktemp)"
37+
trap 'rm -f "$download"' EXIT
38+
curl -fsSL https://github.com/bytecodealliance/javy/releases/download/v8.1.1/javy-x86_64-linux-v8.1.1.gz -o "$download"
39+
echo "4bec224e1d51827808fd063541a2b4d94cf474744636b692be63e7a29bec83a6 $download" | sha256sum -c -
40+
gzip -dc "$download" > "$HOME/.local/bin/javy"
41+
chmod +x "$HOME/.local/bin/javy"
42+
fi

‎.github/workflows/ci.yml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,33 @@ jobs:
7171
run: |
7272
cargo install cargo-audit --locked
7373
cargo audit --file native/javex_nif/Cargo.lock
74+
cargo audit --file native/javex_plugin/Cargo.lock
75+
76+
provider:
77+
name: Custom provider build and lint
78+
runs-on: tuist-linux
79+
steps:
80+
- uses: actions/checkout@v4
81+
- uses: jdx/mise-action@v2
82+
with:
83+
experimental: true
84+
- name: Install provider build prerequisites
85+
run: |
86+
sudo apt-get update -qq
87+
sudo apt-get install -y clang libclang-dev lld wasi-libc
88+
rustup target add wasm32-wasip1
89+
rustup component add rustfmt clippy
90+
curl -fsSL https://github.com/bytecodealliance/javy/releases/download/v8.1.1/javy-x86_64-linux-v8.1.1.gz -o /tmp/javy.gz
91+
echo '4bec224e1d51827808fd063541a2b4d94cf474744636b692be63e7a29bec83a6 /tmp/javy.gz' | sha256sum -c -
92+
gzip -dc /tmp/javy.gz > /tmp/javy
93+
chmod +x /tmp/javy
94+
- name: Check and rebuild provider
95+
run: |
96+
cargo fmt --manifest-path native/javex_plugin/Cargo.toml --check
97+
cargo clippy --manifest-path native/javex_plugin/Cargo.toml --locked --target wasm32-wasip1 -- -D warnings
98+
cp priv/javy_plugin.wasm /tmp/bundled-provider.wasm
99+
PATH="/tmp:$PATH" bash native/javex_plugin/build.sh
100+
cmp /tmp/bundled-provider.wasm priv/javy_plugin.wasm
74101
75102
compile:
76103
name: Compile without warnings

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ erl_crash.dump
1313

1414
# Rust / NIF
1515
/native/javex_nif/target/
16+
/native/javex_plugin/target/
1617
/priv/native/
1718

1819
# macOS

‎README.md‎

Lines changed: 104 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,95 @@ Javy.IO.writeSync(1, new TextEncoder().encode(JSON.stringify({hello: "world"})))
5555
| Compile a JS snippet | `Javex.compile/2` |
5656
| Run a compiled module with JSON I/O | `Javex.run(mod, input)` |
5757
| Run with raw bytes | `Javex.run(mod, bytes, encoding: :raw)` |
58+
| Grant a host capability | `Javex.run(mod, input, callbacks: %{"name" => &MyApp.callback/2})` |
5859
| Cap fuel, memory, or timeout | `Javex.run(mod, input, fuel: …, max_memory: …, timeout: …)` |
5960
| Set runtime defaults | `Javex.Runtime.start_link(name: :strict, default_fuel: …, default_max_memory: …)` |
6061
| Inspect active calls and cache usage | `Javex.Runtime.stats()` |
6162

6263
Persisting compiled modules? `%Javex.Module{}` is a plain struct — `:erlang.term_to_binary/1` round-trips it, no helper needed.
6364

65+
## Asynchronous host callbacks
66+
67+
`Javex.Host.call(name, argument)` returns a real Promise. Top-level `await`,
68+
sequential calls and `Promise.all` work on both backends. Each run grants its own
69+
named callbacks; runtimes never carry grants or credentials across runs.
70+
71+
For example, expose a narrow HTTP capability, not an unrestricted proxy:
72+
73+
```elixir
74+
js = ~S"""
75+
const products = await Promise.all([
76+
Javex.Host.call("catalog.product", {id: "p123"}),
77+
Javex.Host.call("catalog.product", {id: "p456"})
78+
]);
79+
Javy.IO.writeSync(1, new TextEncoder().encode(JSON.stringify(products)));
80+
"""
81+
82+
{:ok, mod} = Javex.compile(js, runtime: :untrusted_js)
83+
Javex.run(mod, nil,
84+
runtime: :untrusted_js,
85+
callbacks: %{"catalog.product" => &MyApp.Catalog.product/2},
86+
callback_timeout: 1_500,
87+
max_callback_calls: 8,
88+
max_callback_concurrency: 2
89+
)
90+
```
91+
92+
The application supplies `MyApp.Catalog` and its HTTP client. For example, with
93+
Finch installed and a `MyApp.Finch` pool supervised by the application:
94+
95+
```elixir
96+
defmodule MyApp.Catalog do
97+
def product(%{"id" => id}, %{deadline: deadline}) when is_binary(id) do
98+
if !Regex.match?(~r/\A[a-zA-Z0-9_-]{1,64}\z/, id), do: raise("invalid product ID")
99+
remaining = deadline - System.monotonic_time(:millisecond)
100+
if remaining <= 0, do: raise("deadline elapsed")
101+
102+
# Neither the base URL nor credentials are chosen by JavaScript.
103+
token = Application.fetch_env!(:my_app, :catalog_token)
104+
request = Finch.build(:get, "https://catalog.example.com/products/" <> id,
105+
[{"authorization", "Bearer " <> token}])
106+
107+
{:ok, response} = Finch.stream(request, MyApp.Finch, %{status: nil, body: ""}, fn
108+
{:status, status}, acc -> %{acc | status: status}
109+
{:data, chunk}, acc ->
110+
if byte_size(acc.body) + byte_size(chunk) > 65_536 or
111+
System.monotonic_time(:millisecond) >= deadline,
112+
do: raise("response budget exceeded")
113+
%{acc | body: acc.body <> chunk}
114+
_, acc -> acc
115+
end, pool_timeout: remaining, receive_timeout: remaining)
116+
117+
{:ok, %{"status" => response.status, "body" => response.body}}
118+
end
119+
end
120+
```
121+
122+
Finch does not follow redirects automatically. Keep destination, methods,
123+
redirect/DNS policy and credentials in trusted host code. The example bounds
124+
the body while streaming; checking its size after buffering would be too late.
125+
HTTP is not a Javex dependency, and `fetch` is not installed: the generic bridge
126+
does not implement the browser Request/Response, streaming or AbortSignal APIs.
127+
128+
Callbacks receive one JSON value and an absolute monotonic millisecond deadline.
129+
Return `{:ok, json}` or `{:error, %{"code" => code, "message" => safe_message}}`.
130+
JSON objects use string keys; encode binary bodies explicitly (for example base64),
131+
and use strings for integers beyond JavaScript's safe range. Names are non-empty
132+
UTF-8 strings of at most 128 bytes, with at most 128 grants per run.
133+
134+
Declared errors reject with `HostError` (`code` and `message`). Missing grants use
135+
`capability_denied`; crashes and invalid results use sanitized `callback_failed`
136+
messages. Rejections propagated to the root module fail with `:js_error`.
137+
Callback limits and timeouts terminate the run even if JavaScript catches errors.
138+
Concurrency limits reject excess work rather than building an unbounded queue.
139+
140+
Execution ends when the root module evaluation settles. Await all required work;
141+
detached callbacks are cancelled before a successful run returns. Caller death,
142+
runtime shutdown and deadlines also stop owned callback processes. Host callbacks
143+
run outside the guest's memory and OS limits. Propagate their deadline to your
144+
client and cancel separately owned tasks; killing a callback cannot undo an HTTP
145+
request already received by a server. There is no per-call AbortSignal API yet.
146+
64147
## 🛡 Safety knobs
65148

66149
```elixir
@@ -93,6 +176,10 @@ Jason errors; sandbox failures return `Javex.RuntimeError`:
93176
| `max_input`, `max_output` | 1 MiB each |
94177
| `max_stderr` | 64 KiB diagnostics |
95178
| `max_module` | 8 MiB input Wasm |
179+
| `max_callback_calls` | 32 attempts per run; maximum 1,024; zero disables callbacks |
180+
| `max_callback_concurrency` | 4 outstanding calls; maximum 1,024 |
181+
| `callback_timeout` | 1,000 ms per callback, bounded by the run deadline |
182+
| `max_callback_input`, `max_callback_output` | 64 KiB argument / 256 KiB result or error; maximum 8 MiB each |
96183
| Runtime `max_concurrency` | 4 active calls; excess calls return `:busy` |
97184
| Runtime `max_cached_modules`, `max_cache_bytes` | 64 modules / 64 MiB compiled images, LRU eviction |
98185

@@ -157,11 +244,14 @@ and cold compilation costs apply on every call. IPC requests/responses have a
157244
are configured. The host environment is never inherited, no filesystem
158245
directories are preopened, and no network capability is granted.
159246
- The bundled JavaScript API provides synchronous `Javy.IO`, TextEncoder and
160-
TextDecoder, and standard QuickJS intrinsics. It is **not Node or Deno**:
161-
there is no `fetch`, filesystem/path API, external module loading, timers,
162-
or Promise event loop. Pending Promise jobs fail rather than running later.
247+
TextDecoder, standard QuickJS intrinsics, Promises, top-level `await`, and
248+
explicit `Javex.Host.call` capabilities. It is **not Node or Deno**:
249+
there is no `fetch`, filesystem/path API, external module loading or timers.
250+
A root awaiting a Promise with no possible completion reaches the run deadline.
163251
WASI environment pairs are not a JavaScript `process.env` API.
164-
- Clocks and randomness are not deterministic. The module provider hash checks
252+
- Runtime clocks are not deterministic. Snapshot-seeded `Math.random` can repeat
253+
across instances and is not cryptographically secure; obtain security-sensitive
254+
randomness from a host callback. The module provider hash checks
165255
compatibility, not authenticity. Persist only trusted module metadata and
166256
use `:erlang.binary_to_term(bytes, [:safe])` only for trusted serialized data.
167257
- The default `:in_process` backend is for trusted workloads: native JIT work
@@ -172,7 +262,7 @@ and cold compilation costs apply on every call. IPC requests/responses have a
172262
containers to cap the whole application. Process isolation assumes a trusted
173263
Linux kernel and does not eliminate kernel vulnerabilities or side channels.
174264

175-
Javex supplies bounded synchronous computation. Workflow persistence, retries,
265+
Javex supplies bounded computation and explicitly granted host calls. Workflow persistence, retries,
176266
credentials, tools, permissions and external effects belong in the automation
177267
layer above it. Do not execute agent-generated JS as trusted Elixir host code.
178268

@@ -181,27 +271,26 @@ layer above it. Do not execute agent-generated JS as trusted Elixir host code.
181271
- Compilation runs the provider's `compile-src` ABI inside a fuel/memory/deadline-limited Wasm store, then emits a small wrapper (or embeds the provider in static mode). No JS runs as native host code.
182272
- In-process runtimes share a Wasmtime engine and one epoch clock thread. Each owns a compiled provider, bounded worker admission, and a bounded native-module cache. Isolated runtimes instead launch fresh OS-restricted workers.
183273
- Every `run/3` creates a fresh Wasmtime `Store` and provider instance. The cache avoids repeat native compilation, including for modules whose execution fails.
274+
- The resumable provider returns control between Promise continuations. Elixir runs callbacks in monitored processes, then resumes the same store with correlated results. I/O waits occupy no dirty NIF scheduler. Isolated workers exchange bounded callback/completion frames; their OS network restrictions remain unchanged.
184275
- The Rust NIF and worker executable ship **precompiled** for macOS (Apple Silicon + Intel) and Linux (aarch64 + x86_64 GNU). The isolated backend is Linux-only. No Rust toolchain required to install.
185276

186277
### Bundled provider provenance
187278

188-
`priv/javy_plugin.wasm` is the initialized default provider from
189-
[Javy v8.1.1](https://github.com/bytecodealliance/javy/releases/tag/v8.1.1), using
190-
namespace `javy-default-plugin-v3`. The bundled bytes match the decompressed
191-
official `plugin.wasm.gz` asset exactly. Reproduce or verify them with:
279+
`priv/javy_plugin.wasm` is Javex's custom resumable provider, built from the pinned
280+
Javy 7.0.0 runtime used by [Javy CLI v8.1.1](https://github.com/bytecodealliance/javy/releases/tag/v8.1.1).
281+
It is **not** the upstream default provider. Source, locked dependencies,
282+
build instructions and the versioned ABI are in `native/javex_plugin/`.
283+
Install the mise toolchain, clang/libclang and Javy CLI 8.1.1, then rebuild with:
192284

193285
```sh
194-
curl -fL https://github.com/bytecodealliance/javy/releases/download/v8.1.1/plugin.wasm.gz -o /tmp/javy-plugin.wasm.gz
195-
echo '52e3c83235f7735cb294e119a1a4b7be0a503323d5b8967eff8c0e516f3da8e1 /tmp/javy-plugin.wasm.gz' | sha256sum -c -
196-
gzip -dc /tmp/javy-plugin.wasm.gz > /tmp/javy-plugin.wasm
197-
echo '9a1f5310999888e39b7712e43384291cdc8cf84eaa409faf5338959d901ecacc /tmp/javy-plugin.wasm' | sha256sum -c -
198-
cmp /tmp/javy-plugin.wasm priv/javy_plugin.wasm
286+
bash native/javex_plugin/build.sh
199287
```
200288

201289
Provider changes require compatibility tests and recompilation of dynamic
202290
modules. Custom providers must be trusted, initialized Javy providers supporting
203291
the same `cabi_realloc` / `compile-src` / `invoke` ABI; the namespace alone does
204-
not establish compatibility or trust.
292+
not establish compatibility or trust. Providers without Javex's resumable ABI
293+
retain synchronous execution and do not provide `Javex.Host`.
205294

206295
## Development and verification
207296

‎lib/javex.ex‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ defmodule Javex do
2828

2929
@type input :: map() | list() | binary() | number() | boolean() | nil
3030
@type output :: map() | list() | binary() | number() | boolean() | nil
31+
@type json ::
32+
%{optional(String.t()) => json()} | [json()] | String.t() | number() | boolean() | nil
33+
@type callback ::
34+
(json(), %{deadline: integer()} ->
35+
{:ok, json()} | {:error, %{required(String.t()) => String.t()}})
3136

3237
@doc """
3338
Compile a JavaScript source string into a `Javex.Module`.
@@ -90,26 +95,76 @@ defmodule Javex do
9095
* `:max_stderr` - diagnostic byte cap. Default 64 KiB.
9196
* `:max_module` - input Wasm byte cap. Default 8 MiB.
9297
* `:env` - explicit WASI environment; host environment is never inherited.
98+
* `:callbacks` - per-run map of UTF-8 names to arity-2 Elixir functions.
99+
Defaults to `%{}` (no capabilities). See "Host callbacks" below.
100+
* `:max_callback_calls` - total callback attempts. Default 32; maximum 1024.
101+
Zero denies all attempts.
102+
* `:max_callback_concurrency` - outstanding JS host calls. Default 4;
103+
maximum 1024. Exceeding it fails rather than queueing unbounded work.
104+
* `:callback_timeout` - milliseconds per callback, including encoding its
105+
result. Default 1_000; also bounded by the remaining run deadline.
106+
* `:max_callback_input`, `:max_callback_output` - encoded JSON bytes per
107+
argument and result/error, separate from stdin/stdout. Defaults 64 KiB
108+
and 256 KiB; maximum 8 MiB each.
93109
94110
Limits must be finite non-negative integers; memory, input, module and timeout
95111
must be positive. Invalid options return an error. Saturated runtimes return
96112
`:busy`. Caller death cancels execution. These options are controlled by trusted
97113
host code, not by the JS author.
98114
99-
The bundled JS environment supports synchronous `Javy.IO` and text encoding;
100-
it does not provide Node, Deno, fetch, timers, external module loading, or a
101-
Promise event loop. No host filesystem is preopened. Clocks and randomness
102-
are not deterministic. `max_memory` is not an OS process/RSS limit. For
115+
The bundled JS environment supports synchronous `Javy.IO`, text encoding,
116+
Promises and top-level `await`; it does not provide Node, Deno, fetch, timers,
117+
or external module loading. No host filesystem is preopened. Runtime clocks
118+
are not deterministic; snapshot-seeded `Math.random` can repeat across instances
119+
and is not cryptographically secure. `max_memory` is not an OS process/RSS limit. For
103120
untrusted code, start a Linux runtime with `backend: :isolated` and a finite
104121
`max_process_memory`: native parsing, compilation and execution then run in
105122
disposable, killable OS-restricted workers. Also pass that runtime to
106123
`compile/2`; standalone compilation remains in-process. Host-side JSON
107124
encoding/decoding is outside the worker's address-space limit.
108125
126+
## Host callbacks
127+
128+
JavaScript calls `await Javex.Host.call("name", argument)`. `Promise.all`
129+
runs independent callbacks concurrently on both backends. Registration grants
130+
only that name for this run; callbacks are never inherited from other runs.
131+
Names are non-empty strings of at most 128 UTF-8 bytes, with at most 128 grants.
132+
133+
A callback receives one decoded JSON value and `%{deadline: deadline}`, an
134+
absolute `System.monotonic_time(:millisecond)` deadline. It returns
135+
`{:ok, json}` or `{:error, %{"code" => code, "message" => safe_message}}`.
136+
Objects must have string keys; structs, tuples, atoms other than booleans/nil,
137+
and non-JSON values are rejected. Use strings for integers outside JavaScript's
138+
safe-integer range, and an explicit base64 convention for binary data.
139+
140+
Declared errors reject the JS Promise with an Error named `HostError` and a
141+
`code` property. Unknown names reject with `capability_denied`; crashes and
142+
invalid returns reject with sanitized `callback_failed` errors. A rejection
143+
propagated to the root module fails the run with `:js_error`. Callback limits
144+
and callback timeouts terminate the run with `:callback_limit` and
145+
`:callback_timeout`, even if JS catches an exception. The overall deadline
146+
remains `:timeout`.
147+
148+
Await all work that must finish: execution ends when the root module evaluation
149+
settles, and cancels remaining callbacks before returning. Caller death and
150+
runtime shutdown cancel callbacks too. Callback code runs in monitored Elixir
151+
processes, outside the Wasm/process memory limits. Host code must bound HTTP
152+
bodies while reading, propagate deadlines to its HTTP client, and cancel any
153+
separately owned work. Cancellation cannot undo external side effects.
154+
155+
HTTP clients, credentials and destination/method policy belong in callbacks,
156+
not the guest or Javex core. Never grant an unrestricted HTTP proxy to untrusted
157+
JS. Custom providers need the Javex resumable ABI to expose `Javex.Host`;
158+
legacy providers retain their existing synchronous execution behavior.
159+
109160
## Examples
110161
111162
{:ok, %{"sum" => 3}} = Javex.run(mod, %{a: 1, b: 2})
112163
{:ok, bytes} = Javex.run(mod, "raw in", encoding: :raw)
164+
165+
Javex.run(mod, nil,
166+
callbacks: %{"http.request" => &MyApp.HTTP.request/2}
167+
)
113168
"""
114169
@spec run(Module.t(), input(), keyword()) :: {:ok, output()} | {:error, term()}
115170
def run(%Module{} = mod, input, opts \\ []) do

0 commit comments

Comments
 (0)