爆米花:在 WASM 中运行 Elixir
Popcorn: Run Elixir in WASM

原始链接: https://popcorn.swmansion.com/

Popcorn是一个库,它利用AtomVM运行时编译成WASM,从而使Elixir代码能够在Web浏览器中执行。它通过消息传递和从Elixir直接执行JS来促进Elixir和JavaScript之间的通信。 **入门:** 在你的`mix.exs`文件中包含Popcorn,并设置一个JS运行时和Elixir WASM入口点(一个带有非退出`start/0`函数的模块)。使用`Popcorn.Wasm.send_elixir_ready/1`通知JS Elixir初始化何时完成。 **API:** JavaScript通过`Popcorn`类与`init`、`call`、`cast`和`deinit`方法进行交互。Elixir使用`Popcorn.Wasm`模块进行通信:`send_elixir_ready`、`handle_message!`、`run_js`、`register_event_listener`和`unregister_event_listener`。 **限制:** Popcorn使用AtomVM,AtomVM对OTP的支持有限。一些NIF缺失,大整数和比特串并未完全支持。API仍在不断发展中。 **底层原理:** AtomVM通过Emscripten编译,并加载到iframe中以实现隔离。Popcorn修补现有的Erlang/Elixir beam文件。JS调用和投递使用JSON序列化来进行结构化数据交换。

Hacker News 最新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 Popcorn:在 WASM 中运行 Elixir (swmansion.com) 7 分,作者 clessg,2 小时前 | 隐藏 | 过去 | 收藏 | 讨论 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系我们 搜索:

原文

Popcorn is a library that enables execution of Elixir code within web browsers.

Compiled Elixir code is executed in the client-side AtomVM runtime. Popcorn offers APIs for interactions between Elixir and JavaScript, handling serialization and communication, as well as ensuring browser responsiveness.

We prepared three live examples using Popcorn, check them out!
You will find Popcorn API in "API" section and read how it all works in "Under the hood" section.

Popcorn in action

REPL example
A simple Elixir REPL, compiling code dynamically in WASM.
Hexdocs
Elixir docs "Getting started" guide with interactive snippets.
Game of life
Game of life, representing every cell as a process.

Getting started

Note

This library is work in progress. API is unstable and some things don't work. You can read more in "Limitations" section.

Popcorn connects your JS and Elixir code by sending messages and directly executing JS from Elixir. To do that, you need to setup both JS and Elixir.

Add Popcorn as a dependency in your mix.exs{:popcorn, "~> 0.1"} and run mix deps.get. After that, setup JS and Elixir WASM entrypoint.

JS

First, generate a directory that will host Popcorn JS library, WASM, and generated app bundle. To do that, run:

$ mix popcorn.build_runtime --target wasm --out-dir static/wasm

Next, in your main html you need to include the library and code that sets up communication channels with Elixir. Add those scripts at the end of the body element in HTML.

HTML snippet
# static/index.html
<script type="module" src="wasm/popcorn.js" defer></script>
<script type="module" defer>
    import { Popcorn } from "./wasm/popcorn.js";
    const popcorn = await Popcorn.init({
        onStdout: console.log,
        onStderr: console.error,
    });
</script>

WASM Entrypoint

A WASM entrypoint is any Elixir module with start/0 function that never exits. If you are using supervision tree, you can write it as follows:

Entrypoint snippet
# lib/app/application.ex
defmodule App.Application do
    use Application
    alias Popcorn.Wasm

    @receiver_name :main

    # entrypoint
    def start do
        {:ok, _pid} = start(:normal, [])
        Wasm.send_elixir_ready(default_receiver: @receiver_name)
        Process.sleep(:infinity)
    end

    @impl true
    def start(_type, _args) do
        # Create default receiver process and register it under `@receiver_name`
        # ...
    end
end

After we finish initializing Elixir (setting up supervision trees, etc), we notify JS side by calling Wasm.send_elixir_ready/1. For convenience, we also pass name of the default receiver process. JS will send messages to it if no other process name is specified.

We need to set entrypoint name in the config:

Config snippet
# config/config.ex
config :popcorn, start_module: App.Application

At this point, your application is ready to exchange messages between JS and Elixir. Next, we will implement Elixir GenServer that will process JS messages and interact with DOM.

Elixir receiver process

This is a process that will receive messages originating from JS. See the "API" section for details on how to receive messages to JS and how to call JS code.

API

JS

Main component is the Popcorn class that manages the WASM module and sends messages to it.

To create an instance, use Popcorn.init(options) static method. Options:

    • onStdout ((text: string) => void) – a function that receives any text from standard output. Defaults to no-op function.
    • onStderr ((text: string) => void) – a function that receives any text from standard error. Defaults to no-op function.
    • container (DOMElement) – a DOM element that iframe should be mounted at. Read more in "Under the hood" section. Defaults to document.body.
    • bundlePath (string) – a path to the compiled Elixir code bundle. Defaults to static/wasm/app.avm.
    • heartbeatTimeoutMs (number) – a time limit set for iframe to send heartbeat message. Read more in "Under the hood" section. Defaults to 15s.
    • debug (boolean) – an option to enable internal logs used to debug the library. Defaults to false.

Methods used to interact with Elixir from JS:

  • async call(args, options) – takes a serializable value in JS, sends a message to registered Elixir process, and waits for Elixir code to settle the promise. Options:
    • process (string) – name of the process that will receive the message. Defaults to the process name set in Wasm.send_elixir_ready/1 call.
    • timeoutMs (number) – a time limit set for Elixir to settle the promise. After that time promise is automatically rejected. Defaults to 5s.
  • cast(args, options) – takes a serializable value in JS and sends a message to registered Elixir process. Options:
    • process (string) – name of the process that will receive the message. Defaults to the process name set in Wasm.send_elixir_ready/1 call.

To destroy an instance, use popcorn.deinit() method.

Elixir

Main component is the Popcorn.Wasm module that handles communication with JS.

  • send_elixir_ready(opts) – a function that notifies JS that Elixir finished initialization. Opts:
    • default_receiver (string or atom) – sets the default receiver for JS calls and casts. Optional.
  • is_wasm_message(raw_message) – a guard that returns true if argument is a raw message received from JS.
  • handle_message!(raw_message, handler) – parses raw message received from JS and dispatches it to handler.

    For :wasm_call, handler should return {promise_status, promise_value, result} tuple, where:

    • promise_status is either :resolve or :reject,
    • promise_value is any serializable value that JS should receive in response,
    • result is any value passed back to the caller.

    Popcorn resolves the JS promise with it, finishing the call.

    For :wasm_cast message, it should return only result.

  • run_js(js_function, opts) – Executes JS function in the iframe context and returns a map containing reference to JS object (RemoteObject struct).

    The JS function takes an object and returns any value. The object contains:

    • bindings – an object with serializable values passed from Elixir in bindings option.
    • window – a JS window bound to main browser context. Used for DOM manipulation.

    Value returned from JS function will be returned to Elixir in form of RemoteObject. If returned value is serializable, it can be retrieved in Elixir by using return option described below. Opts:

    • bindings – a map of serializable Elixir values that will be passed to JS function. Defaults to %{}.
    • return (list) – if :value is included in the list, run_js/2 will additionally include serializable JS value in returned map. Defaults to [:ref].
  • register_event_listener(event_name, opts) – registers event listener for event_name events (e.g. "click"). Opts:
    • selector (string) – a selector for DOM element that listener will attach to.
    • target (atom or string) – a name of the process that will receive the events.
    • event_keys (list) – a list containing atom names of event object. The specified keys will be included in the message.
  • unregister_event_listener(ref) – unregisters event listener referenced by ref.
  • parse_message!(raw_message) – a low level function that parses JS message.
  • resolve(term, promise) – a low level function that resolves JS promise with serializable term.
  • resolve(term, promise) – a low level function that rejects JS promise with serializable term.

Limitations

We rely on AtomVM for running the compiled beams. It's a runtime designed for microcontrollers and it doesn't entire OTP. Most notably, some natively implemented functions (NIFs) from OTP standard library are missing. We provide patches, reimplementing some in Erlang and work on adding important NIFs directly to AtomVM. Nevertheless, some modules (e.g. :timer, full :ets selects – core Elixir code depend on them) won't work just yet.

Aside of parts of standard library, AtomVM doesn't support big integers and bitstring well. There's ongoing work to support both of those.

Popcorn provides set of functions that work with JS. Not all values can be sent to either JS or Elixir. Working with those values is based on passing opaque references to them.

API is not stabilized yet but we mostly want to keep the current form for JS and slightly improve developer experience for Elixir parts.

Under the hood

Overall architecture

To run Elixir on the web, you need to compile Erlang/Elixir runtime to WASM and load the compiled Elixir bytecode. We use AtomVM runtime. It is compiled via Emscripten and loaded in iframe to isolate main window context from crashes and freezes. The runtime then loads user's code bundle with .avm extension. The bundle is a file consisting of concatenated .beam files.

This flow guides the architecture – main window creates an iframe and communicates with it via postMessage(). Script in the iframe loads WASM module and code bundle. The WASM module initializes the runtime on multiple webworkers. Main window sets up the timeouts which trigger if call() takes too long or if iframe doesn't respond in time (most likely crashed or got stuck on long computation).

When initializing WASM module, the script in iframe also waits for a message from Elixir. This ensures we can't send messages to Elixir before we can process them.

Patching

In order to use Elixir and Erlang standard library, we use custom patching mechanism. It takes .beams from known version of Erlang and Elixir, optionally patching them with our changes. This allows for overriding behavior (working around missing functionality in AtomVM) and adding modules such as :emscripten to standard library. This mechanism is currently not exposed to end users.

Elixir and JS communication

JS calls and casts are extensions for WASM platform in AtomVM. Both allows sending messages with string or number data to named processes. call() additionally creates a promise that Elixir code needs to resolve to complete the request.

Popcorn builds on this mechanism to allow sending any structured data. We use JSON as serialization strategy.

For Elixir communication with JS, we use Emscripten API to make a JS call in the iframe JS context. Any scheduler on worker thread can queue a JS call to be executed on main browser thread. We expose a function that takes JS function as a string and return any value. This value is persisted in global map in JS under unique key and function returns a reference to the key. If Elixir loses this reference, the value is removed from the JS map.

If value returned from JS function is serializable, you can use return: :value option to send the value back to the Elixir.

About

Popcorn is created by Software Mansion.

Since 2012 Software Mansion is a software agency with experience in building web and mobile apps as well as complex multimedia solutions. We are Core React Native Contributors and experts in live streaming and broadcasting technologies. We can help you build your next dream product – Hire us.

Copyright 2025, Software Mansion

Licensed under the Apache License, Version 2.0.

联系我们 contact @ memedata.com