From b0d3ed046199de56058038241e13d98be45ad13f Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Tue, 20 Oct 2026 22:38:09 +0300 Subject: [PATCH 01/12] feat: add Elixir (Wasm) with persistent AtomVM runtime Run Elixir in a worker owned by the result window so LiveReload edits reuse the booted AtomVM instead of recreating the runtime. Coalesce rapid runs, supersede blocked evaluations, recover from VM crashes, and expose tagged stdout/stderr/value/error results. Add the language spec, starter template, isolation headers, Playwright lifecycle coverage, runtime benchmarks, and documentation for the pinned Elixir 1.17.3/AtomVM runtime and its limitations. Refs #1060 --- .../language-support/references/languages.md | 1 + README.md | 2 +- docs/docs/languages/elixir-wasm.mdx | 150 +++ docs/src/components/LanguageSliders.tsx | 1 + docs/src/components/TemplateList.tsx | 1 + e2e/specs/elixir-wasm.spec.ts | 411 ++++++++ functions/vendors/templates.js | 1 + package.json | 4 +- scripts/app-headers.js | 19 + scripts/build.js | 1 + scripts/sandbox-headers.js | 16 + scripts/serve-app.js | 14 + scripts/serve-sandbox.js | 16 + server/php/inc/starter-templates.json | 1 + src/_headers | 5 + src/livecodes/UI/command-menu-actions.ts | 1 + src/livecodes/assets/templates/elixir.svg | 4 + src/livecodes/html/language-info.html | 30 + .../locales/ar/language-info.lokalise.json | 9 + .../i18n/locales/ar/language-info.ts | 5 + .../i18n/locales/ar/translation.lokalise.json | 3 + src/livecodes/i18n/locales/ar/translation.ts | 1 + .../locales/bn/language-info.lokalise.json | 9 + .../i18n/locales/bn/language-info.ts | 5 + .../i18n/locales/bn/translation.lokalise.json | 3 + src/livecodes/i18n/locales/bn/translation.ts | 1 + .../locales/de/language-info.lokalise.json | 9 + .../i18n/locales/de/language-info.ts | 5 + .../i18n/locales/de/translation.lokalise.json | 3 + src/livecodes/i18n/locales/de/translation.ts | 1 + .../locales/en/language-info.lokalise.json | 12 + .../i18n/locales/en/language-info.ts | 5 + .../i18n/locales/en/translation.lokalise.json | 4 + src/livecodes/i18n/locales/en/translation.ts | 1 + .../locales/es/language-info.lokalise.json | 9 + .../i18n/locales/es/language-info.ts | 5 + .../i18n/locales/es/translation.lokalise.json | 3 + src/livecodes/i18n/locales/es/translation.ts | 1 + .../locales/fa/language-info.lokalise.json | 9 + .../i18n/locales/fa/language-info.ts | 5 + .../i18n/locales/fa/translation.lokalise.json | 3 + src/livecodes/i18n/locales/fa/translation.ts | 1 + .../locales/fr/language-info.lokalise.json | 9 + .../i18n/locales/fr/language-info.ts | 5 + .../i18n/locales/fr/translation.lokalise.json | 3 + src/livecodes/i18n/locales/fr/translation.ts | 1 + .../locales/hi/language-info.lokalise.json | 9 + .../i18n/locales/hi/language-info.ts | 5 + .../i18n/locales/hi/translation.lokalise.json | 3 + src/livecodes/i18n/locales/hi/translation.ts | 1 + .../locales/hu/language-info.lokalise.json | 9 + .../i18n/locales/hu/language-info.ts | 5 + .../i18n/locales/hu/translation.lokalise.json | 3 + src/livecodes/i18n/locales/hu/translation.ts | 1 + .../locales/id/language-info.lokalise.json | 9 + .../i18n/locales/id/language-info.ts | 5 + .../i18n/locales/id/translation.lokalise.json | 3 + src/livecodes/i18n/locales/id/translation.ts | 1 + .../locales/it/language-info.lokalise.json | 9 + .../i18n/locales/it/language-info.ts | 5 + .../i18n/locales/it/translation.lokalise.json | 3 + src/livecodes/i18n/locales/it/translation.ts | 1 + .../locales/ja/language-info.lokalise.json | 9 + .../i18n/locales/ja/language-info.ts | 5 + .../i18n/locales/ja/translation.lokalise.json | 3 + src/livecodes/i18n/locales/ja/translation.ts | 1 + .../locales/nl/language-info.lokalise.json | 9 + .../i18n/locales/nl/language-info.ts | 5 + .../i18n/locales/nl/translation.lokalise.json | 3 + src/livecodes/i18n/locales/nl/translation.ts | 1 + .../locales/pt/language-info.lokalise.json | 9 + .../i18n/locales/pt/language-info.ts | 5 + .../i18n/locales/pt/translation.lokalise.json | 3 + src/livecodes/i18n/locales/pt/translation.ts | 1 + .../locales/ru/language-info.lokalise.json | 9 + .../i18n/locales/ru/language-info.ts | 5 + .../i18n/locales/ru/translation.lokalise.json | 3 + src/livecodes/i18n/locales/ru/translation.ts | 1 + .../locales/tr/language-info.lokalise.json | 9 + .../i18n/locales/tr/language-info.ts | 5 + .../i18n/locales/tr/translation.lokalise.json | 3 + src/livecodes/i18n/locales/tr/translation.ts | 1 + .../locales/ur/language-info.lokalise.json | 9 + .../i18n/locales/ur/language-info.ts | 5 + .../i18n/locales/ur/translation.lokalise.json | 3 + src/livecodes/i18n/locales/ur/translation.ts | 1 + .../locales/zh-CN/language-info.lokalise.json | 9 + .../i18n/locales/zh-CN/language-info.ts | 5 + .../locales/zh-CN/translation.lokalise.json | 3 + .../i18n/locales/zh-CN/translation.ts | 1 + .../__tests__/lang-elixir-wasm.spec.ts | 68 ++ .../languages/elixir-wasm/benchmark.md | 107 ++ src/livecodes/languages/elixir-wasm/index.ts | 1 + .../elixir-wasm/lang-elixir-wasm-script.ts | 931 ++++++++++++++++++ .../lang-elixir-wasm-worker.raw.js | 317 ++++++ .../languages/elixir-wasm/lang-elixir-wasm.ts | 29 + src/livecodes/languages/languages.ts | 2 + src/livecodes/models.ts | 1 + .../templates/starter/elixir-wasm-starter.ts | 86 ++ src/livecodes/templates/starter/index.ts | 2 + src/livecodes/vendors.ts | 4 + src/netlify.toml | 8 + src/sdk/__tests__/createPlayground.spec.ts | 1 + src/sdk/internal.ts | 2 + src/sdk/models.ts | 7 + storybook/_stories/EmbedOptions/template.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + tsconfig.json | 3 +- vendor-licenses.md | 6 + 114 files changed, 2584 insertions(+), 4 deletions(-) create mode 100644 docs/docs/languages/elixir-wasm.mdx create mode 100644 e2e/specs/elixir-wasm.spec.ts create mode 100644 scripts/app-headers.js create mode 100644 scripts/sandbox-headers.js create mode 100644 scripts/serve-app.js create mode 100644 scripts/serve-sandbox.js create mode 100644 src/livecodes/assets/templates/elixir.svg create mode 100644 src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts create mode 100644 src/livecodes/languages/elixir-wasm/benchmark.md create mode 100644 src/livecodes/languages/elixir-wasm/index.ts create mode 100644 src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts create mode 100644 src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js create mode 100644 src/livecodes/languages/elixir-wasm/lang-elixir-wasm.ts create mode 100644 src/livecodes/templates/starter/elixir-wasm-starter.ts diff --git a/.agents/skills/livecodes/language-support/references/languages.md b/.agents/skills/livecodes/language-support/references/languages.md index aaa73e2bbb..67ecd2b51f 100644 --- a/.agents/skills/livecodes/language-support/references/languages.md +++ b/.agents/skills/livecodes/language-support/references/languages.md @@ -154,6 +154,7 @@ Enabled via `processors` config: | Zig (Wasm) | `zig-wasm`, `zig` | Zig WASM | | V (Wasm) | `v-wasm`, `v`, `vlang`, `wasm.v`, `vsh` | V compiler in WASM | | Janet (Wasm) | `janet-wasm`, `janet`, `wasm.janet` | Janet interpreter (WASM) | +| Elixir (Wasm) | `elixir-wasm`, `ex`, `exs`, `elixir`, `wasm.ex` | Elixir compiler in WASM | | Tcl | `tcl` | Tcl interpreter | | WebAssembly | `wat`, `wast`, `wasm`, `webassembly` | WAT format | | Assembly (Wasm) | `assembly-wasm`, `assembly`, `asm`, `asm-wasm`, `x86`, `x86-64`, `nasm`, `wasm.asm` | Keystone + Unicorn (WASM), x86-64 assembly | diff --git a/README.md b/README.md index 43da972dd3..d1e9a9b460 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A [feature-rich](https://livecodes.io/docs/features/), open-source, **client-sid [![LiveCodes: npm version](https://img.shields.io/npm/v/livecodes)](https://www.npmjs.com/package/livecodes) [![LiveCodes: npm downloads](https://img.shields.io/npm/dm/livecodes)](https://www.npmjs.com/package/livecodes) [![LiveCodes: jsdelivr downloads](https://data.jsdelivr.com/v1/package/npm/livecodes/badge?style=rounded)](https://www.jsdelivr.com/package/npm/livecodes) -[![LiveCodes: languages](https://img.shields.io/badge/languages-125-blue)](https://livecodes.io/docs/languages/) +[![LiveCodes: languages](https://img.shields.io/badge/languages-126-blue)](https://livecodes.io/docs/languages/) [![LiveCodes: docs](https://img.shields.io/badge/Documentation-575757?logo=gitbook&logoColor=white)](https://livecodes.io/docs/) [![LiveCodes: llms.txt](https://img.shields.io/badge/llms.txt-575757?logo=googledocs&logoColor=white)](https://livecodes.io/docs/llms.txt) [![LiveCodes: llms-full.txt](https://img.shields.io/badge/llms--full.txt-575757?logo=googledocs&logoColor=white)](https://livecodes.io/docs/llms-full.txt) diff --git a/docs/docs/languages/elixir-wasm.mdx b/docs/docs/languages/elixir-wasm.mdx new file mode 100644 index 0000000000..626600c1ff --- /dev/null +++ b/docs/docs/languages/elixir-wasm.mdx @@ -0,0 +1,150 @@ +# Elixir (Wasm) + +Elixir is a dynamic, functional language for building scalable and maintainable applications, powered by the Erlang VM. + +In LiveCodes, Elixir runs in the browser using WebAssembly: the Elixir compiler and standard library execute on [AtomVM](https://atomvm.org/), a lightweight Erlang VM with a WebAssembly target. No server is involved. + +## Usage + +Demo: + +import LiveCodes from '../../src/components/LiveCodes.tsx'; + +export const elixirConfig = { + activeEditor: 'script', + script: { + language: 'elixir-wasm', + content: `IO.puts("Hello, Elixir!")`, + }, + mode: 'simple', + editor: 'auto', + tools: { + status: 'full', + }, +}; + + + +### Communication with JavaScript + +The program runs in the context of the result page. A few helpers are available in the browser global +`livecodes.elixir` object: + +- `livecodes.elixir.input`: the standard input passed to the program. +- `livecodes.elixir.loaded`: a promise that resolves after the runtime has downloaded and the first + program run has completed. It rejects if the environment fails to load. Other helpers should be + used after it resolves. +- `livecodes.elixir.output`: the program's standard output. +- `livecodes.elixir.value`: the inspected return value of the program. +- `livecodes.elixir.warnings`: warnings emitted while compiling or running the program. +- `livecodes.elixir.error`: the syntax or runtime error, if the program failed. +- `livecodes.elixir.exitCode`: the exit code of the program. +- `livecodes.elixir.run`: runs the current code with new input, reusing the booted VM, and returns a + promise that resolves with `output`, `value`, `warnings`, `error` and `exitCode`. +- `livecodes.elixir.retry`: restarts the runtime after a failed initialization. A VM crash in a + normal run is recovered automatically on the next run. + +Example: + + + +```elixir +IO.puts("Hello, Elixir!") + +squares = Enum.map(1..5, &(&1 * &1)) +IO.inspect(squares, label: "squares") +``` + +### Runtime lifecycle + +The VM runs in a persistent worker owned by the result window. Updating the code +rewrites the result document but does not destroy the worker, so normal edits +reuse the booted VM instead of downloading and initializing it again. + +Each worker boot compiles a small Elixir prelude. That takes a few seconds on +the first run; subsequent runs only evaluate the user code. Evaluations time out +after 60 seconds. If a newer run is waiting and the active evaluation does not +finish within 10 seconds, the worker is terminated and recreated so the newer +run is not blocked. Pending edits are coalesced to the latest one. A VM crash is +reported in `error`; the next run starts a fresh worker automatically. Use +`retry()` when initialization itself failed. + +## Language Info + +### Name + +`elixir-wasm` + +### Aliases / Extensions + +`ex`, `exs`, `elixir`, `elixir-wasm`, `wasm.ex` + +### Editor + +`script` + +## Compiler + +The Elixir toolchain compiled to WebAssembly, running on +[AtomVM](https://github.com/atomvm/AtomVM) with the +[Popcorn](https://popcorn.swmansion.com/) browser runtime. The assets are pinned to +`live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8`. + +### Version + +Elixir 1.17.3 on OTP 27, with AtomVM 0.7.0-dev (32-bit). + +### Limitations + +AtomVM implements a subset of the Erlang VM and its standard library. Features that depend on a full +operating system (for example file I/O and OS processes) may be missing or behave differently. +Examples include `File.write!/2` (`not_implemented`), `Date.utc_today/0`, `Task.async/await`, +`Regex.match?/2`, and the `JSON` module. `System.halt/1` terminates the VM instead of returning an +exit status; the runtime reports the crash and the next run boots a fresh worker. Frameworks that +require a server, such as Phoenix and LiveView, are out of scope for the playground. + +The runtime uses WebAssembly threads, so the result page must be cross-origin isolated +(`SharedArrayBuffer` is unavailable otherwise) and the environment never finishes loading +without it. This needs `Cross-Origin-Opener-Policy: same-origin` / +`Cross-Origin-Embedder-Policy: credentialless` headers on both the app and the sandbox +origin, plus the `cross-origin-isolated` iframe permission (which LiveCodes sets +automatically). Note that plain `same-origin` (not `same-origin-allow-popups`) is required +for isolation, which severs popup/opener links — popup-based OAuth logins need a redirect +flow instead. The dev servers and deployment configs in this repo already send these headers; +other self-hosted setups and the production sandbox deployment must send them too. Browsers +without `credentialless` support (e.g. Safari) cannot isolate and are not supported for this +language. + +## Code Formatting + +Not currently supported. + +## Performance + +On the benchmark machine, a cold start took about 9 seconds from navigation to the first result; +about 5 seconds of that is the Elixir runtime, including a 3.6 second prelude compile. After the +first run, a warm `run()` takes about 120 ms and a normal edit takes about 200 ms because the VM is +reused. Three edits made during a 3 second evaluation complete in about 3.3 seconds, with pending runs +coalesced to the latest one. See +[`src/livecodes/languages/elixir-wasm/benchmark.md`](https://github.com/live-codes/livecodes/blob/develop/src/livecodes/languages/elixir-wasm/benchmark.md) +for methodology, the complete cold-start breakdown, and the AtomVM/Popcorn comparison. + +## Live Reload + +By default, new code changes are sent to the result page for re-evaluation without a full page reload +or a VM restart. This behavior can be disabled by adding the code comment `# __livecodes_reload__` to +the Elixir code, which forces a full page reload. + +This comment can be added in the `hiddenContent` property of the editor for embedded playgrounds. + +## Starter Template + +https://livecodes.io/?template=elixir-wasm + +## Links + +- [Elixir official website](https://elixir-lang.org/) +- [Elixir documentation](https://hexdocs.pm/elixir/) +- [Learn X in Y minutes, where X=Elixir](https://learnxinyminutes.com/elixir/) +- [AtomVM](https://atomvm.org/) +- [Popcorn: Elixir and Wasm](https://popcorn.swmansion.com/) diff --git a/docs/src/components/LanguageSliders.tsx b/docs/src/components/LanguageSliders.tsx index 7b18df7932..807ba90ff6 100644 --- a/docs/src/components/LanguageSliders.tsx +++ b/docs/src/components/LanguageSliders.tsx @@ -104,6 +104,7 @@ export default function Sliders() { { name: 'dart', title: 'Dart' }, { name: 'flutter', title: 'Flutter' }, { name: 'v-wasm', title: 'V (Wasm)' }, + { name: 'elixir-wasm', title: 'Elixir (Wasm)' }, { name: 'rust-wasm', title: 'Rust (Wasm)' }, { name: 'swift-wasm', title: 'Swift (Wasm)' }, { name: 'haskell', title: 'Haskell' }, diff --git a/docs/src/components/TemplateList.tsx b/docs/src/components/TemplateList.tsx index c25520e27d..57d16b5501 100644 --- a/docs/src/components/TemplateList.tsx +++ b/docs/src/components/TemplateList.tsx @@ -58,6 +58,7 @@ const templates = [ { name: 'crystal-wasm', title: 'Crystal Starter', thumbnail: 'crystal.svg' }, { name: 'odin-wasm', title: 'Odin Starter', thumbnail: 'odin.svg' }, { name: 'v-wasm', title: 'V Starter', thumbnail: 'v.svg' }, + { name: 'elixir-wasm', title: 'Elixir Starter', thumbnail: 'elixir.svg' }, { name: 'cpp-wasm', title: 'C++ (Wasm) Starter', thumbnail: 'cpp.svg' }, { name: 'objc-wasm', title: 'Objective-C (Wasm) Starter', thumbnail: 'objective-c.svg' }, { name: 'rust-wasm', title: 'Rust (Wasm) Starter', thumbnail: 'rust.svg' }, diff --git a/e2e/specs/elixir-wasm.spec.ts b/e2e/specs/elixir-wasm.spec.ts new file mode 100644 index 0000000000..a2badaef07 --- /dev/null +++ b/e2e/specs/elixir-wasm.spec.ts @@ -0,0 +1,411 @@ +import { expect, type Frame, type Page } from '@playwright/test'; +import { test } from '../test-fixtures'; + +const APP_URL = `${process.env.TEST_URL || 'http://localhost:8080'}?template=elixir-wasm`; + +interface RunEnd { + runId?: number; + exitCode?: number; + latest?: boolean; + hasError?: boolean; + stdoutBytes?: number; + stderrBytes?: number; + empty?: boolean; +} + +interface ElixirState { + evals?: number; + runId?: number; + ready?: boolean; + failed?: boolean; + output?: string | null; + value?: string | null; + warnings?: string | null; + error?: string | null; + exitCode?: number | null; + popcornReady?: boolean; + timings: Array<{ name: string; detail?: Record }>; +} + +const getResultFrame = (page: Page): Frame | null => page.frame('result'); + +const requireResultFrame = (page: Page): Frame => { + const frame = getResultFrame(page); + if (frame == null) throw new Error('Result frame is not available'); + return frame; +}; + +const readState = async (page: Page): Promise => { + const frame = getResultFrame(page); + if (frame == null) return null; + try { + return await frame.evaluate(() => { + const elixir = (window as any).livecodes?.elixir; + if (elixir == null) return null; + return { + evals: elixir.evals, + runId: elixir.runId, + ready: elixir.ready, + failed: elixir.failed, + output: elixir.output, + value: elixir.value, + warnings: elixir.warnings, + error: elixir.error, + exitCode: elixir.exitCode, + popcornReady: elixir.popcorn?.isReady, + timings: elixir.timings ?? [], + }; + }); + } catch { + // LiveReload may be replacing the document; retry from the caller. + return null; + } +}; + +const latestRunEnd = (state: ElixirState, runId: number): RunEnd | undefined => + [...state.timings] + .reverse() + .find((timing) => timing.name === 'run-end' && timing.detail?.runId === runId)?.detail; + +const waitForState = async ( + page: Page, + predicate: (state: ElixirState) => boolean, + timeout = 120_000, +): Promise => { + const start = Date.now(); + let last: ElixirState | null = null; + while (Date.now() - start < timeout) { + last = await readState(page); + if (last != null && predicate(last)) return last; + await page.waitForTimeout(25); + } + throw new Error(`Timed out waiting for Elixir state: ${JSON.stringify(last)}`); +}; + +const waitForRun = async (page: Page, runId: number, timeout = 120_000) => + waitForState( + page, + (state) => + latestRunEnd(state, runId)?.latest === true || + state.timings.some( + (timing) => + timing.name === 'run-error' && + timing.detail?.runId === runId && + timing.detail?.latest === true, + ), + timeout, + ); + +const installApiHook = async (page: Page) => { + await page.addInitScript(() => { + window.addEventListener('livecodes-app-loaded', (event) => { + (window as any).livecodesApi = (event as CustomEvent).detail; + }); + }); +}; + +const waitForApi = async (page: Page) => { + await page.waitForFunction(() => (window as any).livecodesApi != null, undefined, { + timeout: 30_000, + }); +}; + +const setSource = async (frame: Frame, source: string) => { + await frame.evaluate((content) => { + const scripts = document.querySelectorAll('script[type="text/elixir-wasm"]'); + if (scripts.length === 0) throw new Error('No Elixir script tag found'); + scripts[scripts.length - 1].textContent = content; + }, source); +}; + +const runDirect = async (page: Page, source: string, input?: string) => { + const frame = requireResultFrame(page); + await setSource(frame, source); + const runId = await frame.evaluate((value) => { + const elixir = (window as any).livecodes.elixir; + if (value === undefined) { + void elixir.run(); + } else { + void elixir.run(value); + } + return elixir.runId; + }, input); + return waitForRun(page, runId); +}; + +const setConfigAndTrigger = async (page: Page, source: string) => { + const before = await readState(page); + await page.evaluate(async (content) => { + const api = (window as any).livecodesApi; + await api.setConfig({ script: { content } }); + await api.run(); + }, source); + return before; +}; + +const setConfigAndRun = async (page: Page, source: string) => { + const before = await setConfigAndTrigger(page, source); + return waitForState( + page, + (state) => + state.evals != null && + before?.evals != null && + state.evals > before.evals && + state.runId != null && + before?.runId != null && + state.runId > before.runId && + latestRunEnd(state, state.runId)?.latest === true, + 30_000, + ); +}; + +const boot = async (page: Page) => { + await installApiHook(page); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await waitForApi(page); + return waitForRun(page, 1, 180_000); +}; + +test.describe('Elixir (Wasm)', () => { + test.describe.configure({ timeout: 120_000 }); + + test('runs, keeps the VM alive across edits, and reports IO/errors', async ({ page }) => { + const initial = await boot(page); + expect(initial.ready).toBe(true); + expect(initial.failed).toBe(false); + expect(initial.output).toBe('Elixir\n0'); + expect(initial.value).toBe(':ok'); + expect(initial.error).toBeNull(); + + const isolation = await requireResultFrame(page).evaluate(() => ({ + isolated: window.crossOriginIsolated, + sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', + })); + expect(isolation).toEqual({ isolated: true, sharedArrayBuffer: true }); + + const warm = await requireResultFrame(page).evaluate(async () => { + const elixir = (window as any).livecodes.elixir; + const start = performance.now(); + const result = await elixir.run(); + return { ms: performance.now() - start, ...result }; + }); + expect(warm.output).toBe('Elixir\n0'); + expect(warm.ms).toBeLessThan(5_000); + + const workerCreatesBefore = initial.timings.filter( + (timing) => timing.name === 'worker-create-start', + ).length; + const edited = await setConfigAndRun(page, 'IO.puts("edited")\n:edited_value'); + expect(edited.output).toBe('edited'); + expect(edited.value).toBe(':edited_value'); + expect(edited.error).toBeNull(); + expect(edited.timings.filter((timing) => timing.name === 'worker-create-start').length).toBe( + workerCreatesBefore, + ); + + const stdout = await runDirect(page, 'IO.puts("hello")'); + expect(stdout.output).toBe('hello'); + expect(stdout.value).toBe(':ok'); + + const returnValue = await runDirect(page, '1 + 1'); + expect(returnValue.output).toBeNull(); + expect(returnValue.value).toBe('2'); + expect(returnValue.exitCode).toBe(0); + + const stdin = await runDirect(page, 'IO.puts(String.trim(IO.gets("")))', 'hello'); + expect(stdin.output).toBe('hello'); + const stdinReused = await runDirect(page, 'IO.puts(String.trim(IO.gets("")))'); + expect(stdinReused.output).toBe('hello'); + + const write = await runDirect(page, 'IO.write("abc")'); + expect(write.output).toBe('abc'); + const next = await runDirect(page, 'IO.puts("next")'); + expect(next.output).toBe('next'); + + const syntax = await runDirect(page, 'defmodule broken do'); + expect(syntax.error).toContain('SyntaxError'); + expect(syntax.exitCode).toBe(1); + expect(syntax.failed).toBe(false); + + const runtime = await runDirect(page, 'raise "boom"'); + expect(runtime.error).toBe('RuntimeError: boom'); + expect(runtime.exitCode).toBe(1); + expect(runtime.failed).toBe(false); + + const warning = await runDirect(page, 'IO.warn("careful")'); + expect(warning.error).toBeNull(); + expect(warning.exitCode).toBe(0); + expect(warning.warnings).toContain('careful'); + + const empty = await runDirect(page, ''); + expect(empty.output).toBeNull(); + expect(empty.error).toBeNull(); + expect(empty.exitCode).toBe(0); + expect(empty.ready).toBe(true); + }); + + test('coalesces rapid edits and recovers from a VM crash', async ({ page }) => { + const initial = await boot(page); + const workerCreates = initial.timings.filter( + (timing) => timing.name === 'worker-create-start', + ).length; + + const beforeSlow = await readState(page); + await page.evaluate(async () => { + const api = (window as any).livecodesApi; + await api.setConfig({ + script: { content: 'Process.sleep(3000)\nIO.puts("slow")\n:slow' }, + }); + await api.run(); + }); + const slow = await waitForState( + page, + (state) => + state.runId != null && + beforeSlow?.runId != null && + state.runId > beforeSlow.runId && + state.timings.some( + (timing) => timing.name === 'run-start' && timing.detail?.runId === state.runId, + ), + 30_000, + ); + const slowRunId = slow.runId!; + // The slow evaluation is synchronous inside the worker; give it a moment + // to enter the call before issuing edits. + await page.waitForTimeout(200); + + // Trigger three edits while the slow run is still active. Only the newest + // pending run should execute after it. + const rapidStart = Date.now(); + await page.evaluate(async () => { + const api = (window as any).livecodesApi; + for (const content of [ + 'IO.puts("one")\n:one', + 'IO.puts("two")\n:two', + 'IO.puts("three")\n:three', + ]) { + await api.setConfig({ script: { content } }); + await api.run(); + } + }); + const rapid = await waitForState( + page, + (state) => + state.output === 'three' && + state.runId != null && + state.runId > slowRunId && + latestRunEnd(state, state.runId)?.latest === true, + 30_000, + ); + expect(rapid.value).toBe(':three'); + expect(rapid.error).toBeNull(); + expect(Date.now() - rapidStart).toBeLessThan(30_000); + expect(rapid.timings.filter((timing) => timing.name === 'worker-create-start').length).toBe( + workerCreates, + ); + expect(rapid.timings.some((timing) => timing.name === 'run-superseded')).toBe(true); + + const crash = await runDirect(page, 'System.halt(1)'); + expect(crash.failed).toBe(true); + expect(crash.error).toBeTruthy(); + + const recovered = await runDirect(page, 'IO.puts("recovered")\n:recovered'); + expect(recovered.output).toBe('recovered'); + expect(recovered.value).toBe(':recovered'); + expect(recovered.error).toBeNull(); + expect(recovered.failed).toBe(false); + expect( + recovered.timings.filter((timing) => timing.name === 'worker-create-start').length, + ).toBeGreaterThan(workerCreates); + }); + + test('supersedes a hung evaluation after the timeout', async ({ page }) => { + await boot(page); + const beforeHung = await setConfigAndTrigger(page, 'receive do :never -> :ok end'); + const hung = await waitForState( + page, + (state) => + state.runId != null && + beforeHung?.runId != null && + state.runId > beforeHung.runId && + state.timings.some( + (timing) => timing.name === 'eval-start' && timing.detail?.runId === state.runId, + ), + 30_000, + ); + // The evaluation is synchronous inside the worker; let it enter the call. + await page.waitForTimeout(250); + + const start = Date.now(); + await setConfigAndTrigger(page, 'IO.puts("newer")\n:newer'); + const newer = await waitForState( + page, + (state) => + state.output === 'newer' && + state.runId != null && + state.runId > hung.runId! && + latestRunEnd(state, state.runId)?.latest === true, + 30_000, + ); + expect(newer.value).toBe(':newer'); + expect(newer.error).toBeNull(); + expect(newer.timings.some((timing) => timing.name === 'run-supersede-timeout')).toBe(true); + expect(Date.now() - start).toBeGreaterThanOrEqual(9_500); + }); + + test('reports a failed boot and retries without reloading the page', async ({ page }) => { + await page.context().route('**/AtomVM.mjs', (route) => route.abort()); + await installApiHook(page); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + + const failed = await waitForState(page, (state) => state.failed === true, 30_000); + expect(failed.error).toContain('Failed to fetch'); + + await page.context().unroute('**/AtomVM.mjs'); + await requireResultFrame(page).evaluate(async () => { + await (window as any).livecodes.elixir.retry(); + }); + const retried = await waitForState( + page, + (state) => state.failed === false && state.popcornReady === true, + 120_000, + ); + expect(retried.popcornReady).toBe(true); + + const rerun = await runDirect(page, 'IO.puts("after retry")\n:ok'); + expect(rerun.output).toBe('after retry'); + expect(rerun.error).toBeNull(); + }); + + test('serves the result frame with production cross-origin isolation headers', async ({ + page, + }) => { + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const frame = await (async () => { + const start = Date.now(); + while (Date.now() - start < 30_000) { + const result = getResultFrame(page); + if (result != null && result.url().startsWith('http')) return result; + await page.waitForTimeout(100); + } + throw new Error('Result frame did not load'); + })(); + + const response = await page.request.get(frame.url()); + const headers = response.headers(); + expect(headers['cross-origin-opener-policy']).toBe('same-origin'); + expect(headers['cross-origin-embedder-policy']).toBe('credentialless'); + expect(headers['cross-origin-resource-policy']).toBe('cross-origin'); + + const isolated = await frame.evaluate(() => window.crossOriginIsolated); + expect(isolated).toBe(true); + + // `src/_headers` is the production/static-host configuration; the dev + // server middleware sends the same values. + const { readFileSync } = await import('fs'); + const productionHeaders = readFileSync('src/_headers', 'utf8'); + expect(productionHeaders).toContain('Cross-Origin-Opener-Policy: same-origin'); + expect(productionHeaders).toContain('Cross-Origin-Embedder-Policy: credentialless'); + expect(productionHeaders).toContain('Cross-Origin-Resource-Policy: cross-origin'); + }); +}); diff --git a/functions/vendors/templates.js b/functions/vendors/templates.js index 0a621d42b5..da4d68a638 100644 --- a/functions/vendors/templates.js +++ b/functions/vendors/templates.js @@ -59,6 +59,7 @@ export const starterTemplates = { "cobol-wasm": "COBOL Starter", "fortran-wasm": "Fortran Starter", "d-wasm": "D Starter", + "elixir-wasm": "Elixir Starter", "v-wasm": "V Starter", "rust-wasm": "Rust (Wasm) Starter", "swift-wasm": "Swift (Wasm) Starter", diff --git a/package.json b/package.json index 1c5b1bdd59..b88cd09446 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "docs": "cd docs && npm run start", "storybook": "run-p storybook:*", "serve": "run-p serve:*", - "serve:app": "live-server build --watch=build/tmp/ --cors --quiet", - "serve:sandbox": "live-server src/livecodes/html/sandbox/ --port=8085 --cors --quiet --no-browser", + "serve:app": "node ./scripts/serve-app.js", + "serve:sandbox": "node ./scripts/serve-sandbox.js", "watch": "nodemon --watch src -e \"*\" --ignore src/livecodes/i18n/locale-paths.ts ./scripts/build.js --dev", "prebuild": "npm run i18n-exclude pre", "postbuild": "npm run i18n-exclude post", diff --git a/scripts/app-headers.js b/scripts/app-headers.js new file mode 100644 index 0000000000..c5b6238f56 --- /dev/null +++ b/scripts/app-headers.js @@ -0,0 +1,19 @@ +// Connect middleware for the dev app server (`npm run serve:app`). +// It sends cross-origin isolation headers so the app page is +// `crossOriginIsolated`. Embedded sandbox (result/compiler) frames can only +// be isolated when the embedding page is isolated too, and threaded Wasm +// runtimes (e.g. Elixir on AtomVM) need isolation to boot. +// +// NOTE: this must be plain `same-origin`. `same-origin-allow-popups` does NOT +// produce an isolated context in browsers (verified), so it cannot be used +// here even though it would preserve OAuth popups. `credentialless` (instead +// of `require-corp`) keeps third-party CDN resources loading without CORP. +module.exports = function appIsolationHeaders(req, res, next) { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless'); + // The app is designed to be embedded cross-origin (SDK). Without an + // explicit CORP header, COEP defaults it to same-origin and cross-origin + // embeds are blocked (net::ERR_BLOCKED_BY_RESPONSE). + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + next(); +}; diff --git a/scripts/build.js b/scripts/build.js index 0ed0c7cf3d..0ee6f5de30 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -238,6 +238,7 @@ const iifeBuild = () => 'languages/odin-wasm/lang-odin-wasm-script.ts', 'languages/cobol-wasm/lang-cobol-wasm-script.ts', 'languages/v-wasm/lang-v-wasm-script.ts', + 'languages/elixir-wasm/lang-elixir-wasm-script.ts', 'languages/go-wasm/lang-go-wasm-script.ts', 'languages/csharp-wasm/lang-csharp-wasm-script.ts', 'languages/fanak/lang-fanak-script.ts', diff --git a/scripts/sandbox-headers.js b/scripts/sandbox-headers.js new file mode 100644 index 0000000000..0facf7e094 --- /dev/null +++ b/scripts/sandbox-headers.js @@ -0,0 +1,16 @@ +// Connect middleware for the dev sandbox server (`npm run serve:sandbox`). +// It sends cross-origin isolation headers so that result/compiler pages are +// `crossOriginIsolated`, which enables `SharedArrayBuffer`. Threaded Wasm +// runtimes (e.g. Elixir on AtomVM) need it to boot. +// +// `credentialless` (instead of `require-corp`) is used so that third-party +// CDN resources without CORP headers keep loading; browsers that do not +// support it simply ignore it and behave as before. +module.exports = function sandboxIsolationHeaders(req, res, next) { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless'); + // The sandbox is meant to be embedded cross-origin (the app embeds result + // and compiler frames), so opt into that explicitly. + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + next(); +}; diff --git a/scripts/serve-app.js b/scripts/serve-app.js new file mode 100644 index 0000000000..1cadc108a9 --- /dev/null +++ b/scripts/serve-app.js @@ -0,0 +1,14 @@ +// Dev server for the app. +// Mirrors the old `live-server build ...` flags, but loads the +// isolation-headers middleware via an absolute path (live-server resolves +// relative `--middleware` paths from its own package, not the repo root). +const path = require('path'); +const liveServer = require('live-server'); + +liveServer.start({ + root: path.join(__dirname, '..', 'build'), + watch: path.join(__dirname, '..', 'build', 'tmp'), + cors: true, + quiet: true, + middleware: [path.join(__dirname, 'app-headers.js')], +}); diff --git a/scripts/serve-sandbox.js b/scripts/serve-sandbox.js new file mode 100644 index 0000000000..2e3119befd --- /dev/null +++ b/scripts/serve-sandbox.js @@ -0,0 +1,16 @@ +// Dev server for the sandbox (result/compiler pages). +// Mirrors the old `live-server ... --port=8085` flags, but loads the +// isolation-headers middleware via an absolute path (live-server resolves +// relative `--middleware` paths from its own package, not the repo root). +const path = require('path'); +const liveServer = require('live-server'); + +liveServer.start({ + root: path.join(__dirname, '..', 'src', 'livecodes', 'html', 'sandbox'), + port: 8085, + cors: true, + quiet: true, + open: false, + noBrowser: true, + middleware: [path.join(__dirname, 'sandbox-headers.js')], +}); diff --git a/server/php/inc/starter-templates.json b/server/php/inc/starter-templates.json index d1f223fbc5..3d95c8be64 100644 --- a/server/php/inc/starter-templates.json +++ b/server/php/inc/starter-templates.json @@ -59,6 +59,7 @@ "cobol-wasm": "COBOL Starter", "fortran-wasm": "Fortran Starter", "d-wasm": "D Starter", + "elixir-wasm": "Elixir Starter", "v-wasm": "V Starter", "rust-wasm": "Rust (Wasm) Starter", "swift-wasm": "Swift (Wasm) Starter", diff --git a/src/_headers b/src/_headers index d466dc3a5d..e6f8baded8 100644 --- a/src/_headers +++ b/src/_headers @@ -11,3 +11,8 @@ /livecodes/assets/* ! cache-control cache-control: public, max-age=14400, must-revalidate + +/* + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: credentialless + Cross-Origin-Resource-Policy: cross-origin diff --git a/src/livecodes/UI/command-menu-actions.ts b/src/livecodes/UI/command-menu-actions.ts index b4b99e5a48..72d4139201 100644 --- a/src/livecodes/UI/command-menu-actions.ts +++ b/src/livecodes/UI/command-menu-actions.ts @@ -315,6 +315,7 @@ export const getCommandMenuActions = ({ 'd-wasm', 'crystal-wasm', 'odin-wasm', + 'elixir-wasm', 'v-wasm', 'rust-wasm', 'swift-wasm', diff --git a/src/livecodes/assets/templates/elixir.svg b/src/livecodes/assets/templates/elixir.svg new file mode 100644 index 0000000000..9d72a096ba --- /dev/null +++ b/src/livecodes/assets/templates/elixir.svg @@ -0,0 +1,4 @@ + + + Ex + diff --git a/src/livecodes/html/language-info.html b/src/livecodes/html/language-info.html index 6bf34b882e..2d00f5b6f6 100644 --- a/src/livecodes/html/language-info.html +++ b/src/livecodes/html/language-info.html @@ -3057,6 +3057,36 @@

TypeScript

+
+

Elixir (Wasm)

+
+ Elixir compiler running on WebAssembly, using + AtomVM +
+ +

V (Wasm)

diff --git a/src/livecodes/i18n/locales/ar/language-info.lokalise.json b/src/livecodes/i18n/locales/ar/language-info.lokalise.json index 26d2173efc..67e219f1ff 100644 --- a/src/livecodes/i18n/locales/ar/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/ar/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "مترجم Elixir يعمل على WebAssembly، باستخدام AtomVM" + }, + "elixirWasm.link": { + "translation": " موقع Elixir وثائق لغة Elixir تعلم X في Y دقيقة، حيث X = Elixir وثائق LiveCodes تحميل قالب البداية " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "يتم ترجمة لغة Elm إلى JavaScript في المتصفح بواسطة إصدار WebAssembly من مُترجم Elm." }, diff --git a/src/livecodes/i18n/locales/ar/language-info.ts b/src/livecodes/i18n/locales/ar/language-info.ts index 7ef3043368..196e1f83d0 100644 --- a/src/livecodes/i18n/locales/ar/language-info.ts +++ b/src/livecodes/i18n/locales/ar/language-info.ts @@ -144,6 +144,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>الموقع الرسمي <3> <4>وثائق LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'مترجم Elixir يعمل على WebAssembly، باستخدام <1>AtomVM', + link: '<1> <2>موقع Elixir <3> <4>وثائق لغة Elixir <5> <6>تعلم X في Y دقيقة، حيث X = Elixir <7> <8>وثائق LiveCodes <9> <10>تحميل قالب البداية ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'يتم ترجمة لغة Elm إلى JavaScript في المتصفح بواسطة إصدار WebAssembly من مُترجم Elm.', link: '<1><2>الموقع الرسمي لـ Elm <3><4>دليل Elm <5> <6>وثائق LiveCodes <7> <8>تحميل قالب البداية ', diff --git a/src/livecodes/i18n/locales/ar/translation.lokalise.json b/src/livecodes/i18n/locales/ar/translation.lokalise.json index f18218349f..aa3a5d8c75 100644 --- a/src/livecodes/i18n/locales/ar/translation.lokalise.json +++ b/src/livecodes/i18n/locales/ar/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "قالب DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "قالب Elixir" + }, "templates.starter.elm": { "translation": "قالب Elm" }, diff --git a/src/livecodes/i18n/locales/ar/translation.ts b/src/livecodes/i18n/locales/ar/translation.ts index 7ff51c18b2..68e40580e9 100644 --- a/src/livecodes/i18n/locales/ar/translation.ts +++ b/src/livecodes/i18n/locales/ar/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'قالب daisyUI', dart: 'قالب Dart', diagrams: 'قالب الرسوم البيانية', + 'elixir-wasm': 'قالب Elixir', elm: 'قالب Elm', fanak: 'قالب Fanak', fennel: 'قالب Fennel', diff --git a/src/livecodes/i18n/locales/bn/language-info.lokalise.json b/src/livecodes/i18n/locales/bn/language-info.lokalise.json index 2e2cecc15c..e247a9610e 100644 --- a/src/livecodes/i18n/locales/bn/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/bn/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Elixir কম্পাইলার ওয়েবঅ্যাসেম্বলিতে চলছে, AtomVM ব্যবহার করে" + }, + "elixirWasm.link": { + "translation": " Elixir ওয়েবসাইট Elixir ল্যাঙ্গুয়েজ ডকুমেন্টেশন Y মিনিটে X শিখুন, যেখানে X=Elixir LiveCodes ডকুমেন্টেশন স্টার্টার টেমপ্লেট লোড করুন " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm কম্পাইলারের একটি WebAssembly বিল্ড দ্বারা ব্রাউজারে JavaScript-এ কম্পাইল করা হয়।" }, diff --git a/src/livecodes/i18n/locales/bn/language-info.ts b/src/livecodes/i18n/locales/bn/language-info.ts index eafd2ec097..b5142bdbd8 100644 --- a/src/livecodes/i18n/locales/bn/language-info.ts +++ b/src/livecodes/i18n/locales/bn/language-info.ts @@ -144,6 +144,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>অফিসিয়াল ওয়েবসাইট <3> <4>LiveCodes ডকুমেন্টেশন ', name: 'EJS', }, + elixirWasm: { + desc: 'Elixir কম্পাইলার ওয়েবঅ্যাসেম্বলিতে চলছে, <1>AtomVM ব্যবহার করে', + link: '<1> <2>Elixir ওয়েবসাইট <3> <4>Elixir ল্যাঙ্গুয়েজ ডকুমেন্টেশন <5> <6>Y মিনিটে X শিখুন, যেখানে X=Elixir <7> <8>LiveCodes ডকুমেন্টেশন <9> <10>স্টার্টার টেমপ্লেট লোড করুন ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm কম্পাইলারের একটি WebAssembly বিল্ড দ্বারা ব্রাউজারে JavaScript-এ কম্পাইল করা হয়।', link: '<1><2>Elm অফিসিয়াল ওয়েবসাইট <3><4>Elm গাইড <5> <6>LiveCodes ডকুমেন্টেশন <7> <8>স্টার্টার টেমপ্লেট লোড করুন ', diff --git a/src/livecodes/i18n/locales/bn/translation.lokalise.json b/src/livecodes/i18n/locales/bn/translation.lokalise.json index 304fe216c2..500c20a663 100644 --- a/src/livecodes/i18n/locales/bn/translation.lokalise.json +++ b/src/livecodes/i18n/locales/bn/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB স্টার্টার" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir স্টার্টার" + }, "templates.starter.elm": { "translation": "Elm স্টার্টার" }, diff --git a/src/livecodes/i18n/locales/bn/translation.ts b/src/livecodes/i18n/locales/bn/translation.ts index 330bd4dd8e..f06875bae0 100644 --- a/src/livecodes/i18n/locales/bn/translation.ts +++ b/src/livecodes/i18n/locales/bn/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI স্টার্টার', dart: 'Dart স্টার্টার', diagrams: 'Diagrams স্টার্টার', + 'elixir-wasm': 'Elixir স্টার্টার', elm: 'Elm স্টার্টার', fanak: 'Fanak স্টার্টার', fennel: 'Fennel স্টার্টার', diff --git a/src/livecodes/i18n/locales/de/language-info.lokalise.json b/src/livecodes/i18n/locales/de/language-info.lokalise.json index 8552ed6f9f..8055a79685 100644 --- a/src/livecodes/i18n/locales/de/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/de/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Elixir-Compiler, der auf WebAssembly läuft, unter Verwendung von AtomVM" + }, + "elixirWasm.link": { + "translation": " Elixir-Website Elixir-Sprachdokumentation Lerne X in Y Minuten, wobei X = Elixir LiveCodes-Dokumentation Starter-Elixirorlage laden " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm wird im Browser durch eine WebAssembly-Version des Elm-Compilers in JavaScript kompiliert." }, diff --git a/src/livecodes/i18n/locales/de/language-info.ts b/src/livecodes/i18n/locales/de/language-info.ts index 21621428c5..6bab7d51d9 100644 --- a/src/livecodes/i18n/locales/de/language-info.ts +++ b/src/livecodes/i18n/locales/de/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Offizielle Website <3> <4>LiveCodes Dokumentation ', name: 'EJS', }, + elixirWasm: { + desc: 'Elixir-Compiler, der auf WebAssembly läuft, unter Verwendung von <1>AtomVM', + link: '<1> <2>Elixir-Website <3> <4>Elixir-Sprachdokumentation <5> <6>Lerne X in Y Minuten, wobei X = Elixir <7> <8>LiveCodes-Dokumentation <9> <10>Starter-Elixirorlage laden ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm wird im Browser durch eine WebAssembly-Version des Elm-Compilers in JavaScript kompiliert.', link: '<1><2>Offizielle Website von Elm <3><4>Elm-Handbuch <5> <6>LiveCodes-Dokumentation <7> <8>Starter-Vorlage laden ', diff --git a/src/livecodes/i18n/locales/de/translation.lokalise.json b/src/livecodes/i18n/locales/de/translation.lokalise.json index f8162ebd18..ca0ee5eaa3 100644 --- a/src/livecodes/i18n/locales/de/translation.lokalise.json +++ b/src/livecodes/i18n/locales/de/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB-Starter" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir-Starter" + }, "templates.starter.elm": { "translation": "Elm-Starter" }, diff --git a/src/livecodes/i18n/locales/de/translation.ts b/src/livecodes/i18n/locales/de/translation.ts index c1543f3909..86b60dbec3 100644 --- a/src/livecodes/i18n/locales/de/translation.ts +++ b/src/livecodes/i18n/locales/de/translation.ts @@ -1004,6 +1004,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI-Starter', dart: 'Dart-Starter', diagrams: 'Diagramm-Starter', + 'elixir-wasm': 'Elixir-Starter', elm: 'Elm-Starter', fanak: 'Fanak-Starter', fennel: 'Fennel-Starter', diff --git a/src/livecodes/i18n/locales/en/language-info.lokalise.json b/src/livecodes/i18n/locales/en/language-info.lokalise.json index 90ca83dc2c..c9b0ee4ba9 100644 --- a/src/livecodes/i18n/locales/en/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/en/language-info.lokalise.json @@ -348,6 +348,18 @@ "notes": "", "translation": "EJS" }, + "elixirWasm.desc": { + "notes": "### ###\n\n\n", + "translation": "Elixir compiler running on WebAssembly, using AtomVM" + }, + "elixirWasm.link": { + "notes": "### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n", + "translation": " Elixir official website Elixir documentation Learn X in Y minutes, where X=Elixir LiveCodes Documentation Load starter template " + }, + "elixirWasm.name": { + "notes": "", + "translation": "Elixir (Wasm)" + }, "elm.desc": { "notes": "", "translation": "Elm is compiled to JavaScript in the browser by a WebAssembly build of the Elm compiler." diff --git a/src/livecodes/i18n/locales/en/language-info.ts b/src/livecodes/i18n/locales/en/language-info.ts index 97da69a43b..ac1a18d62c 100644 --- a/src/livecodes/i18n/locales/en/language-info.ts +++ b/src/livecodes/i18n/locales/en/language-info.ts @@ -157,6 +157,11 @@ const languageInfo = { link: '<1><2>Official website <3> <4>LiveCodes Documentations ', name: 'EJS', }, + elixirWasm: { + desc: 'Elixir compiler running on WebAssembly, using <1>AtomVM', + link: '<1> <2>Elixir official website <3> <4>Elixir documentation <5> <6>Learn X in Y minutes, where X=Elixir <7> <8>LiveCodes Documentation <9> <10>Load starter template ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm is compiled to JavaScript in the browser by a WebAssembly build of the Elm compiler.', link: '<1><2>Elm official website <3><4>Elm guide <5> <6>LiveCodes Documentation <7> <8>Load starter template ', diff --git a/src/livecodes/i18n/locales/en/translation.lokalise.json b/src/livecodes/i18n/locales/en/translation.lokalise.json index 6ea7baf234..53eed123dc 100644 --- a/src/livecodes/i18n/locales/en/translation.lokalise.json +++ b/src/livecodes/i18n/locales/en/translation.lokalise.json @@ -2656,6 +2656,10 @@ "notes": "", "translation": "DuckDB Starter" }, + "templates.starter.elixir-wasm": { + "notes": "", + "translation": "Elixir Starter" + }, "templates.starter.elm": { "notes": "", "translation": "Elm Starter" diff --git a/src/livecodes/i18n/locales/en/translation.ts b/src/livecodes/i18n/locales/en/translation.ts index adc2494775..a3542ec39a 100644 --- a/src/livecodes/i18n/locales/en/translation.ts +++ b/src/livecodes/i18n/locales/en/translation.ts @@ -1010,6 +1010,7 @@ const translation = { dart: 'Dart Starter', diagrams: 'Diagrams Starter', duckdb: 'DuckDB Starter', + 'elixir-wasm': 'Elixir Starter', elm: 'Elm Starter', fanak: 'Fanak Starter', fennel: 'Fennel Starter', diff --git a/src/livecodes/i18n/locales/es/language-info.lokalise.json b/src/livecodes/i18n/locales/es/language-info.lokalise.json index ad6b6a9fba..24fb4124d1 100644 --- a/src/livecodes/i18n/locales/es/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/es/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Compilador Elixir que se ejecuta en WebAssembly, utilizando AtomVM" + }, + "elixirWasm.link": { + "translation": " Página web de Elixir Documentación del lenguaje Elixir Aprende X en Y minutos, donde X = Elixir Documentación de LiveCodes Cargar plantilla de inicio " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm se compila a JavaScript en el navegador mediante una versión de WebAssembly del compilador de Elm." }, diff --git a/src/livecodes/i18n/locales/es/language-info.ts b/src/livecodes/i18n/locales/es/language-info.ts index 8cb9d35bf8..5dc73f0056 100644 --- a/src/livecodes/i18n/locales/es/language-info.ts +++ b/src/livecodes/i18n/locales/es/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Sitio web oficial <3> <4>Documentación de LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Compilador Elixir que se ejecuta en WebAssembly, utilizando <1>AtomVM', + link: '<1> <2>Página web de Elixir <3> <4>Documentación del lenguaje Elixir <5> <6>Aprende X en Y minutos, donde X = Elixir <7> <8>Documentación de LiveCodes <9> <10>Cargar plantilla de inicio ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm se compila a JavaScript en el navegador mediante una versión de WebAssembly del compilador de Elm.', link: '<1><2>Página web oficial de Elm <3><4>Guía de Elm <5> <6>Documentación de LiveCodes <7> <8>Cargar plantilla de inicio ', diff --git a/src/livecodes/i18n/locales/es/translation.lokalise.json b/src/livecodes/i18n/locales/es/translation.lokalise.json index 33bf4cf86e..0eaa082101 100644 --- a/src/livecodes/i18n/locales/es/translation.lokalise.json +++ b/src/livecodes/i18n/locales/es/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Inicio DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Inicio Elixir" + }, "templates.starter.elm": { "translation": "Inicio Elm" }, diff --git a/src/livecodes/i18n/locales/es/translation.ts b/src/livecodes/i18n/locales/es/translation.ts index a53f3e6c0e..6640f17463 100644 --- a/src/livecodes/i18n/locales/es/translation.ts +++ b/src/livecodes/i18n/locales/es/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'Inicio daisyUI', dart: 'Inicio Dart', diagrams: 'Inicio Diagramas', + 'elixir-wasm': 'Inicio Elixir', elm: 'Inicio Elm', fanak: 'Inicio Fanak', fennel: 'Inicio Fennel', diff --git a/src/livecodes/i18n/locales/fa/language-info.lokalise.json b/src/livecodes/i18n/locales/fa/language-info.lokalise.json index 698420cb6e..01fe944624 100644 --- a/src/livecodes/i18n/locales/fa/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/fa/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "کامپایلر Elixir که روی WebAssembly اجرا می‌شود، با استفاده از AtomVM" + }, + "elixirWasm.link": { + "translation": " وبسایت Elixir مستندات زبان Elixir زبان Elixir را در چند دقیقه بیاموزید مستندات LiveCodes بارگذاری قالب شروع " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "زبان Elm توسط یک نسخه WebAssembly از کامپایلر Elm، در مرورگر به جاوااسکریپت کامپایل می‌شود." }, diff --git a/src/livecodes/i18n/locales/fa/language-info.ts b/src/livecodes/i18n/locales/fa/language-info.ts index 96cc510d01..a36bc6631f 100644 --- a/src/livecodes/i18n/locales/fa/language-info.ts +++ b/src/livecodes/i18n/locales/fa/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>وب‌سایت رسمی <3> <4>مستندات LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'کامپایلر Elixir که روی WebAssembly اجرا می‌شود، با استفاده از <1>AtomVM', + link: '<1> <2>وبسایت Elixir <3> <4>مستندات زبان Elixir <5> <6>زبان Elixir را در چند دقیقه بیاموزید <7> <8>مستندات LiveCodes <9> <10>بارگذاری قالب شروع ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'زبان Elm توسط یک نسخه WebAssembly از کامپایلر Elm، در مرورگر به جاوااسکریپت کامپایل می‌شود.', link: '<1><2>وب‌سایت رسمی Elm <3><4>راهنمای Elm <5> <6>مستندات LiveCodes <7> <8>بارگذاری الگوی اولیه ', diff --git a/src/livecodes/i18n/locales/fa/translation.lokalise.json b/src/livecodes/i18n/locales/fa/translation.lokalise.json index d526a74288..14d1424fe2 100644 --- a/src/livecodes/i18n/locales/fa/translation.lokalise.json +++ b/src/livecodes/i18n/locales/fa/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "شروع کننده DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "شروع کننده Elixir" + }, "templates.starter.elm": { "translation": "شروع کننده Elm" }, diff --git a/src/livecodes/i18n/locales/fa/translation.ts b/src/livecodes/i18n/locales/fa/translation.ts index 08c0d6fc4d..4b4dbdfbdc 100644 --- a/src/livecodes/i18n/locales/fa/translation.ts +++ b/src/livecodes/i18n/locales/fa/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'شروع کننده daisyUI', dart: 'شروع کننده Dart', diagrams: 'شروع کننده نمودارها', + 'elixir-wasm': 'شروع کننده Elixir', elm: 'شروع کننده Elm', fanak: 'شروع کننده Fanak', fennel: 'شروع کننده Fennel', diff --git a/src/livecodes/i18n/locales/fr/language-info.lokalise.json b/src/livecodes/i18n/locales/fr/language-info.lokalise.json index c3407ec735..e0ed6d25f5 100644 --- a/src/livecodes/i18n/locales/fr/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/fr/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Compilateur Elixir fonctionnant sur WebAssembly, utilisant AtomVM" + }, + "elixirWasm.link": { + "translation": " Site web d'Elixir Documentation du langage Elixir Apprenez X en Y minutes, où X = Elixir Documentation LiveCodes Charger un modèle de démarrage " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm est compilé en JavaScript dans le navigateur grâce à une version WebAssembly du compilateur Elm." }, diff --git a/src/livecodes/i18n/locales/fr/language-info.ts b/src/livecodes/i18n/locales/fr/language-info.ts index 0188e651ef..a9db78ba3b 100644 --- a/src/livecodes/i18n/locales/fr/language-info.ts +++ b/src/livecodes/i18n/locales/fr/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Site officiel <3> <4>Documentation LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Compilateur Elixir fonctionnant sur WebAssembly, utilisant <1>AtomVM', + link: "<1> <2>Site web d'Elixir <3> <4>Documentation du langage Elixir <5> <6>Apprenez X en Y minutes, où X = Elixir <7> <8>Documentation LiveCodes <9> <10>Charger un modèle de démarrage ", + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm est compilé en JavaScript dans le navigateur grâce à une version WebAssembly du compilateur Elm.', link: "<1><2>Site officiel d'Elm <3><4>Guide d'Elm <5> <6>Documentation LiveCodes <7> <8>Charger un modèle de démarrage ", diff --git a/src/livecodes/i18n/locales/fr/translation.lokalise.json b/src/livecodes/i18n/locales/fr/translation.lokalise.json index 0de06d0a4c..563c81feae 100644 --- a/src/livecodes/i18n/locales/fr/translation.lokalise.json +++ b/src/livecodes/i18n/locales/fr/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Démarrage DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Démarrage Elixir" + }, "templates.starter.elm": { "translation": "Démarrage Elm" }, diff --git a/src/livecodes/i18n/locales/fr/translation.ts b/src/livecodes/i18n/locales/fr/translation.ts index 8bdb62ac8b..a25b4250fc 100644 --- a/src/livecodes/i18n/locales/fr/translation.ts +++ b/src/livecodes/i18n/locales/fr/translation.ts @@ -1004,6 +1004,7 @@ const translation: I18nTranslation = { daisyui: 'Démarrage daisyUI', dart: 'Démarrage Dart', diagrams: 'Démarrage Diagrammes', + 'elixir-wasm': 'Démarrage Elixir', elm: 'Démarrage Elm', fanak: 'Démarrage Fanak', fennel: 'Démarrage Fennel', diff --git a/src/livecodes/i18n/locales/hi/language-info.lokalise.json b/src/livecodes/i18n/locales/hi/language-info.lokalise.json index df92603af0..dac283ace7 100644 --- a/src/livecodes/i18n/locales/hi/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/hi/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "वेबअसेंबली पर चल रहा Elixir कंपाइलर, AtomVM का उपयोग करके" + }, + "elixirWasm.link": { + "translation": " Elixir वेबसाइट Elixir भाषा दस्तावेज़ीकरण वाई मिनटों में एक्स सीखें, जहां एक्स=Elixir लाइवकोड दस्तावेज़ीकरण स्टार्टर टेम्पलेट लोड करें " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "एल्म को ब्राउज़र में एल्म कंपाइलर के एक वेबअसेंबली (WebAssembly) बिल्ड द्वारा जावास्क्रिप्ट में कंपाइल किया जाता है।" }, diff --git a/src/livecodes/i18n/locales/hi/language-info.ts b/src/livecodes/i18n/locales/hi/language-info.ts index ea4a44ebd5..9fa9e86f55 100644 --- a/src/livecodes/i18n/locales/hi/language-info.ts +++ b/src/livecodes/i18n/locales/hi/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>आधिकारिक वेबसाइट <3> <4>LiveCodes दस्तावेज़ीकरण ', name: 'EJS', }, + elixirWasm: { + desc: 'वेबअसेंबली पर चल रहा Elixir कंपाइलर, <1>AtomVM का उपयोग करके', + link: '<1> <2>Elixir वेबसाइट <3> <4>Elixir भाषा दस्तावेज़ीकरण <5> <6>वाई मिनटों में एक्स सीखें, जहां एक्स=Elixir <7> <8>लाइवकोड दस्तावेज़ीकरण <9> <10>स्टार्टर टेम्पलेट लोड करें ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'एल्म को ब्राउज़र में एल्म कंपाइलर के एक वेबअसेंबली (WebAssembly) बिल्ड द्वारा जावास्क्रिप्ट में कंपाइल किया जाता है।', link: '<1><2>Elm आधिकारिक वेबसाइट <3><4>Elm गाइड <5> <6>LiveCodes दस्तावेज़ीकरण <7> <8>स्टार्टर टेम्पलेट लोड करें ', diff --git a/src/livecodes/i18n/locales/hi/translation.lokalise.json b/src/livecodes/i18n/locales/hi/translation.lokalise.json index 42dcf5ecfb..021422e048 100644 --- a/src/livecodes/i18n/locales/hi/translation.lokalise.json +++ b/src/livecodes/i18n/locales/hi/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB स्टार्टर" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir स्टार्टर" + }, "templates.starter.elm": { "translation": "Elm स्टार्टर" }, diff --git a/src/livecodes/i18n/locales/hi/translation.ts b/src/livecodes/i18n/locales/hi/translation.ts index 187c47afbf..b8d3f55f30 100644 --- a/src/livecodes/i18n/locales/hi/translation.ts +++ b/src/livecodes/i18n/locales/hi/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI स्टार्टर', dart: 'Dart स्टार्टर', diagrams: 'आरेख स्टार्टर', + 'elixir-wasm': 'Elixir स्टार्टर', elm: 'Elm स्टार्टर', fanak: 'Fanak स्टार्टर', fennel: 'Fennel स्टार्टर', diff --git a/src/livecodes/i18n/locales/hu/language-info.lokalise.json b/src/livecodes/i18n/locales/hu/language-info.lokalise.json index 638fb71dff..9b8a0d6683 100644 --- a/src/livecodes/i18n/locales/hu/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/hu/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Az Elixir fordító WebAssembly-en fut, a AtomVM használatával" + }, + "elixirWasm.link": { + "translation": " Az Elixir weboldala Az Elixir nyelv dokumentációja Tanulj meg X-et Y perc alatt, ahol X=Elixir LiveCodes dokumentáció Indító sablon betöltése " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Az Elm-et a böngészőben az Elm-fordító WebAssembly-verziója fordítja le JavaScript-re." }, diff --git a/src/livecodes/i18n/locales/hu/language-info.ts b/src/livecodes/i18n/locales/hu/language-info.ts index 2cae071469..bdc06d4c7e 100644 --- a/src/livecodes/i18n/locales/hu/language-info.ts +++ b/src/livecodes/i18n/locales/hu/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Hivatalos weboldal <3> <4>LiveCodes dokumentáció ', name: 'EJS', }, + elixirWasm: { + desc: 'Az Elixir fordító WebAssembly-en fut, a <1>AtomVM használatával', + link: '<1> <2>Az Elixir weboldala <3> <4>Az Elixir nyelv dokumentációja <5> <6>Tanulj meg X-et Y perc alatt, ahol X=Elixir <7> <8>LiveCodes dokumentáció <9> <10>Indító sablon betöltése ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Az Elm-et a böngészőben az Elm-fordító WebAssembly-verziója fordítja le JavaScript-re.', link: '<1><2>Az Elm hivatalos weboldala <3><4>Elm útmutató <5> <6>LiveCodes dokumentáció <7> <8>Kezdő sablon betöltése ', diff --git a/src/livecodes/i18n/locales/hu/translation.lokalise.json b/src/livecodes/i18n/locales/hu/translation.lokalise.json index ef317333c0..039dd5c10b 100644 --- a/src/livecodes/i18n/locales/hu/translation.lokalise.json +++ b/src/livecodes/i18n/locales/hu/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB kezdő" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir kezdő" + }, "templates.starter.elm": { "translation": "Elm kezdő" }, diff --git a/src/livecodes/i18n/locales/hu/translation.ts b/src/livecodes/i18n/locales/hu/translation.ts index 7a4fe5e11b..87f0ea7b39 100644 --- a/src/livecodes/i18n/locales/hu/translation.ts +++ b/src/livecodes/i18n/locales/hu/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI kezdő', dart: 'Dart kezdő', diagrams: 'Diagramok kezdő', + 'elixir-wasm': 'Elixir kezdő', elm: 'Elm kezdő', fanak: 'Fanak kezdő', fennel: 'Fennel kezdő', diff --git a/src/livecodes/i18n/locales/id/language-info.lokalise.json b/src/livecodes/i18n/locales/id/language-info.lokalise.json index d4e26d0411..cb6138fb45 100644 --- a/src/livecodes/i18n/locales/id/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/id/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Kompiler Elixir yang berjalan di WebAssembly, menggunakan AtomVM" + }, + "elixirWasm.link": { + "translation": " Situs web Elixir Dokumentasi bahasa Elixir Pelajari X dalam Y menit, di mana X = Elixir Dokumentasi LiveCodes Muat templat awal " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm dikompilasi menjadi JavaScript di dalam browser melalui versi WebAssembly dari kompiler Elm." }, diff --git a/src/livecodes/i18n/locales/id/language-info.ts b/src/livecodes/i18n/locales/id/language-info.ts index 71e6c6a451..d109b6e8d4 100644 --- a/src/livecodes/i18n/locales/id/language-info.ts +++ b/src/livecodes/i18n/locales/id/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Situs web resmi <3> <4>Dokumentasi LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Kompiler Elixir yang berjalan di WebAssembly, menggunakan <1>AtomVM', + link: '<1> <2>Situs web Elixir <3> <4>Dokumentasi bahasa Elixir <5> <6>Pelajari X dalam Y menit, di mana X = Elixir <7> <8>Dokumentasi LiveCodes <9> <10>Muat templat awal ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm dikompilasi menjadi JavaScript di dalam browser melalui versi WebAssembly dari kompiler Elm.', link: '<1><2>Situs web resmi Elm <3><4>Panduan Elm <5> <6>Dokumentasi LiveCodes <7> <8>Muat templat awal ', diff --git a/src/livecodes/i18n/locales/id/translation.lokalise.json b/src/livecodes/i18n/locales/id/translation.lokalise.json index e9b4412f57..e582bd81a7 100644 --- a/src/livecodes/i18n/locales/id/translation.lokalise.json +++ b/src/livecodes/i18n/locales/id/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Pemula DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Pemula Elixir" + }, "templates.starter.elm": { "translation": "Pemula Elm" }, diff --git a/src/livecodes/i18n/locales/id/translation.ts b/src/livecodes/i18n/locales/id/translation.ts index 389c4a34b7..673c2230f1 100644 --- a/src/livecodes/i18n/locales/id/translation.ts +++ b/src/livecodes/i18n/locales/id/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'Pemula daisyUI', dart: 'Pemula Dart', diagrams: 'Pemula Diagram', + 'elixir-wasm': 'Pemula Elixir', elm: 'Pemula Elm', fanak: 'Pemula Fanak', fennel: 'Pemula Fennel', diff --git a/src/livecodes/i18n/locales/it/language-info.lokalise.json b/src/livecodes/i18n/locales/it/language-info.lokalise.json index b3261bb671..b2cf3077a6 100644 --- a/src/livecodes/i18n/locales/it/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/it/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Compilatore Elixir in esecuzione su WebAssembly, utilizzando AtomVM" + }, + "elixirWasm.link": { + "translation": " Sito web di Elixir Documentazione sul linguaggio Elixir Impara X in Y minuti, dove X = Elixir Documentazione LiveCodes Carica il modello di avvio " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm viene compilato in JavaScript nel browser tramite una versione WebAssembly del compilatore Elm." }, diff --git a/src/livecodes/i18n/locales/it/language-info.ts b/src/livecodes/i18n/locales/it/language-info.ts index e7b6cfe1c6..d8082f917c 100644 --- a/src/livecodes/i18n/locales/it/language-info.ts +++ b/src/livecodes/i18n/locales/it/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Sito ufficiale <3> <4>Documentazione LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Compilatore Elixir in esecuzione su WebAssembly, utilizzando <1>AtomVM', + link: '<1> <2>Sito web di Elixir <3> <4>Documentazione sul linguaggio Elixir <5> <6>Impara X in Y minuti, dove X = Elixir <7> <8>Documentazione LiveCodes <9> <10>Carica il modello di avvio ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm viene compilato in JavaScript nel browser tramite una versione WebAssembly del compilatore Elm.', link: '<1><2>Sito web ufficiale di Elm <3><4>Guida a Elm <5> <6>Documentazione LiveCodes <7> <8>Carica il modello iniziale ', diff --git a/src/livecodes/i18n/locales/it/translation.lokalise.json b/src/livecodes/i18n/locales/it/translation.lokalise.json index 55c046d463..5f71c69358 100644 --- a/src/livecodes/i18n/locales/it/translation.lokalise.json +++ b/src/livecodes/i18n/locales/it/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Starter DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Starter Elixir" + }, "templates.starter.elm": { "translation": "Starter Elm" }, diff --git a/src/livecodes/i18n/locales/it/translation.ts b/src/livecodes/i18n/locales/it/translation.ts index ab3ff2108b..104205b2a9 100644 --- a/src/livecodes/i18n/locales/it/translation.ts +++ b/src/livecodes/i18n/locales/it/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'Starter daisyUI', dart: 'Starter Dart', diagrams: 'Starter Diagrammi', + 'elixir-wasm': 'Starter Elixir', elm: 'Starter Elm', fanak: 'Starter Fanak', fennel: 'Starter Fennel', diff --git a/src/livecodes/i18n/locales/ja/language-info.lokalise.json b/src/livecodes/i18n/locales/ja/language-info.lokalise.json index d019b7d5a6..9940851a29 100644 --- a/src/livecodes/i18n/locales/ja/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/ja/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "WebAssembly上で動作するElixirコンパイラ。AtomVM を使用。" + }, + "elixirWasm.link": { + "translation": " Elixirのウェブサイト Elixir言語のドキュメント 「XをY分で学ぶ」(X=Elixir) LiveCodes ドキュメント スターターテンプレートを読み込む " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elmは、ElmコンパイラのWebAssemblyビルドによって、ブラウザ上でJavaScriptにコンパイルされます。" }, diff --git a/src/livecodes/i18n/locales/ja/language-info.ts b/src/livecodes/i18n/locales/ja/language-info.ts index 6898af047f..6c542b1486 100644 --- a/src/livecodes/i18n/locales/ja/language-info.ts +++ b/src/livecodes/i18n/locales/ja/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>公式ウェブサイト <3> <4>LiveCodes ドキュメント ', name: 'EJS', }, + elixirWasm: { + desc: 'WebAssembly上で動作するElixirコンパイラ。<1>AtomVM を使用。', + link: '<1> <2>Elixirのウェブサイト <3> <4>Elixir言語のドキュメント <5> <6>「XをY分で学ぶ」(X=Elixir) <7> <8>LiveCodes ドキュメント <9> <10>スターターテンプレートを読み込む ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elmは、ElmコンパイラのWebAssemblyビルドによって、ブラウザ上でJavaScriptにコンパイルされます。', link: '<1><2>Elm公式サイト <3><4>Elmガイド <5> <6>LiveCodes ドキュメント <7> <8>スターターテンプレートの読み込み ', diff --git a/src/livecodes/i18n/locales/ja/translation.lokalise.json b/src/livecodes/i18n/locales/ja/translation.lokalise.json index 43d30d2598..35c926c91f 100644 --- a/src/livecodes/i18n/locales/ja/translation.lokalise.json +++ b/src/livecodes/i18n/locales/ja/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDBスターター" }, + "templates.starter.elixir-wasm": { + "translation": "Elixirスターター" + }, "templates.starter.elm": { "translation": "Elmスターター" }, diff --git a/src/livecodes/i18n/locales/ja/translation.ts b/src/livecodes/i18n/locales/ja/translation.ts index d0c93956f6..6cdc226212 100644 --- a/src/livecodes/i18n/locales/ja/translation.ts +++ b/src/livecodes/i18n/locales/ja/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUIスターター', dart: 'Dart スターター', diagrams: 'ダイアグラムスターター', + 'elixir-wasm': 'Elixirスターター', elm: 'Elmスターター', fanak: 'Fanakスターター', fennel: 'Fennelスターター', diff --git a/src/livecodes/i18n/locales/nl/language-info.lokalise.json b/src/livecodes/i18n/locales/nl/language-info.lokalise.json index 7b52c073af..97899ad804 100644 --- a/src/livecodes/i18n/locales/nl/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/nl/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Elixir-compiler die op WebAssembly draait, met AtomVM" + }, + "elixirWasm.link": { + "translation": " Elixir-website Documentatie over de taal Elixir Leer X in Y minuten, waarbij X = Elixir LiveCodes-documentatie Starterssjabloon laden " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm wordt in de browser naar JavaScript gecompileerd door een WebAssembly-versie van de Elm-compiler." }, diff --git a/src/livecodes/i18n/locales/nl/language-info.ts b/src/livecodes/i18n/locales/nl/language-info.ts index e698268945..bedc662bcb 100644 --- a/src/livecodes/i18n/locales/nl/language-info.ts +++ b/src/livecodes/i18n/locales/nl/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Officiële website <3> <4>LiveCodes Documentatie ', name: 'EJS', }, + elixirWasm: { + desc: 'Elixir-compiler die op WebAssembly draait, met <1>AtomVM', + link: '<1> <2>Elixir-website <3> <4>Documentatie over de taal Elixir <5> <6>Leer X in Y minuten, waarbij X = Elixir <7> <8>LiveCodes-documentatie <9> <10>Starterssjabloon laden ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm wordt in de browser naar JavaScript gecompileerd door een WebAssembly-versie van de Elm-compiler.', link: '<1><2>Officiële website van Elm <3><4>Elm-handleiding <5> <6>LiveCodes-documentatie <7> <8>Startersjabloon laden ', diff --git a/src/livecodes/i18n/locales/nl/translation.lokalise.json b/src/livecodes/i18n/locales/nl/translation.lokalise.json index 6fd6c46be0..ea15555706 100644 --- a/src/livecodes/i18n/locales/nl/translation.lokalise.json +++ b/src/livecodes/i18n/locales/nl/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB-starter" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir-starter" + }, "templates.starter.elm": { "translation": "Elm-starter" }, diff --git a/src/livecodes/i18n/locales/nl/translation.ts b/src/livecodes/i18n/locales/nl/translation.ts index 63c5ec5814..477d1e76ad 100644 --- a/src/livecodes/i18n/locales/nl/translation.ts +++ b/src/livecodes/i18n/locales/nl/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI-starter', dart: 'Dart-starter', diagrams: 'Diagrammen-starter', + 'elixir-wasm': 'Elixir-starter', elm: 'Elm-starter', fanak: 'Fanak-starter', fennel: 'Fennel-starter', diff --git a/src/livecodes/i18n/locales/pt/language-info.lokalise.json b/src/livecodes/i18n/locales/pt/language-info.lokalise.json index 762e0fe919..d0e5fdefd3 100644 --- a/src/livecodes/i18n/locales/pt/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/pt/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Compilador Elixir a funcionar no WebAssembly, usando AtomVM" + }, + "elixirWasm.link": { + "translation": " Site do Elixir Documentação da linguagem Elixir Aprenda X em Y minutos, em que X = Elixir Documentação do LiveCodes Carregar modelo inicial " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "O Elm é compilado para JavaScript no navegador por uma versão do compilador Elm para WebAssembly." }, diff --git a/src/livecodes/i18n/locales/pt/language-info.ts b/src/livecodes/i18n/locales/pt/language-info.ts index 16414ece99..e1f9609ca4 100644 --- a/src/livecodes/i18n/locales/pt/language-info.ts +++ b/src/livecodes/i18n/locales/pt/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Site oficial <3> <4>Documentação do LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Compilador Elixir a funcionar no WebAssembly, usando <1>AtomVM', + link: '<1> <2>Site do Elixir <3> <4>Documentação da linguagem Elixir <5> <6>Aprenda X em Y minutos, em que X = Elixir <7> <8>Documentação do LiveCodes <9> <10>Carregar modelo inicial ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'O Elm é compilado para JavaScript no navegador por uma versão do compilador Elm para WebAssembly.', link: '<1><2>Site oficial do Elm <3><4>Guia do Elm <5> <6>Documentação do LiveCodes <7> <8>Carregar modelo inicial ', diff --git a/src/livecodes/i18n/locales/pt/translation.lokalise.json b/src/livecodes/i18n/locales/pt/translation.lokalise.json index 6fd016435e..594ee3f339 100644 --- a/src/livecodes/i18n/locales/pt/translation.lokalise.json +++ b/src/livecodes/i18n/locales/pt/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Iniciante DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Iniciante Elixir" + }, "templates.starter.elm": { "translation": "Iniciante Elm" }, diff --git a/src/livecodes/i18n/locales/pt/translation.ts b/src/livecodes/i18n/locales/pt/translation.ts index 4af81696f4..09d595e01b 100644 --- a/src/livecodes/i18n/locales/pt/translation.ts +++ b/src/livecodes/i18n/locales/pt/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'Iniciante daisyUI', dart: 'Iniciante Dart', diagrams: 'Iniciante Diagrams', + 'elixir-wasm': 'Iniciante Elixir', elm: 'Iniciante Elm', fanak: 'Iniciante Fanak', fennel: 'Iniciante Fennel', diff --git a/src/livecodes/i18n/locales/ru/language-info.lokalise.json b/src/livecodes/i18n/locales/ru/language-info.lokalise.json index 7e12fdb87c..f6868ccb56 100644 --- a/src/livecodes/i18n/locales/ru/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/ru/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "Компилятор Elixir, работающий на WebAssembly, с использованием AtomVM" + }, + "elixirWasm.link": { + "translation": " Сайт Elixir Документация по языку Elixir Изучите X за Y минут, где X = Elixir Документация LiveCodes Загрузить стартовый шаблон " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm компилируется в JavaScript в браузере с помощью версии компилятора Elm, оптимизированной для WebAssembly." }, diff --git a/src/livecodes/i18n/locales/ru/language-info.ts b/src/livecodes/i18n/locales/ru/language-info.ts index 73a5ec60f9..9c4a7ba009 100644 --- a/src/livecodes/i18n/locales/ru/language-info.ts +++ b/src/livecodes/i18n/locales/ru/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Официальный сайт <3> <4>Документация LiveCodes ', name: 'EJS', }, + elixirWasm: { + desc: 'Компилятор Elixir, работающий на WebAssembly, с использованием <1>AtomVM', + link: '<1> <2>Сайт Elixir <3> <4>Документация по языку Elixir <5> <6>Изучите X за Y минут, где X = Elixir <7> <8>Документация LiveCodes <9> <10>Загрузить стартовый шаблон ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm компилируется в JavaScript в браузере с помощью версии компилятора Elm, оптимизированной для WebAssembly.', link: '<1><2>Официальный сайт Elm <3><4>Руководство по Elm <5> <6>Документация по LiveCodes <7> <8>Загрузить стартовый шаблон ', diff --git a/src/livecodes/i18n/locales/ru/translation.lokalise.json b/src/livecodes/i18n/locales/ru/translation.lokalise.json index b10f0e8969..c3e5e97750 100644 --- a/src/livecodes/i18n/locales/ru/translation.lokalise.json +++ b/src/livecodes/i18n/locales/ru/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "Стартер DuckDB" }, + "templates.starter.elixir-wasm": { + "translation": "Стартер Elixir" + }, "templates.starter.elm": { "translation": "Стартер Elm" }, diff --git a/src/livecodes/i18n/locales/ru/translation.ts b/src/livecodes/i18n/locales/ru/translation.ts index d9e2fa957c..62e8c457bd 100644 --- a/src/livecodes/i18n/locales/ru/translation.ts +++ b/src/livecodes/i18n/locales/ru/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'Стартер daisyUI', dart: 'Стартер Dart', diagrams: 'Стартер диаграмм', + 'elixir-wasm': 'Стартер Elixir', elm: 'Стартер Elm', fanak: 'Стартер Fanak', fennel: 'Стартер Fennel', diff --git a/src/livecodes/i18n/locales/tr/language-info.lokalise.json b/src/livecodes/i18n/locales/tr/language-info.lokalise.json index 07090356f6..c91107c8c4 100644 --- a/src/livecodes/i18n/locales/tr/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/tr/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "WebAssembly üzerinde çalışan Elixir derleyicisi, AtomVM kullanılarak." + }, + "elixirWasm.link": { + "translation": " Elixir web sitesi Elixir dil belgeleri Y dakikada X'i öğrenin, burada X=Elixir LiveCodes Belgeleri Başlangıç şablonunu yükle " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm, Elm derleyicisinin bir WebAssembly derlemesi aracılığıyla tarayıcıda JavaScript'e derlenir." }, diff --git a/src/livecodes/i18n/locales/tr/language-info.ts b/src/livecodes/i18n/locales/tr/language-info.ts index 78f8196445..931125851b 100644 --- a/src/livecodes/i18n/locales/tr/language-info.ts +++ b/src/livecodes/i18n/locales/tr/language-info.ts @@ -145,6 +145,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>Resmi web sitesi <3> <4>LiveCodes Dokümantasyonu ', name: 'EJS', }, + elixirWasm: { + desc: 'WebAssembly üzerinde çalışan Elixir derleyicisi, <1>AtomVM kullanılarak.', + link: "<1> <2>Elixir web sitesi <3> <4>Elixir dil belgeleri <5> <6>Y dakikada X'i öğrenin, burada X=Elixir <7> <8>LiveCodes Belgeleri <9> <10>Başlangıç şablonunu yükle ", + name: 'Elixir (Wasm)', + }, elm: { desc: "Elm, Elm derleyicisinin bir WebAssembly derlemesi aracılığıyla tarayıcıda JavaScript'e derlenir.", link: '<1><2>Elm resmi web sitesi <3><4>Elm kılavuzu <5> <6>LiveCodes Belgeleri <7> <8>Başlangıç şablonunu yükle ', diff --git a/src/livecodes/i18n/locales/tr/translation.lokalise.json b/src/livecodes/i18n/locales/tr/translation.lokalise.json index 936ddad183..6dc97c873f 100644 --- a/src/livecodes/i18n/locales/tr/translation.lokalise.json +++ b/src/livecodes/i18n/locales/tr/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB Başlangıç" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir Başlangıç" + }, "templates.starter.elm": { "translation": "Elm Başlangıç" }, diff --git a/src/livecodes/i18n/locales/tr/translation.ts b/src/livecodes/i18n/locales/tr/translation.ts index 59f3185932..1e955a8c6d 100644 --- a/src/livecodes/i18n/locales/tr/translation.ts +++ b/src/livecodes/i18n/locales/tr/translation.ts @@ -1003,6 +1003,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI Başlangıç', dart: 'Dart Başlangıç', diagrams: 'Diyagramlar Başlangıç', + 'elixir-wasm': 'Elixir Başlangıç', elm: 'Elm Başlangıç', fanak: 'Fanak Başlangıç', fennel: 'Fennel Başlangıç', diff --git a/src/livecodes/i18n/locales/ur/language-info.lokalise.json b/src/livecodes/i18n/locales/ur/language-info.lokalise.json index d87e16b285..c22d7a159e 100644 --- a/src/livecodes/i18n/locales/ur/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/ur/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "ای جے ایس" }, + "elixirWasm.desc": { + "translation": "Elixir کمپائلر WebAssembly پر چل رہا ہے، استعمال کر رہا ہے AtomVM" + }, + "elixirWasm.link": { + "translation": " Elixir ویب سائٹ Elixir زبان کی دستاویزات X=Elixir — Elixir کو چند منٹوں میں سیکھیں LiveCodes دستاویزات اسٹارٹر ٹیمپلیٹ لوڈ کریں " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "ایلم (Elm) کو براؤزر میں ایلم کمپائلر کے ویب اسمبلی (WebAssembly) بلڈ کے ذریعے جاوا اسکرپٹ میں کمپائل کیا جاتا ہے۔" }, diff --git a/src/livecodes/i18n/locales/ur/language-info.ts b/src/livecodes/i18n/locales/ur/language-info.ts index 1956682c82..d2282b580e 100644 --- a/src/livecodes/i18n/locales/ur/language-info.ts +++ b/src/livecodes/i18n/locales/ur/language-info.ts @@ -146,6 +146,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>سرکاری ویب سائٹ <3> <4>لائیو کوڈز دستاویزات ', name: 'ای جے ایس', }, + elixirWasm: { + desc: 'Elixir کمپائلر WebAssembly پر چل رہا ہے، استعمال کر رہا ہے <1>AtomVM', + link: '<1> <2>Elixir ویب سائٹ <3> <4>Elixir زبان کی دستاویزات <5> <6>X=Elixir — Elixir کو چند منٹوں میں سیکھیں <7> <8>LiveCodes دستاویزات <9> <10>اسٹارٹر ٹیمپلیٹ لوڈ کریں ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'ایلم (Elm) کو براؤزر میں ایلم کمپائلر کے ویب اسمبلی (WebAssembly) بلڈ کے ذریعے جاوا اسکرپٹ میں کمپائل کیا جاتا ہے۔', link: '<1><2>Elm کی سرکاری ویب سائٹ <3><4>Elm گائیڈ <5> <6>LiveCodes دستاویزی مواد <7> <8>سٹارٹر ٹیمپلیٹ لوڈ کریں ', diff --git a/src/livecodes/i18n/locales/ur/translation.lokalise.json b/src/livecodes/i18n/locales/ur/translation.lokalise.json index cb54f11c3a..6bc7409210 100644 --- a/src/livecodes/i18n/locales/ur/translation.lokalise.json +++ b/src/livecodes/i18n/locales/ur/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "ڈک ڈی بی شروعاتی" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir شروعاتی" + }, "templates.starter.elm": { "translation": "ایلم شروعاتی" }, diff --git a/src/livecodes/i18n/locales/ur/translation.ts b/src/livecodes/i18n/locales/ur/translation.ts index 024962bf34..111e787ed3 100644 --- a/src/livecodes/i18n/locales/ur/translation.ts +++ b/src/livecodes/i18n/locales/ur/translation.ts @@ -1002,6 +1002,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI شروعاتی', dart: 'Dart شروعاتی', diagrams: 'ڈایاگرامز شروعاتی', + 'elixir-wasm': 'Elixir شروعاتی', elm: 'ایلم شروعاتی', fanak: 'Fanak شروعاتی', fennel: 'فینل شروعاتی', diff --git a/src/livecodes/i18n/locales/zh-CN/language-info.lokalise.json b/src/livecodes/i18n/locales/zh-CN/language-info.lokalise.json index 0f9bc4fb8e..fc05c50eae 100644 --- a/src/livecodes/i18n/locales/zh-CN/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/zh-CN/language-info.lokalise.json @@ -261,6 +261,15 @@ "ejs.name": { "translation": "EJS" }, + "elixirWasm.desc": { + "translation": "在 WebAssembly 上运行的 Elixir 编译器,使用AtomVM" + }, + "elixirWasm.link": { + "translation": " Elixir 网站 Elixir 语言文档 Y分钟掌握X,其中X=Elixir LiveCodes 文档 加载入门模板 " + }, + "elixirWasm.name": { + "translation": "Elixir (Wasm)" + }, "elm.desc": { "translation": "Elm 通过 Elm 编译器的 WebAssembly 构建版本在浏览器中编译为 JavaScript。" }, diff --git a/src/livecodes/i18n/locales/zh-CN/language-info.ts b/src/livecodes/i18n/locales/zh-CN/language-info.ts index 85524aa7b7..90729f2c08 100644 --- a/src/livecodes/i18n/locales/zh-CN/language-info.ts +++ b/src/livecodes/i18n/locales/zh-CN/language-info.ts @@ -144,6 +144,11 @@ const languageInfo: I18nLangInfoTranslation = { link: '<1><2>官方网站 <3> <4>LiveCodes 文档 ', name: 'EJS', }, + elixirWasm: { + desc: '在 WebAssembly 上运行的 Elixir 编译器,使用<1>AtomVM', + link: '<1> <2>Elixir 网站 <3> <4>Elixir 语言文档 <5> <6>Y分钟掌握X,其中X=Elixir <7> <8>LiveCodes 文档 <9> <10>加载入门模板 ', + name: 'Elixir (Wasm)', + }, elm: { desc: 'Elm 通过 Elm 编译器的 WebAssembly 构建版本在浏览器中编译为 JavaScript。', link: '<1><2>Elm 官方网站 <3><4>Elm 指南 <5> <6>LiveCodes 文档 <7> <8>加载入门模板 ', diff --git a/src/livecodes/i18n/locales/zh-CN/translation.lokalise.json b/src/livecodes/i18n/locales/zh-CN/translation.lokalise.json index 9ded550110..0bd57bdaa7 100644 --- a/src/livecodes/i18n/locales/zh-CN/translation.lokalise.json +++ b/src/livecodes/i18n/locales/zh-CN/translation.lokalise.json @@ -1992,6 +1992,9 @@ "templates.starter.duckdb": { "translation": "DuckDB 启动模板" }, + "templates.starter.elixir-wasm": { + "translation": "Elixir 启动模板" + }, "templates.starter.elm": { "translation": "Elm 启动模板" }, diff --git a/src/livecodes/i18n/locales/zh-CN/translation.ts b/src/livecodes/i18n/locales/zh-CN/translation.ts index 1d141f8488..ff306f16a6 100644 --- a/src/livecodes/i18n/locales/zh-CN/translation.ts +++ b/src/livecodes/i18n/locales/zh-CN/translation.ts @@ -1000,6 +1000,7 @@ const translation: I18nTranslation = { daisyui: 'daisyUI 启动模板', dart: 'Dart 启动模板', diagrams: '图表启动模板', + 'elixir-wasm': 'Elixir 启动模板', elm: 'Elm 启动模板', fanak: 'Fanak 启动模板', fennel: 'Fennel 启动模板', diff --git a/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts b/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts new file mode 100644 index 0000000000..1a3430a3d4 --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts @@ -0,0 +1,68 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { elixirWasm } from '../lang-elixir-wasm'; + +const readSource = (file: string) => readFileSync(resolve(__dirname, '..', file), 'utf8'); + +describe('Elixir (Wasm) language spec', () => { + test('is registered as elixir-wasm, with the wasm compiler script', () => { + expect(elixirWasm.name).toBe('elixir-wasm'); + expect(elixirWasm.title).toBe('Elixir (Wasm)'); + expect(elixirWasm.compiler).toMatchObject({ + scriptType: 'text/elixir-wasm', + compiledCodeLanguage: 'elixir', + liveReload: true, + }); + }); + + test('accepts the common Elixir extensions', () => { + expect(elixirWasm.extensions).toEqual( + expect.arrayContaining(['ex', 'exs', 'elixir', 'elixir-wasm', 'wasm.ex']), + ); + }); + + test('warns that the first run is a large download', () => { + expect(elixirWasm.largeDownload).toBe(true); + }); + + test('uses a code editor with Elixir support', () => { + expect(elixirWasm.editor).toBe('script'); + expect(elixirWasm.editorSupport?.monaco?.language).toBe('elixir'); + expect(elixirWasm.editorSupport?.codejar?.language).toBe('elixir'); + }); +}); + +describe('Elixir (Wasm) bridge', () => { + const bridge = readSource('lang-elixir-wasm-script.ts'); + const worker = readSource('lang-elixir-wasm-worker.raw.js'); + + test('keeps the VM in a module worker instead of an iframe', () => { + expect(bridge).toContain("new Worker(url, { type: 'module' })"); + expect(bridge).toContain('worker.terminate()'); + expect(bridge).not.toContain("createElement('iframe')"); + }); + + test('uses tagged results for values, errors, and exits', () => { + expect(bridge).toContain("OK_TAG = '__LC_OK__'"); + expect(bridge).toContain("ERROR_TAG = '__LC_ERROR__'"); + expect(bridge).toContain("EXIT_TAG = '__LC_EXIT__'"); + expect(bridge).toContain('Base.encode64'); + }); + + test('bounds calls and supersedes a blocked run', () => { + expect(bridge).toContain('EVAL_TIMEOUT_MS = 60_000'); + expect(bridge).toContain('SUPERSEDE_TIMEOUT_MS = 10_000'); + expect(bridge).toContain("mark('run-supersede-timeout'"); + expect(bridge).toContain('const latest = isLatest()'); + }); + + test('boots AtomVM in a worker with the required interop shim', () => { + expect(worker).toContain("INIT: 'popcorn-init'"); + expect(worker).toContain("CALL: 'popcorn-call'"); + expect(worker).toContain('globalThis.window = globalThis'); + expect(worker).toContain('mainScriptUrlOrBlob'); + expect(worker).toContain('Module.onElixirReady'); + expect(worker).toContain('self.postMessage'); + expect(worker).not.toContain('window.parent.postMessage'); + }); +}); diff --git a/src/livecodes/languages/elixir-wasm/benchmark.md b/src/livecodes/languages/elixir-wasm/benchmark.md new file mode 100644 index 0000000000..18b656bfb4 --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/benchmark.md @@ -0,0 +1,107 @@ +# Elixir (Wasm) performance notes + +These measurements were taken while fixing the Elixir (Wasm) runtime. They are +representative numbers for this machine, not a cross-device benchmark suite. +The browser-level lifecycle and performance assertions live in +`e2e/specs/elixir-wasm.spec.ts`. + +## Environment + +- Linux, AMD Ryzen 5 4600G (6 cores / 12 threads), 13 GiB RAM. +- Google Chrome for Testing 148.0.7778.96, headless, `--enable-precise-memory-info`. +- AtomVM runtime: Elixir 1.17.3 on OTP 27, AtomVM 0.7.0-dev (32-bit), pinned to + `live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8`. +- Popcorn comparison: `@swmansion/popcorn@0.4.0-next.9` (the same version as + Hex `popcorn 0.4.0-next.9`), Elixir 1.20.4 on OTP 29.0.6, full BEAM. +- App and sandbox were served locally with COOP/COEP isolation headers. + +## Before and after + +The old bridge recreated the runtime iframe for every run. A code edit therefore +downloaded and initialized the VM again and recompiled the Elixir prelude. + +| Measurement | Before | After | +| -------------------------------------- | ----------------------------: | ----------------------------: | +| Cold start, navigation to first result | ~8.8 s | ~8.8-9.5 s (network variance) | +| Warm run (`livecodes.elixir.run()`) | ~6.0 s (new iframe + VM boot) | ~119 ms (reuses worker) | +| Edit, including LiveReload and result | ~64 s | ~200 ms | +| Three edits while a 3 s run was active | ~63.6 s | ~3.29 s | + +The cold start is still dominated by the one-time prelude compile. The worker +removes that cost from edits, but it does not hide it on first boot. Cold-start +variation is mostly network and app startup; the Elixir-specific portion was +about 5.3 s in the final run. This change does not claim a cold-start win: the +first boot still compiles the same prelude, and the measured range overlaps the +old baseline. It removes the repeated compile from edit iterations. + +### Final cold-start breakdown + +Measured from the result-page script evaluation: + +| Phase | Time | +| ----------------------------------------------------------- | -----: | +| Asset fetch / module load until the worker's `init` message | 1.23 s | +| AtomVM WASM initialization | 281 ms | +| Elixir prelude compile | 3.57 s | +| First user evaluation | 121 ms | +| Total from result script to first completed run | ~5.3 s | + +Another ~4.2 s before the result-page script runs is LiveCodes app startup; it is +not part of the Elixir runtime. The 3.57 s prelude compile happens once per +worker boot. A normal edit reuses the booted worker, which is why the edit path +drops from tens of seconds to ~200 ms. + +### Rapid edits + +While a `Process.sleep(3000)` evaluation is active, three further edits are +coalesced to the latest one. The final result appears after the active run (or +after the 10 s supersede timeout), with one worker boot. This avoids building a +queue of stale evaluations and prevents a newer edit from being blocked behind a +hung one. + +## AtomVM vs Popcorn OTP/BEAM + +Both runtimes were measured in minimal pages with a single worker, after boot +and warm evaluation, so app-level LiveCodes work is excluded from the +worker-level numbers. + +| Metric | AtomVM (production) | Popcorn OTP/BEAM | +| ---------------------------------- | ----------------------------------------: | --------------------------------------: | +| Runtime | Elixir 1.17.3 / OTP 27 | Elixir 1.20.4 / OTP 29.0.6 | +| First-load transfer | ~3.48 MB Brotli | ~7.88 MB (local server) | +| Decoded runtime assets | ~7.88 MB | ~30 MB build output | +| Init time, local assets | 266 ms | 574 ms | +| First direct evaluation | 29.3 ms | 31.2 ms | +| Warm direct evaluation | 4.4 ms | 1.0-1.1 ms | +| Total memory after warm evaluation | 32.9 MB | 101.2 MB | +| Worker memory only | 31.9 MB | 100.0 MB | +| Isolation headers tested | COOP `same-origin`, COEP `credentialless` | COOP `same-origin`, COEP `require-corp` | + +Notes: + +- The AtomVM transfer figure is the actual Brotli-compressed CDN response: + `AtomVM.mjs` 35.8 KB, `AtomVM.wasm` 248 KB, and `bundle.avm` 3.36 MB. The + decoded sizes are 150 KB, 997 KB, and 7.11 MB. +- The Popcorn local benchmark server compressed the OTP tarballs but left + `beam.wasm` and the JS modules uncompressed. A static host with Brotli enabled + could reduce that transfer further. +- Popcorn's ~30 MB cook output is a build-time artifact, not all of it is + downloaded by the browser. It shows the packaging cost of shipping a complete + OTP/BEAM image. +- The direct warm-evaluation numbers are worker calls. A LiveCodes `run()` takes + ~119 ms end-to-end because it also parses the wrapper with + `Code.string_to_quoted/1`, evaluates it with `Code.eval_string/2`, and decodes + the tagged result. That overhead is common to both runtimes in the app; it is + not the VM call itself. +- Memory was measured with + `performance.measureUserAgentSpecificMemory()` in an isolated page after one + boot and two or three evaluations. It includes the page, worker, and shared + memory; it excludes the browser process, GPU process, and page cache. The + numbers are representative single runs and should be treated as approximate. + +Popcorn is not a drop-in replacement for the production runtime. It uses the +full BEAM, has broader OTP compatibility, but requires Mix build-time packaging +and a substantially larger download and memory footprint. The AtomVM runtime +keeps the current pinned artifact and the persistent-worker architecture. Any +future migration should be justified by a compatibility need that cannot be +fixed in the AtomVM runtime, not by the benchmark alone. diff --git a/src/livecodes/languages/elixir-wasm/index.ts b/src/livecodes/languages/elixir-wasm/index.ts new file mode 100644 index 0000000000..ed93b98ef1 --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/index.ts @@ -0,0 +1 @@ +export * from './lang-elixir-wasm'; diff --git a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts new file mode 100644 index 0000000000..7faf219296 --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts @@ -0,0 +1,931 @@ +/* eslint-disable no-console */ +import { getErrorMessage } from '../../utils/utils'; +import { elixirWasmBaseUrl } from '../../vendors'; +import { createLoadingReporter } from '../wasm-runtime'; +// @ts-ignore +// eslint-disable-next-line import/no-unresolved +import workerSource from './lang-elixir-wasm-worker.raw.js?raw'; + +interface RunResult { + output: string | null; + error: string | null; + exitCode: number | null; + value?: string | null; + warnings?: string | null; + cancelled?: boolean; +} + +interface ElixirTiming { + name: string; + at: number; + detail?: Record; +} + +interface ElixirWasmApi { + ready: boolean; + failed?: boolean; + input?: string; + output: string | null; + error: string | null; + exitCode: number | null; + value?: string | null; + warnings?: string | null; + loaded: Promise; + evals?: number; + runId?: number; + currentRun?: Promise | null; + init?: Promise; + ensureInit?: () => Promise; + retry?: () => Promise; + popcorn?: any; + stdout?: string[]; + stderr?: string[]; + timings?: ElixirTiming[]; + run: (input?: string) => Promise; +} + +declare const window: Window & { + livecodes: Record; +}; + +const BUNDLE_URL = `${elixirWasmBaseUrl}bundle.avm`; +const EVAL_TIMEOUT_MS = 60_000; +// First boot downloads ~8MB (bundle.avm + AtomVM.wasm) and starts the VM, +// so allow generous time before giving up. +const INIT_VM_TIMEOUT_MS = 120_000; + +const SCRIPT_TYPE = 'text/elixir-wasm'; + +// Message protocol spoken by the runtime worker (same types as upstream Popcorn). +const bridgeMessages = { + init: 'popcorn-init', + startVm: 'popcorn-startVm', + call: 'popcorn-call', + callAck: 'popcorn-callAck', + stdout: 'popcorn-stdout', + stderr: 'popcorn-stderr', + heartbeat: 'popcorn-heartbeat', + reload: 'popcorn-reload', + timing: 'popcorn-timing', + fatal: 'popcorn-fatal', +} as const; + +const postLoading = createLoadingReporter(); + +const existingElixir = window.livecodes?.elixir as ElixirWasmApi | undefined; +const timings: ElixirTiming[] = existingElixir?.timings ?? []; +const mark = (name: string, detail?: Record) => { + timings.push({ name, at: performance.now(), ...(detail == null ? {} : { detail }) }); + if (timings.length > 1000) timings.splice(0, timings.length - 1000); +}; + +const readCode = () => { + let code = ''; + document + .querySelectorAll(`script[type="${SCRIPT_TYPE}"]`) + .forEach((script) => { + code += `${script.textContent ?? script.innerHTML}\n`; + }); + return code; +}; + +const escapeElixirString = (value: string) => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/#{/g, '#\\{'); + +const wrapElixir = (code: string, input: string) => { + // The user source is embedded as a string and parsed first with + // Code.string_to_quoted/1: a syntax error becomes a tagged value instead of + // an exception that would wedge (or kill) the VM with no reply. + const source = escapeElixirString(code); + const stdin = escapeElixirString(input); + return `livecodes_input = "${stdin}" +livecodes_source = "${source}" +gl = Process.group_leader() +dev = :livecodes_io.start(livecodes_input) +Process.group_leader(self(), dev) +result = + try do + case Code.string_to_quoted(livecodes_source) do + {:error, {meta, message, _token}} -> + line = if is_list(meta), do: Keyword.get(meta, :line, 0), else: 0 + {:lc_error, :syntax, "SyntaxError: " <> to_string(message) <> " (line " <> to_string(line) <> ")", ""} + {:error, err} -> + {:lc_error, :syntax, "SyntaxError: " <> inspect(err), ""} + {:ok, _} -> + try do + {value, _binding} = Code.eval_string(livecodes_source, livecodes_input: livecodes_input) + {:lc_ok, value} + rescue + e -> {:lc_error, :error, inspect(e.__struct__) <> ": " <> Exception.message(e), ""} + catch + :throw, {:lc_exit, status} -> {:lc_exit, status} + kind, other -> {:lc_error, kind, inspect(other), ""} + end + end + after + Process.group_leader(self(), gl) + # Terminals are line buffered: a final newline flushes an IO.write/1 that + # did not end in a newline. The main thread drops the resulting trailing + # newline so the observable output does not change. + IO.write("\\n") + end +:livecodes_io.stop(dev) +case result do + {:lc_ok, value} -> + text = inspect(value) + text = if byte_size(text) > 1500, do: binary_part(text, 0, 1500) <> "...", else: text + "__LC_OK__" <> Base.encode64(text) + {:lc_error, kind, message, _stack} -> + message = if byte_size(message) > 1000, do: binary_part(message, 0, 1000) <> "...", else: message + "__LC_ERROR__" <> Base.encode64(to_string(kind)) <> "|" <> Base.encode64(message) + {:lc_exit, status} -> + "__LC_EXIT__" <> Integer.to_string(status) +end`; +}; + +const OK_TAG = '__LC_OK__'; +const ERROR_TAG = '__LC_ERROR__'; +const EXIT_TAG = '__LC_EXIT__'; +const LEGACY_EXIT_TAG = /^\{:?lc_exit, (-?\d+)\}$/; +const LEGACY_ERROR_TAG = /^\{:?lc_error, ([\s\S]*)\}$/; + +const decodeBase64 = (value: string): string => { + try { + const binary = atob(value); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return value; + } +}; + +// Popcorn serializes a returned Elixir string as a JSON string. A tagged +// protocol string therefore arrives as `"\"__LC_OK__...\""`; unwrap it before +// parsing. Atoms/tuples arrive without the extra quoting. +const normalizeResultText = (data: unknown): string => { + if (data == null) return ''; + let text = String(data); + if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) { + try { + const parsed = JSON.parse(text); + if (typeof parsed === 'string') text = parsed; + } catch { + // Keep the original text if it is not valid JSON. + } + } + return text; +}; + +const parseElixirResult = ( + text: string, +): { value: string | null; error: string | null; exitCode: number } => { + if (text.startsWith(OK_TAG)) { + return { value: decodeBase64(text.slice(OK_TAG.length)), error: null, exitCode: 0 }; + } + if (text.startsWith(EXIT_TAG)) { + const exitCode = Number(text.slice(EXIT_TAG.length)); + return { value: null, error: null, exitCode: Number.isFinite(exitCode) ? exitCode : 1 }; + } + if (text.startsWith(ERROR_TAG)) { + const [kind, message, stack] = text + .slice(ERROR_TAG.length) + .split('|') + .map((part) => decodeBase64(part)); + const detail = message || stack || 'Elixir error'; + const prefix = kind && kind !== 'error' && kind !== 'syntax' ? `${kind}: ` : ''; + return { value: null, error: `${prefix}${detail}`, exitCode: 1 }; + } + const legacyExit = LEGACY_EXIT_TAG.exec(text); + if (legacyExit) { + return { value: null, error: null, exitCode: Number(legacyExit[1]) }; + } + const legacyError = LEGACY_ERROR_TAG.exec(text); + if (legacyError) { + return { value: null, error: legacyError[1], exitCode: 1 }; + } + return { value: text === '' ? null : text, error: null, exitCode: 0 }; +}; + +window.livecodes ??= {}; +window.livecodes.elixir ??= {} as ElixirWasmApi; +const elixir = window.livecodes.elixir; +elixir.ready = false; +elixir.evals = (elixir.evals ?? 0) + 1; +const thisEval = elixir.evals; +console.log( + `[elixir-wasm] script evaluated (#${thisEval}), ` + + `elixir script tags: ${document.querySelectorAll(`script[type="${SCRIPT_TYPE}"]`).length}, ` + + `isolated: ${window.crossOriginIsolated}, SAB: ${typeof SharedArrayBuffer !== 'undefined'}`, +); +mark('script-eval', { + evalId: thisEval, + isolated: window.crossOriginIsolated, + sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', +}); + +// LiveReload updates rewrite the result document in rapid succession and the +// window `load` event is not reliably fired after coalesced rewrites, so runs +// are scheduled by polling the parser state instead of listening for `load`. +// Only the latest evaluation runs; superseded ones abort silently. +const waitForParseThenRun = async (evalId: number): Promise => { + const start = Date.now(); + while (document.readyState === 'loading' && Date.now() - start < 30_000) { + if (elixir.evals !== evalId) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + if (elixir.evals !== evalId) return; + postLoading(true); + try { + await elixir.run(elixir.input); + } finally { + postLoading(false); + } +}; +void waitForParseThenRun(thisEval); + +// AtomVM compiles this module on first boot, and compile time is the dominant +// cold-start cost. Keep it small; the worker keeps the compiled module alive +// across LiveReload edits. +const PRELUDE = `defmodule :livecodes_io do + def start(stdin) do + lines = + stdin + |> to_string() + |> String.split("\\n", trim: true) + |> Enum.map(&(&1 <> "\\n")) + + spawn(fn -> loop(lines) end) + end + + def stop(dev), do: send(dev, :lc_stop) + + def halt, do: throw({:lc_exit, 0}) + def halt(status), do: throw({:lc_exit, status}) + + defp loop(lines) do + receive do + :lc_stop -> :ok + {:io_request, from, reply_as, req} -> + {reply, rest} = handle(req, lines) + send(from, {:io_reply, reply_as, reply}) + loop(rest) + end + end + + defp handle({:get_line, _enc, _prompt}, [line | rest]), do: {line, rest} + defp handle({:get_line, _enc, _prompt}, []), do: {:eof, []} + defp handle({:put_chars, _enc, chars}, lines) do + IO.write(chars) + {:ok, lines} + end + defp handle({:put_chars, chars}, lines) do + IO.write(chars) + {:ok, lines} + end + defp handle(other, lines), do: {{:error, {:enotsup, other}}, lines} +end`; + +interface PendingCall { + startTimeMs: number; + resolve: (data: any) => void; + reject: (reason?: unknown) => void; + timer: ReturnType | null; +} + +interface Waiter { + resolve: (value: any) => void; + reject: (reason?: unknown) => void; + timer: ReturnType | null; +} + +/** + * Main-thread side of the Elixir runtime. + * + * The runtime runs in a Worker owned by the result window. LiveReload rewrites + * the result document with `document.open()/write()/close()`, which keeps the + * window (and therefore this Worker) alive; editing code no longer destroys + * the VM. The bridge is stored on `window.livecodes.elixir`, which also + * survives document rewrites. + */ +class ElixirBridge { + private bundlePath: string; + private onStdout: (text: string) => void; + private onStderr: (text: string) => void; + private onReady: ((bridge: ElixirBridge) => Promise) | null; + private worker: Worker | null = null; + private workerUrl: string | null = null; + private initProcess: string | null = null; + private requestId = 0; + private calls = new Map(); + private waiters = new Map(); + private booting: Promise | null = null; + private ready = false; + private failed: string | null = null; + private generation = 0; + private destroyed = false; + + private constructor(options: { + bundlePath: string; + onStdout?: (text: string) => void; + onStderr?: (text: string) => void; + onReady?: (bridge: ElixirBridge) => Promise; + }) { + this.bundlePath = options.bundlePath; + this.onStdout = options.onStdout ?? console.log; + this.onStderr = options.onStderr ?? console.warn; + this.onReady = options.onReady ?? null; + } + + public get error(): string | null { + return this.failed; + } + + public get isReady(): boolean { + return this.ready && this.worker != null; + } + + public static async init(options: { + bundlePath: string; + onStdout?: (text: string) => void; + onStderr?: (text: string) => void; + onReady?: (bridge: ElixirBridge) => Promise; + }): Promise { + const bridge = new ElixirBridge(options); + await bridge.start(); + return bridge; + } + + public ensureReady(): Promise { + return this.start(); + } + + public async call(args: unknown, options: { timeoutMs?: number } = {}): Promise { + await this.start(); + if (!this.isReady || this.initProcess == null) { + throw new Error(this.failed || 'Elixir runtime is not ready'); + } + const requestId = this.requestId; + this.requestId += 1; + const targetProcess = this.initProcess; + const timeoutMs = options.timeoutMs ?? EVAL_TIMEOUT_MS; + const callPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.calls.delete(requestId); + // Module.call is synchronous inside the worker. If it does not return, + // terminating the worker is the only reliable way to unblock newer + // work. This happens on timeouts only, never on a normal edit. + this.stopWorker(`Elixir evaluation timed out after ${timeoutMs} ms`); + reject(new Error(`Elixir evaluation timed out after ${timeoutMs} ms`)); + }, timeoutMs); + this.calls.set(requestId, { startTimeMs: performance.now(), resolve, reject, timer }); + }); + try { + this.post(bridgeMessages.call, { requestId, process: targetProcess, args }); + } catch (err) { + const call = this.calls.get(requestId); + if (call != null) { + this.calls.delete(requestId); + if (call.timer != null) clearTimeout(call.timer); + call.reject(err); + } + } + return callPromise; + } + + /** Terminate the worker without waiting for the current evaluation. */ + public cancel(reason = 'superseded by a newer run'): void { + if (this.worker == null) return; + this.stopWorker(reason); + } + + public async restart(): Promise { + this.stopWorker('manual restart'); + await this.start(); + } + + public destroy(): void { + this.destroyed = true; + this.stopWorker('destroyed'); + } + + private async start(): Promise { + if (this.destroyed) throw new Error('Elixir runtime was destroyed'); + if (this.isReady) return; + if (this.booting != null) return this.booting; + const attempt = this.boot(); + this.booting = attempt; + try { + await attempt; + } finally { + if (this.booting === attempt) this.booting = null; + } + } + + private async boot(): Promise { + this.stopWorker('restarting'); + this.failed = null; + const generation = this.generation; + mark('worker-create-start'); + const source = String(workerSource).replace('__LIVECODES_ELIXIR_BUNDLE_URL__', this.bundlePath); + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + this.workerUrl = url; + try { + const worker = new Worker(url, { type: 'module' }); + this.worker = worker; + worker.addEventListener('message', this.onWorkerMessage); + worker.addEventListener('error', this.onWorkerError); + worker.addEventListener('messageerror', this.onWorkerMessageError); + mark('worker-created'); + const initPromise = this.waitFor(bridgeMessages.init, INIT_VM_TIMEOUT_MS); + const startPromise = this.waitFor(bridgeMessages.startVm, INIT_VM_TIMEOUT_MS); + await initPromise; + mark('worker-init-message'); + console.log('[elixir-wasm] worker started, waiting for the VM…'); + this.initProcess = await startPromise; + if (generation !== this.generation) { + throw new Error('Elixir runtime was restarted'); + } + mark('worker-vm-ready'); + console.log('[elixir-wasm] VM is ready.'); + this.ready = true; + if (this.onReady != null) { + await this.onReady(this); + } + mark('worker-prelude-ready'); + } catch (err) { + this.stopWorker(`boot failed: ${getErrorMessage(err)}`); + throw err; + } + } + + private waitFor(type: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const waiter: Waiter = { resolve, reject, timer: null }; + waiter.timer = setTimeout(() => { + const list = this.waiters.get(type); + const index = list?.indexOf(waiter) ?? -1; + if (list != null && index >= 0) list.splice(index, 1); + reject(new Error(`Timed out waiting for ${type}`)); + }, timeoutMs); + const list = this.waiters.get(type) ?? []; + list.push(waiter); + this.waiters.set(type, list); + }); + } + + private resolveWaiter(type: string, value: any): void { + const list = this.waiters.get(type); + const waiter = list?.shift(); + if (list != null && list.length === 0) this.waiters.delete(type); + if (waiter == null) return; + if (waiter.timer != null) clearTimeout(waiter.timer); + waiter.resolve(value); + } + + private post(type: string, value: unknown): void { + if (this.worker == null) throw new Error('Elixir worker is not running'); + this.worker.postMessage({ type, value }); + } + + private onWorkerMessage = (event: MessageEvent): void => { + const data = event.data as { type?: unknown; value?: any } | null; + if (data == null || typeof data.type !== 'string') return; + const value = data.value; + switch (data.type) { + case bridgeMessages.init: + case bridgeMessages.startVm: + this.resolveWaiter(data.type, value); + break; + case bridgeMessages.stdout: + this.onStdout(typeof value === 'string' ? value : String(value)); + break; + case bridgeMessages.stderr: + this.onStderr(typeof value === 'string' ? value : String(value)); + break; + case bridgeMessages.call: + this.handleCall(value); + break; + case bridgeMessages.callAck: + break; + case bridgeMessages.heartbeat: + break; + case bridgeMessages.reload: + this.stopWorker('Elixir runtime requested a restart'); + break; + case bridgeMessages.fatal: + { + const detail = + typeof value?.message === 'string' ? value.message : 'unknown runtime failure'; + this.onStderr(`Runtime failed: ${detail}\n`); + this.stopWorker(`Elixir runtime failed: ${detail}`); + } + break; + case bridgeMessages.timing: + this.recordWorkerTiming(value); + break; + default: + break; + } + }; + + private onWorkerError = (event: ErrorEvent): void => { + const detail = + event.error instanceof Error && event.error.message + ? event.error.message + : event.message || 'unknown error'; + const message = `Elixir worker error: ${detail}`; + this.onStderr(`${message}\n`); + this.stopWorker(message); + }; + + private onWorkerMessageError = (): void => { + const message = 'Elixir worker message could not be deserialized'; + this.onStderr(`${message}\n`); + this.stopWorker(message); + }; + + private handleCall(value: { requestId: number; error?: unknown; data?: unknown }): void { + const call = this.calls.get(value.requestId); + if (call == null) return; + this.calls.delete(value.requestId); + if (call.timer != null) clearTimeout(call.timer); + if (value.error !== undefined) { + call.reject(value.error); + } else { + call.resolve({ data: value.data }); + } + } + + private recordWorkerTiming(value: { + name?: unknown; + at?: unknown; + detail?: Record; + }): void { + if (typeof value?.name !== 'string') return; + mark(`worker:${value.name}`, { + workerAt: typeof value.at === 'number' ? value.at : null, + ...(value.detail ?? {}), + }); + } + + private stopWorker(reason: string): void { + const pendingCalls = this.calls.size; + this.generation += 1; + this.ready = false; + this.initProcess = null; + this.failed = reason; + if (this.worker != null) { + this.worker.removeEventListener('message', this.onWorkerMessage); + this.worker.removeEventListener('error', this.onWorkerError); + this.worker.removeEventListener('messageerror', this.onWorkerMessageError); + this.worker.terminate(); + this.worker = null; + } + if (this.workerUrl != null) { + URL.revokeObjectURL(this.workerUrl); + this.workerUrl = null; + } + for (const call of this.calls.values()) { + if (call.timer != null) clearTimeout(call.timer); + call.reject(new Error(reason)); + } + this.calls.clear(); + for (const list of this.waiters.values()) { + for (const waiter of list) { + if (waiter.timer != null) clearTimeout(waiter.timer); + waiter.reject(new Error(reason)); + } + } + this.waiters.clear(); + mark('worker-stopped', { reason, pendingCalls }); + } +} + +const drain = (arr: string[]) => { + const out = arr.join('\n'); + arr.length = 0; + return out; +}; + +const initBridge = async (): Promise => { + mark('init-start'); + postLoading(true); + const stdout: string[] = elixir.stdout ?? []; + const stderr: string[] = elixir.stderr ?? []; + elixir.stdout = stdout; + elixir.stderr = stderr; + try { + const bridge = await ElixirBridge.init({ + bundlePath: BUNDLE_URL, + onStdout: (text: string) => stdout.push(text), + onStderr: (text: string) => stderr.push(text), + onReady: async (readyBridge) => { + // Drop boot chatter (module import, fetches, wasm init) so it does not + // leak into the first run's output after a boot or crash recovery. + drain(stdout); + drain(stderr); + mark('prelude-start'); + const prelude = await readyBridge.call(['eval_elixir', PRELUDE], { + timeoutMs: EVAL_TIMEOUT_MS, + }); + mark('prelude-end'); + const preludeText = normalizeResultText(prelude?.data); + const preludeError = parseElixirResult(preludeText).error; + if (preludeError != null) { + throw new Error(`Elixir prelude failed: ${preludeError}`); + } + // Warnings emitted while compiling the prelude are not user output. + drain(stdout); + drain(stderr); + }, + }); + elixir.popcorn = bridge; + elixir.failed = false; + mark('init-ready'); + } catch (err) { + // Surface anything the runtime reported before dying (e.g. bundle/AtomVM fetch failures). + if (stderr.length > 0) console.error(drain(stderr)); + if (stdout.length > 0) console.log(drain(stdout)); + elixir.failed = true; + elixir.error = getErrorMessage(err); + mark('init-failed', { error: elixir.error }); + throw err; + } finally { + postLoading(false); + } +}; + +// `ensureInit` is stored on the Elixir API object so it survives LiveReload +// document rewrites and can reuse the existing worker. A rejected attempt is +// cleared, so the next run (or retry()) starts a fresh boot instead of +// replaying a memoized failure forever. +elixir.ensureInit ??= async () => { + const existing = elixir.popcorn as ElixirBridge | undefined; + if (existing != null) { + await existing.ensureReady(); + return; + } + if (elixir.init == null) { + const attempt = initBridge(); + elixir.init = attempt; + attempt.catch(() => { + if (elixir.init === attempt) elixir.init = undefined; + }); + } + await elixir.init; +}; + +const runOnce = async (input?: string, runId?: number): Promise => { + if (input !== undefined) elixir.input = input; + const stdin = `${input ?? elixir.input ?? ''}`; + const isLatest = () => runId == null || runId === elixir.runId; + elixir.output = null; + elixir.error = null; + elixir.exitCode = null; + elixir.value = null; + elixir.warnings = null; + elixir.ready = false; + + const code = readCode(); + mark('run-start', { runId, codeBytes: code.length, inputBytes: stdin.length }); + console.log(`[elixir-wasm] run requested, code bytes: ${code.length}`); + if (!code.trim()) { + console.log('[elixir-wasm] empty code, skipping run.'); + mark('run-empty', { runId }); + if (isLatest()) { + elixir.output = null; + elixir.error = null; + elixir.exitCode = 0; + elixir.value = null; + elixir.warnings = null; + elixir.ready = true; + elixir.failed = false; + } + mark('run-end', { + runId, + exitCode: 0, + stdoutBytes: 0, + stderrBytes: 0, + hasError: false, + latest: isLatest(), + empty: true, + }); + return { output: null, error: null, exitCode: 0, value: null, warnings: null }; + } + if (!window.crossOriginIsolated) { + const message = + 'Error: Elixir needs a cross-origin isolated result page ' + + '(COOP/COEP headers). The AtomVM runtime cannot boot without SharedArrayBuffer.'; + console.error(message); + mark('run-not-isolated', { runId }); + if (isLatest()) { + elixir.error = message; + elixir.exitCode = 1; + elixir.ready = true; + elixir.failed = true; + } + return { output: null, error: message, exitCode: 1, value: null, warnings: null }; + } + + try { + await elixir.ensureInit?.(); + const popcorn = elixir.popcorn as ElixirBridge | undefined; + if (popcorn == null) throw new Error('Elixir runtime is not initialized'); + elixir.stdout!.length = 0; + elixir.stderr!.length = 0; + mark('eval-start', { runId }); + const { data } = await popcorn.call(['eval_elixir', wrapElixir(code, stdin)], { + timeoutMs: EVAL_TIMEOUT_MS, + }); + const printed = drain(elixir.stdout!); + const errPrinted = drain(elixir.stderr!); + const parsed = parseElixirResult(normalizeResultText(data)); + // The wrapper appends one newline to flush line-buffered IO.write/1. Drop + // exactly that newline; a single IO.puts chunk may include it. + const outputText = printed.endsWith('\n') ? printed.slice(0, -1) : printed; + const output = outputText || null; + const warnings = errPrinted || null; + const latest = isLatest(); + if (latest) { + elixir.output = output; + elixir.error = parsed.error; + elixir.exitCode = parsed.exitCode; + elixir.value = parsed.value; + elixir.warnings = warnings; + elixir.ready = true; + elixir.failed = false; + if (parsed.error != null) console.error(parsed.error); + if (output != null) console.log(output); + } + mark('run-end', { + runId, + exitCode: parsed.exitCode, + stdoutBytes: printed.length, + stderrBytes: errPrinted.length, + hasError: parsed.error != null, + latest, + }); + return { + output, + error: parsed.error, + exitCode: parsed.exitCode, + value: parsed.value, + warnings, + ...(latest ? {} : { cancelled: true }), + }; + } catch (err) { + const latest = isLatest(); + const runtimeStderr = latest ? drain(elixir.stderr ?? []) : ''; + const base = 'Error: ' + getErrorMessage(err); + const message = runtimeStderr.trim() ? `${base}\n${runtimeStderr.trim()}` : base; + if (latest) { + elixir.output = null; + elixir.error = message; + elixir.exitCode = 1; + elixir.value = null; + elixir.warnings = null; + elixir.ready = true; + elixir.failed = true; + console.error(message); + } + mark('run-error', { runId, error: message, latest }); + return { + output: null, + error: message, + exitCode: 1, + value: null, + warnings: null, + ...(latest ? {} : { cancelled: true }), + }; + } +}; + +const SUPERSEDE_TIMEOUT_MS = 10_000; +let activeRun: Promise | null = null; +let pendingRun: { + input?: string; + runId: number; + resolve: (result: RunResult) => void; + reject: (reason?: unknown) => void; +} | null = null; +let supersedeTimer: ReturnType | null = null; + +const clearSupersedeTimer = () => { + if (supersedeTimer == null) return; + clearTimeout(supersedeTimer); + supersedeTimer = null; +}; + +const scheduleSupersede = () => { + if (supersedeTimer != null) return; + supersedeTimer = setTimeout(() => { + supersedeTimer = null; + if (activeRun == null || pendingRun == null) return; + const bridge = elixir.popcorn as ElixirBridge | undefined; + mark('run-supersede-timeout', { pendingRunId: pendingRun.runId }); + bridge?.cancel('superseded by a newer run'); + }, SUPERSEDE_TIMEOUT_MS); +}; + +const finishRun = (task: Promise) => { + if (activeRun !== task) return; + activeRun = null; + elixir.currentRun = null; + clearSupersedeTimer(); + const next = pendingRun; + pendingRun = null; + if (next != null) { + mark('run-dequeued', { runId: next.runId }); + const nextTask = startRun(next.input, next.runId); + void nextTask.then(next.resolve, next.reject); + } +}; + +const startRun = (input: string | undefined, runId: number): Promise => { + const task = runOnce(input, runId); + activeRun = task; + elixir.currentRun = task.then( + () => undefined, + () => undefined, + ); + void task.then( + () => finishRun(task), + () => finishRun(task), + ); + return task; +}; + +elixir.run ??= async (input?: string): Promise => { + const runId = (elixir.runId ?? 0) + 1; + elixir.runId = runId; + mark('run-queued', { runId, hasInput: input != null }); + if (activeRun == null) { + return startRun(input, runId); + } + if (pendingRun != null) { + const superseded = pendingRun; + pendingRun = null; + mark('run-superseded', { runId: superseded.runId, by: runId }); + superseded.resolve({ + output: null, + error: null, + exitCode: null, + value: null, + warnings: null, + cancelled: true, + }); + } + scheduleSupersede(); + return new Promise((resolve, reject) => { + pendingRun = { input, runId, resolve, reject }; + mark('run-pending', { runId }); + }); +}; + +elixir.retry ??= async () => { + elixir.failed = false; + elixir.error = null; + elixir.ready = false; + elixir.init = undefined; + const bridge = elixir.popcorn as ElixirBridge | undefined; + try { + if (bridge != null) { + await bridge.restart(); + } else { + await elixir.ensureInit?.(); + } + } catch (err) { + elixir.failed = true; + elixir.error = getErrorMessage(err); + throw err; + } +}; + +elixir.timings = timings; +const LOADED_TIMEOUT_MS = INIT_VM_TIMEOUT_MS + EVAL_TIMEOUT_MS; +elixir.loaded = new Promise((resolve, reject) => { + const started = performance.now(); + const interval = setInterval(() => { + if (elixir.failed) { + clearInterval(interval); + mark('loaded-rejected', { error: elixir.error }); + reject(new Error(elixir.error || 'Failed to initialize the Elixir environment')); + } else if (elixir.ready) { + clearInterval(interval); + mark('loaded-resolved'); + resolve(); + } else if (performance.now() - started > LOADED_TIMEOUT_MS) { + clearInterval(interval); + elixir.failed = true; + elixir.error = 'Timed out initializing the Elixir runtime'; + mark('loaded-timeout'); + reject(new Error(elixir.error)); + } + }, 50); +}); +// The starter awaits `loaded` and may not catch. Attaching a no-op handler +// keeps a recoverable boot failure from becoming an unhandled rejection; the +// original promise still rejects for callers that want the error. +void elixir.loaded.catch(() => undefined); + +window.livecodes['elixir-wasm'] = elixir; diff --git a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js new file mode 100644 index 0000000000..1076118a07 --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js @@ -0,0 +1,317 @@ +/* eslint-disable no-eval -- indirect eval is required by AtomVM JS interop (onRunTrackedJs) */ +// @ts-nocheck -- vendored fixed Popcorn worker runtime (plain JS, typed as text import) +/* + * Persistent Popcorn worker runtime for LiveCodes Elixir (Wasm). + * + * Based on the iframe runtime shipped with + * `live-codes/elixir-browser-eval` (itself from Software Mansion Popcorn). + * The runtime is hosted in a Worker owned by the result window, not in an + * iframe owned by the result document. `document.open()/write()/close()` used + * by LiveReload replaces the document but keeps the window (and therefore this + * worker) alive, so a code edit no longer reboots the VM. + * + * Changes from the upstream iframe runtime: + * + * 1. `BUNDLE_PATH` is injected by the main thread when the Blob worker is + * created, so the runtime does not depend on a DOM meta element. + * 2. Messages go through `self` instead of `window.parent`. The main thread + * owns the Worker object, so no cross-origin relay is required. + * 3. Emscripten pthread workers are created from a Blob URL + * (`mainScriptUrlOrBlob`) because a module worker must be same-origin and + * the CDN mirror is cross-origin. + * 4. The Elixir boot code generated by the pinned bundle expects + * `window.parent.document` in its JS interop. A minimal shim is installed + * immediately before `init()`; it is deliberately installed after the + * AtomVM module is imported so Emscripten still detects a worker + * environment. + * + * Message protocol (postMessage types) is unchanged from upstream, with an + * extra `popcorn-timing` diagnostic channel used by the benchmarks/tests. + */ + +const MESSAGES = { + INIT: 'popcorn-init', + START_VM: 'popcorn-startVm', + CALL: 'popcorn-call', + CAST: 'popcorn-cast', + CALL_ACK: 'popcorn-callAck', + STDOUT: 'popcorn-stdout', + STDERR: 'popcorn-stderr', + HEARTBEAT: 'popcorn-heartbeat', + RELOAD: 'popcorn-reload', + TIMING: 'popcorn-timing', + FATAL: 'popcorn-fatal', +}; + +const BUNDLE_PATH = '__LIVECODES_ELIXIR_BUNDLE_URL__'; + +const HEARTBEAT_INTERVAL_MS = 500; + +let Module = null; +let binaryDir = ''; +let workerScript = ''; + +const mark = (name, detail) => { + send(MESSAGES.TIMING, { name, at: performance.now(), detail }); +}; +mark('worker-start'); + +// The binaries sit next to the bundle, which may be a CDN mirror. +const locateBinary = (path) => { + send(MESSAGES.STDERR, `locateFile: ${path} -> ${binaryDir + path}\n`); + return binaryDir + path; +}; + +class TrackedValue { + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility -- plain JS file, no TS modifiers + constructor({ key, value }) { + if (typeof key !== 'number') { + throw new Error('key property in TrackedValue must be a number'); + } + this.key = key; + this.value = value; + } +} + +globalThis.TrackedValue = TrackedValue; + +async function runWorker() { + try { + await startPopcorn(); + } catch (error) { + // A worker that dies silently is near-impossible to debug — report it and + // let the main thread reject the boot waiters immediately instead of + // waiting for the init timeout. + const detail = (error && (error.stack || error.message)) || String(error); + send(MESSAGES.STDERR, `Runtime failed: ${detail}\n`); + send(MESSAGES.FATAL, { message: detail }); + throw error; + } +} + +async function startPopcorn() { + const bundlePath = BUNDLE_PATH; + binaryDir = new URL('.', bundlePath).href; + + // Emscripten spawns its pthread workers from the runtime module's own URL — + // `new Worker(new URL("AtomVM.mjs", import.meta.url), { type: "module" })` — + // and a worker script must be same-origin, which would pin the runtime to this + // origin. It accepts `mainScriptUrlOrBlob` as an override, so the module is + // fetched from the mirror and handed back as a Blob URL: same-origin, and with + // a usable base URL. + mark('atomvm-import-start'); + const source = await fetch(binaryDir + 'AtomVM.mjs').then((resp) => resp.text()); + workerScript = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + const { default: init } = await import(binaryDir + 'AtomVM.mjs'); + mark('atomvm-import-end'); + send(MESSAGES.STDERR, 'AtomVM module imported.\n'); + + mark('bundle-fetch-start'); + const bundleBuffer = await fetch(bundlePath).then((resp) => resp.arrayBuffer()); + const bundle = new Int8Array(bundleBuffer); + mark('bundle-fetch-end', { bytes: bundle.length }); + send(MESSAGES.STDERR, `bundle fetched (${bundle.length} bytes).\n`); + send(MESSAGES.INIT); + // init() hanging would leave the playground loading forever with no output, + // so fail loudly instead: the parent surfaces everything sent before dying. + const startTimeout = new Promise((resolve, reject) => { + setTimeout(() => reject(new Error('AtomVM start timed out (VM did not boot).')), 100000); + }); + mark('vm-init-start'); + const initProcess = await Promise.race([startVm(bundle, init), startTimeout]); + mark('vm-init-end'); + + self.addEventListener('message', async ({ data }) => { + const type = data.type; + + if (type === MESSAGES.CALL) { + await handleCall(data); + } else if (type.startsWith('popcorn')) { + send(MESSAGES.STDERR, `Worker: received unhandled popcorn event: ${JSON.stringify(data)}\n`); + } + }); + + send(MESSAGES.START_VM, initProcess); + mark('vm-ready'); + setInterval(() => send(MESSAGES.HEARTBEAT, null), HEARTBEAT_INTERVAL_MS); +} + +async function startVm(avmBundle, init) { + let resolveResultPromise = null; + const resultPromise = new Promise((resolve) => { + resolveResultPromise = resolve; + }); + // The pinned Elixir boot bundle was built for an iframe and its JS interop + // reads `globalThis.window.parent.document` before calling back into + // `Module.onElixirReady`. Install the smallest possible shim after the + // AtomVM module has been imported (so Emscripten's worker detection is not + // affected) and before init() starts the Elixir boot process. + globalThis.window = globalThis; + globalThis.window.parent = globalThis; + globalThis.window.document = globalThis.document || {}; + Module = await init({ + mainScriptUrlOrBlob: workerScript, + locateFile: locateBinary, + onRuntimeInitialized() { + send(MESSAGES.STDERR, 'AtomVM wasm runtime initialized.\n'); + }, + monitorRunDependencies(left) { + send(MESSAGES.STDERR, `AtomVM run dependencies left: ${left}\n`); + }, + preRun: [ + function ({ FS }) { + FS.mkdir('/data'); + FS.writeFile('/data/bundle.avm', avmBundle); + }, + ], + arguments: ['/data/bundle.avm'], + print(text) { + send(MESSAGES.STDOUT, text); + }, + printErr(text) { + send(MESSAGES.STDERR, text); + }, + onAbort() { + // Timeout so that error logs are (hopefully) printed + // before we terminate + setTimeout(() => send(MESSAGES.RELOAD, null), 100); + }, + }); + + Module.serialize = JSON.stringify; + Module.deserialize = deserialize; + Module.cleanupFunctions = new Map(); + Module.onTrackedObjectDelete = (key) => { + const fns = Module.cleanupFunctions; + const fn = fns.get(key); + fns.delete(key); + try { + if (fn) fn(); + } catch (e) { + send( + MESSAGES.STDERR, + `onTrackedObjectDelete failed: ${(e && (e.stack || e.message)) || e}\n`, + ); + } finally { + Module.trackedObjectsMap.delete(key); + } + }; + const origCast = Module.cast; + const origCall = Module.call; + + Module.cast = (process, args) => { + const serialized = Module.serialize(args); + origCast(process, serialized); + }; + Module.call = (process, args) => { + const serialized = Module.serialize(args); + return origCall(process, serialized); + }; + + Module.onRunTrackedJs = (scriptString, isDebug) => { + const trackValue = (tracked) => { + const getKey = Module.nextTrackedObjectKey; + const map = Module.trackedObjectsMap; + + if (tracked instanceof TrackedValue) { + map.set(tracked.key, tracked.value); + return tracked.key; + } + + const key = getKey(); + map.set(key, tracked); + return key; + }; + + let fn; + try { + const indirectEval = eval; + fn = indirectEval(scriptString); + } catch (e) { + send(MESSAGES.STDERR, `onRunTrackedJs eval failed: ${(e && (e.stack || e.message)) || e}\n`); + return null; + } + if (isDebug) ensureFunctionEval(fn); + let result; + try { + result = fn(Module); + } catch (e) { + send(MESSAGES.STDERR, `onRunTrackedJs failed: ${(e && (e.stack || e.message)) || e}\n`); + return null; + } + if (isDebug) ensureResultKeyList(result); + + return result == null ? [] : result.map(trackValue); + }; + Module.onGetTrackedObjects = (keys) => { + const getTrackedObject = (key) => { + const serialize = Module.serialize; + const map = Module.trackedObjectsMap; + + return serialize(map.get(key)); + }; + return keys.map(getTrackedObject); + }; + + Module.onElixirReady = (initProcess) => { + Module.onElixirReady = null; + resolveResultPromise(initProcess); + }; + + return resultPromise; +} + +async function handleCall(data) { + const { requestId, process, args } = data.value; + send(MESSAGES.CALL_ACK, { requestId }); + mark('call-start', { requestId }); + + try { + const result = await Module.call(process, args); + mark('call-end', { requestId }); + send(MESSAGES.CALL, { requestId, data: Module.deserialize(result) }); + } catch (error) { + mark('call-error', { requestId, error: String(error) }); + if (error === 'noproc') { + send(MESSAGES.RELOAD, null); + send(MESSAGES.STDERR, 'Runtime VM crashed, worker will be restarted.\n'); + return; + } + send(MESSAGES.CALL, { requestId, error: Module.deserialize(error) }); + } +} + +function ensureFunctionEval(maybeFunction) { + if (typeof maybeFunction !== 'function') { + throw new Error('Script passed to onRunTrackedJs() is not wrapped in a function'); + } +} + +function ensureResultKeyList(result) { + if (!Array.isArray(result) && result !== undefined) { + throw new Error( + 'Script passed to onRunTrackedJs() returned invalid value, accepted values are arrays and undefined', + ); + } +} + +function send(type, data) { + self.postMessage({ type, value: data }); +} + +function deserialize(message) { + return JSON.parse(message, (key, value) => { + const isRef = + typeof value === 'object' && + value !== null && + Object.hasOwn(value, 'popcorn_ref') && + Object.getOwnPropertyNames(value).length === 1; + + if (!isRef) { + return value; + } + return Module.trackedObjectsMap.get(value.popcorn_ref); + }); +} + +void runWorker(); diff --git a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm.ts b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm.ts new file mode 100644 index 0000000000..9a7137be08 --- /dev/null +++ b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm.ts @@ -0,0 +1,29 @@ +import { codemirrorLegacy } from '../../editor/codemirror/utils'; +import type { LanguageSpecs } from '../../models'; +import { codeMirrorBaseUrl, monacoLanguagesBaseUrl } from '../../vendors'; + +export const elixirWasm: LanguageSpecs = { + name: 'elixir-wasm', + title: 'Elixir (Wasm)', + compiler: { + factory: () => async (code) => code, + scripts: ({ baseUrl }) => [baseUrl + '{{hash:lang-elixir-wasm-script.js}}'], + scriptType: 'text/elixir-wasm', + compiledCodeLanguage: 'elixir', + liveReload: true, + }, + extensions: ['ex', 'exs', 'elixir', 'elixir-wasm', 'wasm.ex'], + editor: 'script', + editorSupport: { + monaco: { + languageSupport: monacoLanguagesBaseUrl + 'elixir.js', + language: 'elixir', + }, + codemirror: { + languageSupport: async () => + codemirrorLegacy((await import(codeMirrorBaseUrl + 'codemirror-lang-ruby.js')).ruby), + }, + codejar: { language: 'elixir' }, + }, + largeDownload: true, +}; diff --git a/src/livecodes/languages/languages.ts b/src/livecodes/languages/languages.ts index 3fdabafead..ce10fb228f 100644 --- a/src/livecodes/languages/languages.ts +++ b/src/livecodes/languages/languages.ts @@ -27,6 +27,7 @@ import { diagrams } from './diagrams'; import { dot } from './dot'; import { duckdb } from './duckdb'; import { ejs } from './ejs'; +import { elixirWasm } from './elixir-wasm'; import { elm } from './elm'; import { eta } from './eta'; import { fanak } from './fanak'; @@ -203,6 +204,7 @@ export const languages: LanguageSpecs[] = [ fsharp, fsharpWasm, fanak, + elixirWasm, haskell, haskellWasm, scheme, diff --git a/src/livecodes/models.ts b/src/livecodes/models.ts index 0eb72ed690..dd8af7f163 100644 --- a/src/livecodes/models.ts +++ b/src/livecodes/models.ts @@ -241,6 +241,7 @@ export type ScriptType = | 'text/swift-wasm' | 'text/ada-wasm' | 'text/v-wasm' + | 'text/elixir-wasm' | 'text/assembly' | 'application/json' | 'application/lua' diff --git a/src/livecodes/templates/starter/elixir-wasm-starter.ts b/src/livecodes/templates/starter/elixir-wasm-starter.ts new file mode 100644 index 0000000000..c40711e1e4 --- /dev/null +++ b/src/livecodes/templates/starter/elixir-wasm-starter.ts @@ -0,0 +1,86 @@ +import type { Template } from '../../models'; + +export const elixirWasmStarter: Template = { + name: 'elixir-wasm', + title: window.deps.translateString('templates.starter.elixir-wasm', 'Elixir Starter'), + thumbnail: 'assets/templates/elixir.svg', + activeEditor: 'script', + markup: { + language: 'html', + content: ` +
    +

    Hello, World!

    + +

    You clicked 0 times.

    + +
    + + +`.trimStart(), + }, + style: { + language: 'css', + content: ` +.container, +.container button { + text-align: center; + font: 1em sans-serif; +} +.logo { + width: 150px; +} +`.trimStart(), + }, + script: { + language: 'elixir-wasm', + content: ` +count = + case livecodes_input do + "" -> 0 + value -> String.to_integer(String.trim(value)) + end + +IO.puts("Elixir") +IO.puts(count + 1) +`.trimStart(), + }, +}; diff --git a/src/livecodes/templates/starter/index.ts b/src/livecodes/templates/starter/index.ts index c23b07f6e9..6dcee37419 100644 --- a/src/livecodes/templates/starter/index.ts +++ b/src/livecodes/templates/starter/index.ts @@ -27,6 +27,7 @@ import { daisyuiStarter } from './daisyui-starter'; import { dartStarter } from './dart-starter'; import { diagramsStarter } from './diagrams-starter'; import { duckdbStarter } from './duckdb-starter'; +import { elixirWasmStarter } from './elixir-wasm-starter'; import { elmStarter } from './elm-starter'; import { fanakStarter } from './fanak-starter'; import { fennelStarter } from './fennel-starter'; @@ -159,6 +160,7 @@ export const starterTemplates = [ cobolWasmStarter, fortranWasmStarter, dWasmStarter, + elixirWasmStarter, vWasmStarter, rustWasmStarter, swiftWasmStarter, diff --git a/src/livecodes/vendors.ts b/src/livecodes/vendors.ts index 3d91d88525..40da240416 100644 --- a/src/livecodes/vendors.ts +++ b/src/livecodes/vendors.ts @@ -152,6 +152,10 @@ export const duckdbWasmModuleUrl = /* @__PURE__ */ getModuleUrl('@duckdb/duckdb- export const ejsUrl = /* @__PURE__ */ getUrl('ejs@4.0.1/ejs.js'); +export const elixirWasmBaseUrl = /* @__PURE__ */ getUrl( + 'gh:live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8/wasm/', +); + export const elkjsBaseUrl = /* @__PURE__ */ getUrl('elkjs@0.8.2/lib/'); export const elmWasmBaseUrl = /* @__PURE__ */ getUrl('@live-codes/elm-wasm@0.2.1/dist/'); diff --git a/src/netlify.toml b/src/netlify.toml index 51d507f223..35de1cb8b0 100644 --- a/src/netlify.toml +++ b/src/netlify.toml @@ -2,3 +2,11 @@ for = "/*" [headers.values] access-control-allow-origin = "*" + # Cross-origin isolation (required by threaded Wasm runtimes such as + # Elixir on AtomVM, which runs in sandbox result frames). + # NOTE: COOP must be plain `same-origin`; `same-origin-allow-popups` + # does not yield an isolated context. This severs popup/opener links, + # so popup-based OAuth (Firebase signInWithPopup) needs a redirect flow. + cross-origin-opener-policy = "same-origin" + cross-origin-embedder-policy = "credentialless" + cross-origin-resource-policy = "cross-origin" diff --git a/src/sdk/__tests__/createPlayground.spec.ts b/src/sdk/__tests__/createPlayground.spec.ts index c3d8cb27a4..3c1c852684 100644 --- a/src/sdk/__tests__/createPlayground.spec.ts +++ b/src/sdk/__tests__/createPlayground.spec.ts @@ -265,6 +265,7 @@ describe('createPlayground – iframe creation', () => { expect(allow).toContain('writer'); expect(allow).toContain('rewriter'); expect(allow).toContain('language-detector'); + expect(allow).toContain('cross-origin-isolated'); // Should not contain Firefox/default-only attributes expect(allow).not.toContain('ambient-light-sensor'); expect(allow).not.toContain('payment'); diff --git a/src/sdk/internal.ts b/src/sdk/internal.ts index 57e12a07d0..0bc3aca52c 100644 --- a/src/sdk/internal.ts +++ b/src/sdk/internal.ts @@ -39,6 +39,7 @@ const allowAttributes: Record<'chrome' | 'firefox' | 'default', string[]> = { 'camera', 'clipboard-read', 'clipboard-write', + 'cross-origin-isolated', 'display-capture', 'encrypted-media', 'geolocation', @@ -63,6 +64,7 @@ const allowAttributes: Record<'chrome' | 'firefox' | 'default', string[]> = { 'accelerometer', 'ambient-light-sensor', 'camera', + 'cross-origin-isolated', 'display-capture', 'encrypted-media', 'geolocation', diff --git a/src/sdk/models.ts b/src/sdk/models.ts index 6d56b17a02..e2d0d2ec5a 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -250,6 +250,11 @@ export type Language = | 'wasm.fsx' | 'fanak' | 'fnk' + | 'elixir' + | 'ex' + | 'exs' + | 'elixir-wasm' + | 'wasm.ex' | 'rust' | 'rs' | 'rust-wasm' @@ -535,6 +540,7 @@ export type TemplateName = | 'odin-wasm' | 'cobol-wasm' | 'v-wasm' + | 'elixir-wasm' | 'java' | 'csharp-wasm' | 'blazor-wasm' @@ -967,6 +973,7 @@ export type CustomSettings = Partial< | 'text/go-wasm' | 'text/zig-wasm' | 'text/v-wasm' + | 'text/elixir-wasm' | 'application/json' | 'application/lua' | 'text/fennel' diff --git a/storybook/_stories/EmbedOptions/template.ts b/storybook/_stories/EmbedOptions/template.ts index 59fbed84e8..5e338c76d5 100644 --- a/storybook/_stories/EmbedOptions/template.ts +++ b/storybook/_stories/EmbedOptions/template.ts @@ -61,6 +61,7 @@ const storyDef: StoryDef = { CobolWasm: { props: { template: 'cobol-wasm' }, storyName: 'COBOL (Wasm)' }, FortranWasm: { props: { template: 'fortran-wasm' }, storyName: 'Fortran (Wasm)' }, VWasm: { props: { template: 'v-wasm' } }, + ElixirWasm: { props: { template: 'elixir-wasm' } }, RustWasm: { props: { template: 'rust-wasm' } }, SwiftWasm: { props: { template: 'swift-wasm' } }, ZigWasm: { props: { template: 'zig-wasm' } }, diff --git a/storybook/preact/stories/EmbedOptions/template.stories.ts b/storybook/preact/stories/EmbedOptions/template.stories.ts index 138f6369aa..d15d8c22d9 100644 --- a/storybook/preact/stories/EmbedOptions/template.stories.ts +++ b/storybook/preact/stories/EmbedOptions/template.stories.ts @@ -78,6 +78,7 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/react/stories/EmbedOptions/template.stories.ts b/storybook/react/stories/EmbedOptions/template.stories.ts index 138f6369aa..d15d8c22d9 100644 --- a/storybook/react/stories/EmbedOptions/template.stories.ts +++ b/storybook/react/stories/EmbedOptions/template.stories.ts @@ -78,6 +78,7 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/solid/stories/EmbedOptions/template.stories.ts b/storybook/solid/stories/EmbedOptions/template.stories.ts index 138f6369aa..d15d8c22d9 100644 --- a/storybook/solid/stories/EmbedOptions/template.stories.ts +++ b/storybook/solid/stories/EmbedOptions/template.stories.ts @@ -78,6 +78,7 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/svelte/stories/EmbedOptions/template.stories.ts b/storybook/svelte/stories/EmbedOptions/template.stories.ts index 138f6369aa..d15d8c22d9 100644 --- a/storybook/svelte/stories/EmbedOptions/template.stories.ts +++ b/storybook/svelte/stories/EmbedOptions/template.stories.ts @@ -78,6 +78,7 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/vue/stories/EmbedOptions/template.stories.ts b/storybook/vue/stories/EmbedOptions/template.stories.ts index 138f6369aa..d15d8c22d9 100644 --- a/storybook/vue/stories/EmbedOptions/template.stories.ts +++ b/storybook/vue/stories/EmbedOptions/template.stories.ts @@ -78,6 +78,7 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/web-components/stories/EmbedOptions/template.stories.ts b/storybook/web-components/stories/EmbedOptions/template.stories.ts index 138f6369aa..d15d8c22d9 100644 --- a/storybook/web-components/stories/EmbedOptions/template.stories.ts +++ b/storybook/web-components/stories/EmbedOptions/template.stories.ts @@ -78,6 +78,7 @@ CobolWasm.storyName = 'COBOL (Wasm)'; export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); +export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/tsconfig.json b/tsconfig.json index fa90769661..a2bcbf7ebd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -55,7 +55,8 @@ "src/sdk/react.tsx", "src/livecodes/languages/fsharp-wasm/lang-fsharp-wasm-worker.raw.js", "src/livecodes/languages/haskell-wasm/lang-haskell-wasm-worker.raw.js", - "src/livecodes/languages/vb-wasm/lang-vb-wasm-worker.raw.js" + "src/livecodes/languages/vb-wasm/lang-vb-wasm-worker.raw.js", + "src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js" ], "exclude": ["**/node_modules/**", "src/livecodes/i18n/locales/**"] } diff --git a/vendor-licenses.md b/vendor-licenses.md index 856b4f70db..4bb8adf1c2 100644 --- a/vendor-licenses.md +++ b/vendor-licenses.md @@ -380,6 +380,12 @@ LFortran (Wasm): [BSD 3-Clause License](https://github.com/lfortran/lfortran/blo V (Wasm): [MIT License](https://github.com/vlang/v/blob/master/LICENSE) (the V compiler and its standard library, compiled to WebAssembly by @live-codes/v-wasm; the Clang toolchain it links through is covered by LLVM / LLD below) +Elixir (Wasm): [Apache License 2.0](https://github.com/elixir-lang/elixir/blob/main/LICENSE) (the Elixir 1.17.3 compiler and standard library, packaged for browser execution) + +Popcorn: [Apache License 2.0](https://github.com/software-mansion/popcorn/blob/main/LICENSE) (the browser bridge and AtomVM packaging used by the pinned Elixir runtime) + +AtomVM: [Apache License 2.0](https://github.com/atomvm/AtomVM/blob/main/LICENSE) (the lightweight Erlang VM running the compiled Elixir code in WebAssembly) + @live-codes/janet-wasm: [MIT License](https://github.com/live-codes/browser-janet/blob/main/packages/janet-wasm/LICENSE) Janet (Wasm): [MIT License](https://github.com/janet-lang/janet/blob/master/LICENSE) (the Janet interpreter, compiled to WebAssembly by @live-codes/janet-wasm) From 6c4f759ca6a39e2d606368075424759cb71687a8 Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Tue, 20 Oct 2026 22:50:47 +0300 Subject: [PATCH 02/12] feat: use the official Elixir logo in the starter Replace the placeholder Ex badge with the official Elixir drop icon from elixir-lang.org, and update the starter and docs thumbnail references. --- docs/src/components/TemplateList.tsx | 2 +- src/livecodes/assets/templates/elixir.png | Bin 0 -> 18252 bytes src/livecodes/assets/templates/elixir.svg | 4 ---- .../templates/starter/elixir-wasm-starter.ts | 4 ++-- vendor-licenses.md | 2 ++ 5 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 src/livecodes/assets/templates/elixir.png delete mode 100644 src/livecodes/assets/templates/elixir.svg diff --git a/docs/src/components/TemplateList.tsx b/docs/src/components/TemplateList.tsx index 57d16b5501..6e7e157fc8 100644 --- a/docs/src/components/TemplateList.tsx +++ b/docs/src/components/TemplateList.tsx @@ -58,7 +58,7 @@ const templates = [ { name: 'crystal-wasm', title: 'Crystal Starter', thumbnail: 'crystal.svg' }, { name: 'odin-wasm', title: 'Odin Starter', thumbnail: 'odin.svg' }, { name: 'v-wasm', title: 'V Starter', thumbnail: 'v.svg' }, - { name: 'elixir-wasm', title: 'Elixir Starter', thumbnail: 'elixir.svg' }, + { name: 'elixir-wasm', title: 'Elixir Starter', thumbnail: 'elixir.png' }, { name: 'cpp-wasm', title: 'C++ (Wasm) Starter', thumbnail: 'cpp.svg' }, { name: 'objc-wasm', title: 'Objective-C (Wasm) Starter', thumbnail: 'objective-c.svg' }, { name: 'rust-wasm', title: 'Rust (Wasm) Starter', thumbnail: 'rust.svg' }, diff --git a/src/livecodes/assets/templates/elixir.png b/src/livecodes/assets/templates/elixir.png new file mode 100644 index 0000000000000000000000000000000000000000..5d49f99748d0a7af1b0f01faa8b04bf6af35f19b GIT binary patch literal 18252 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 Ef=4|v&j0`b literal 0 HcmV?d00001 diff --git a/src/livecodes/assets/templates/elixir.svg b/src/livecodes/assets/templates/elixir.svg deleted file mode 100644 index 9d72a096ba..0000000000 --- a/src/livecodes/assets/templates/elixir.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - Ex - diff --git a/src/livecodes/templates/starter/elixir-wasm-starter.ts b/src/livecodes/templates/starter/elixir-wasm-starter.ts index c40711e1e4..d020ce1d83 100644 --- a/src/livecodes/templates/starter/elixir-wasm-starter.ts +++ b/src/livecodes/templates/starter/elixir-wasm-starter.ts @@ -3,14 +3,14 @@ import type { Template } from '../../models'; export const elixirWasmStarter: Template = { name: 'elixir-wasm', title: window.deps.translateString('templates.starter.elixir-wasm', 'Elixir Starter'), - thumbnail: 'assets/templates/elixir.svg', + thumbnail: 'assets/templates/elixir.png', activeEditor: 'script', markup: { language: 'html', content: `

    Hello, World!

    - +

    You clicked 0 times.

    diff --git a/vendor-licenses.md b/vendor-licenses.md index 4bb8adf1c2..c1b481c533 100644 --- a/vendor-licenses.md +++ b/vendor-licenses.md @@ -382,6 +382,8 @@ V (Wasm): [MIT License](https://github.com/vlang/v/blob/master/LICENSE) (the V c Elixir (Wasm): [Apache License 2.0](https://github.com/elixir-lang/elixir/blob/main/LICENSE) (the Elixir 1.17.3 compiler and standard library, packaged for browser execution) +Elixir logo: registered trademark of The Elixir Team, used under [nominative use](https://elixir-lang.org/trademarks/) to identify the supported language. + Popcorn: [Apache License 2.0](https://github.com/software-mansion/popcorn/blob/main/LICENSE) (the browser bridge and AtomVM packaging used by the pinned Elixir runtime) AtomVM: [Apache License 2.0](https://github.com/atomvm/AtomVM/blob/main/LICENSE) (the lightweight Erlang VM running the compiled Elixir code in WebAssembly) From e1a6fe7788394b5c35892b4fe07d3729f7b7e75c Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Tue, 20 Oct 2026 23:16:17 +0300 Subject: [PATCH 03/12] chore: annotate the Elixir worker's intentional eval Add a NOSONAR annotation with the reason for the indirect eval used by AtomVM's JS interop. This addresses the SonarCloud security gate without changing runtime behavior. --- .../languages/elixir-wasm/lang-elixir-wasm-worker.raw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js index 1076118a07..a81e02225e 100644 --- a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js +++ b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js @@ -226,7 +226,7 @@ async function startVm(avmBundle, init) { let fn; try { const indirectEval = eval; - fn = indirectEval(scriptString); + fn = indirectEval(scriptString); // NOSONAR - required by AtomVM's sandboxed JS interop } catch (e) { send(MESSAGES.STDERR, `onRunTrackedJs eval failed: ${(e && (e.stack || e.message)) || e}\n`); return null; From a6d122f118965d94620bd945d407a0b95839a29e Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Fri, 23 Oct 2026 15:11:19 +0300 Subject: [PATCH 04/12] feat: run Beam Wasm without cross-origin isolation and add Erlang --- .../language-support/references/languages.md | 1 + README.md | 2 +- docs/docs/languages/elixir-wasm.mdx | 23 +- docs/docs/languages/erlang-wasm.mdx | 150 +++ docs/src/components/LanguageSliders.tsx | 1 + docs/src/components/TemplateList.tsx | 1 + e2e/specs/elixir-wasm.spec.ts | 25 +- e2e/specs/erlang-wasm.spec.ts | 200 ++++ functions/vendors/templates.js | 1 + scripts/app-headers.js | 16 +- scripts/build.js | 1 + scripts/sandbox-headers.js | 16 +- server/php/inc/starter-templates.json | 1 + src/_headers | 2 - src/livecodes/UI/command-menu-actions.ts | 1 + src/livecodes/assets/templates/erlang.svg | 19 + src/livecodes/html/language-info.html | 32 + .../locales/en/language-info.lokalise.json | 12 + .../i18n/locales/en/language-info.ts | 5 + .../i18n/locales/en/translation.lokalise.json | 4 + src/livecodes/i18n/locales/en/translation.ts | 1 + .../lang-beam-wasm-worker.raw.js} | 38 +- src/livecodes/languages/beam-wasm/prelude.ts | 41 + src/livecodes/languages/beam-wasm/runtime.ts | 856 +++++++++++++++++ .../__tests__/lang-elixir-wasm.spec.ts | 14 +- .../languages/elixir-wasm/benchmark.md | 6 +- .../elixir-wasm/lang-elixir-wasm-script.ts | 887 +----------------- .../__tests__/lang-erlang-wasm.spec.ts | 56 ++ src/livecodes/languages/erlang-wasm/index.ts | 1 + .../erlang-wasm/lang-erlang-wasm-script.ts | 64 ++ .../languages/erlang-wasm/lang-erlang-wasm.ts | 29 + src/livecodes/languages/languages.ts | 2 + src/livecodes/models.ts | 1 + .../templates/starter/elixir-wasm-starter.ts | 8 +- .../templates/starter/erlang-wasm-starter.ts | 89 ++ src/livecodes/templates/starter/index.ts | 2 + src/livecodes/vendors.ts | 4 +- src/netlify.toml | 7 - src/sdk/internal.ts | 2 - src/sdk/models.ts | 6 + storybook/_stories/EmbedOptions/template.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + .../stories/EmbedOptions/template.stories.ts | 1 + vendor-licenses.md | 2 + 48 files changed, 1663 insertions(+), 973 deletions(-) create mode 100644 docs/docs/languages/erlang-wasm.mdx create mode 100644 e2e/specs/erlang-wasm.spec.ts create mode 100644 src/livecodes/assets/templates/erlang.svg rename src/livecodes/languages/{elixir-wasm/lang-elixir-wasm-worker.raw.js => beam-wasm/lang-beam-wasm-worker.raw.js} (86%) create mode 100644 src/livecodes/languages/beam-wasm/prelude.ts create mode 100644 src/livecodes/languages/beam-wasm/runtime.ts create mode 100644 src/livecodes/languages/erlang-wasm/__tests__/lang-erlang-wasm.spec.ts create mode 100644 src/livecodes/languages/erlang-wasm/index.ts create mode 100644 src/livecodes/languages/erlang-wasm/lang-erlang-wasm-script.ts create mode 100644 src/livecodes/languages/erlang-wasm/lang-erlang-wasm.ts create mode 100644 src/livecodes/templates/starter/erlang-wasm-starter.ts diff --git a/.agents/skills/livecodes/language-support/references/languages.md b/.agents/skills/livecodes/language-support/references/languages.md index 67ecd2b51f..1ab8c26d26 100644 --- a/.agents/skills/livecodes/language-support/references/languages.md +++ b/.agents/skills/livecodes/language-support/references/languages.md @@ -155,6 +155,7 @@ Enabled via `processors` config: | V (Wasm) | `v-wasm`, `v`, `vlang`, `wasm.v`, `vsh` | V compiler in WASM | | Janet (Wasm) | `janet-wasm`, `janet`, `wasm.janet` | Janet interpreter (WASM) | | Elixir (Wasm) | `elixir-wasm`, `ex`, `exs`, `elixir`, `wasm.ex` | Elixir compiler in WASM | +| Erlang (Wasm) | `erlang-wasm`, `erl`, `erlang`, `wasm.erl` | Erlang runtime in WASM (AtomVM) | | Tcl | `tcl` | Tcl interpreter | | WebAssembly | `wat`, `wast`, `wasm`, `webassembly` | WAT format | | Assembly (Wasm) | `assembly-wasm`, `assembly`, `asm`, `asm-wasm`, `x86`, `x86-64`, `nasm`, `wasm.asm` | Keystone + Unicorn (WASM), x86-64 assembly | diff --git a/README.md b/README.md index d1e9a9b460..5d5d131538 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A [feature-rich](https://livecodes.io/docs/features/), open-source, **client-sid [![LiveCodes: npm version](https://img.shields.io/npm/v/livecodes)](https://www.npmjs.com/package/livecodes) [![LiveCodes: npm downloads](https://img.shields.io/npm/dm/livecodes)](https://www.npmjs.com/package/livecodes) [![LiveCodes: jsdelivr downloads](https://data.jsdelivr.com/v1/package/npm/livecodes/badge?style=rounded)](https://www.jsdelivr.com/package/npm/livecodes) -[![LiveCodes: languages](https://img.shields.io/badge/languages-126-blue)](https://livecodes.io/docs/languages/) +[![LiveCodes: languages](https://img.shields.io/badge/languages-127-blue)](https://livecodes.io/docs/languages/) [![LiveCodes: docs](https://img.shields.io/badge/Documentation-575757?logo=gitbook&logoColor=white)](https://livecodes.io/docs/) [![LiveCodes: llms.txt](https://img.shields.io/badge/llms.txt-575757?logo=googledocs&logoColor=white)](https://livecodes.io/docs/llms.txt) [![LiveCodes: llms-full.txt](https://img.shields.io/badge/llms--full.txt-575757?logo=googledocs&logoColor=white)](https://livecodes.io/docs/llms-full.txt) diff --git a/docs/docs/languages/elixir-wasm.mdx b/docs/docs/languages/elixir-wasm.mdx index 626600c1ff..b2e8223b48 100644 --- a/docs/docs/languages/elixir-wasm.mdx +++ b/docs/docs/languages/elixir-wasm.mdx @@ -27,8 +27,8 @@ export const elixirConfig = { ### Communication with JavaScript -The program runs in the context of the result page. A few helpers are available in the browser global -`livecodes.elixir` object: +The program runs in a persistent worker owned by the result page, not in the result page's JavaScript +context. The browser global `livecodes.elixir` object is the result page's interface to that worker: - `livecodes.elixir.input`: the standard input passed to the program. - `livecodes.elixir.loaded`: a promise that resolves after the runtime has downloaded and the first @@ -87,7 +87,9 @@ reported in `error`; the next run starts a fresh worker automatically. Use The Elixir toolchain compiled to WebAssembly, running on [AtomVM](https://github.com/atomvm/AtomVM) with the -[Popcorn](https://popcorn.swmansion.com/) browser runtime. The assets are pinned to +[Popcorn](https://popcorn.swmansion.com/) browser runtime. The single-threaded runtime is published +as [`@hmh-3080/atomvm-wasm`](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm); the Elixir +and Erlang bytecode bundle is pinned to `live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8`. ### Version @@ -103,17 +105,9 @@ Examples include `File.write!/2` (`not_implemented`), `Date.utc_today/0`, `Task. exit status; the runtime reports the crash and the next run boots a fresh worker. Frameworks that require a server, such as Phoenix and LiveView, are out of scope for the playground. -The runtime uses WebAssembly threads, so the result page must be cross-origin isolated -(`SharedArrayBuffer` is unavailable otherwise) and the environment never finishes loading -without it. This needs `Cross-Origin-Opener-Policy: same-origin` / -`Cross-Origin-Embedder-Policy: credentialless` headers on both the app and the sandbox -origin, plus the `cross-origin-isolated` iframe permission (which LiveCodes sets -automatically). Note that plain `same-origin` (not `same-origin-allow-popups`) is required -for isolation, which severs popup/opener links — popup-based OAuth logins need a redirect -flow instead. The dev servers and deployment configs in this repo already send these headers; -other self-hosted setups and the production sandbox deployment must send them too. Browsers -without `credentialless` support (e.g. Safari) cannot isolate and are not supported for this -language. +The runtime is a single-threaded AtomVM build. It does not use WebAssembly threads, +`SharedArrayBuffer`, or `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` +headers, so it runs in normal (non-isolated) result pages and keeps popup-based OAuth working. ## Code Formatting @@ -147,4 +141,5 @@ https://livecodes.io/?template=elixir-wasm - [Elixir documentation](https://hexdocs.pm/elixir/) - [Learn X in Y minutes, where X=Elixir](https://learnxinyminutes.com/elixir/) - [AtomVM](https://atomvm.org/) +- [@hmh-3080/atomvm-wasm](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm) - [Popcorn: Elixir and Wasm](https://popcorn.swmansion.com/) diff --git a/docs/docs/languages/erlang-wasm.mdx b/docs/docs/languages/erlang-wasm.mdx new file mode 100644 index 0000000000..5fe3da6696 --- /dev/null +++ b/docs/docs/languages/erlang-wasm.mdx @@ -0,0 +1,150 @@ +# Erlang (Wasm) + +Erlang is a functional, concurrent language for building scalable and fault-tolerant systems. + +In LiveCodes, Erlang runs in the browser using WebAssembly on [AtomVM](https://atomvm.org/), the same lightweight Erlang VM used by [Elixir (Wasm)](./elixir-wasm.mdx). No server is involved. + +## Usage + +Demo: + +import LiveCodes from '../../src/components/LiveCodes.tsx'; + +export const erlangConfig = { + activeEditor: 'script', + script: { + language: 'erlang-wasm', + content: `io:format("Hello, Erlang!~n").`, + }, + mode: 'simple', + editor: 'auto', + tools: { + status: 'full', + }, +}; + + + +### Communication with JavaScript + +The program runs in a persistent worker owned by the result page, not in the result page's JavaScript +context. The browser global `livecodes.erlang` object is the result page's interface to that worker: + +- `livecodes.erlang.input`: the standard input passed to the program. +- `livecodes.erlang.loaded`: a promise that resolves after the runtime has downloaded and the first + program run has completed. It rejects if the environment fails to load. Other helpers should be + used after it resolves. +- `livecodes.erlang.output`: the program's standard output. +- `livecodes.erlang.value`: the inspected return value of the program. +- `livecodes.erlang.warnings`: warnings emitted while compiling or running the program. +- `livecodes.erlang.error`: the syntax or runtime error, if the program failed. +- `livecodes.erlang.exitCode`: the exit code of the program. +- `livecodes.erlang.run`: runs the current code with new input, reusing the booted VM, and returns a + promise that resolves with `output`, `value`, `warnings`, `error` and `exitCode`. +- `livecodes.erlang.retry`: restarts the runtime after a failed initialization. A VM crash in a + normal run is recovered automatically on the next run. + +Example: + + + +```erlang +io:format("Hello, Erlang!~n"), +[X * X || X <- lists:seq(1, 5)]. +``` + +### Modules and stdin + +Erlang expressions are comma-separated and must end with a dot. Code that starts with `-module(` +is compiled and loaded into the running VM, so it stays callable in later runs: + +```erlang +-module(m). +-export([double/1]). + +double(X) -> X * 2. +``` + +Then run `m:double(21).` in a later run. `io:get_line("")` reads the standard input, and +`erlang:halt(0)` (or a bare `halt(0)`) sets the exit code. + +The shared stdin device returns binaries. Use `binary_to_list/1` when an API needs a charlist, +and remove the trailing newline with `binary:replace(Value, <<"\n">>, <<>>, [global])` +(`string:trim/1` is not reliable on AtomVM). + +### Runtime lifecycle + +The VM runs in a persistent worker owned by the result window. Updating the code +rewrites the result document but does not destroy the worker, so normal edits +reuse the booted VM instead of downloading and initializing it again. + +Each worker boot compiles a small Elixir prelude that provides the shared I/O +device for Erlang and Elixir. That takes a few seconds on the first run; +subsequent runs only evaluate the user code. Evaluations time out after 60 +seconds. If a newer run is waiting and the active evaluation does not finish +within 10 seconds, the worker is terminated and recreated so the newer run is +not blocked. Pending edits are coalesced to the latest one. A VM crash is +reported in `error`; the next run starts a fresh worker automatically. Use +`retry()` when initialization itself failed. + +## Language Info + +### Name + +`erlang-wasm` + +### Aliases / Extensions + +`erl`, `erlang`, `erlang-wasm`, `wasm.erl` + +### Editor + +`script` + +## Compiler + +The Erlang runtime compiled to WebAssembly, running on +[AtomVM](https://github.com/atomvm/AtomVM) with the +[Popcorn](https://popcorn.swmansion.com/) browser runtime. The single-threaded runtime is published +as [`@hmh-3080/atomvm-wasm`](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm); it shares the +pinned `live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8` bytecode bundle +with Elixir (Wasm). + +### Version + +AtomVM 0.7.0-dev (32-bit), with the Erlang runtime bundled in the shared Elixir/Erlang Wasm bundle. + +### Limitations + +AtomVM implements a subset of the Erlang VM and its standard library. Features that depend on a full +operating system (for example file I/O and OS processes) may be missing or behave differently. +Frameworks and applications that require a server are out of scope for the playground. + +The runtime is a single-threaded AtomVM build. It does not use WebAssembly threads, +`SharedArrayBuffer`, or `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy` +headers, so it runs in normal (non-isolated) result pages and keeps popup-based OAuth working. + +## Code Formatting + +Not currently supported. + +## Live Reload + +By default, new code changes are sent to the result page for re-evaluation without a full page reload +or a VM restart. This behavior can be disabled by adding the code comment `% __livecodes_reload__` to +the Erlang code, which forces a full page reload. + +This comment can be added in the `hiddenContent` property of the editor for embedded playgrounds. + +## Starter Template + +https://livecodes.io/?template=erlang-wasm + +## Links + +- [Erlang official website](https://www.erlang.org/) +- [Erlang documentation](https://www.erlang.org/docs) +- [Learn X in Y minutes, where X=Erlang](https://learnxinyminutes.com/erlang/) +- [AtomVM](https://atomvm.org/) +- [@hmh-3080/atomvm-wasm](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm) +- [Popcorn: Elixir and Wasm](https://popcorn.swmansion.com/) diff --git a/docs/src/components/LanguageSliders.tsx b/docs/src/components/LanguageSliders.tsx index 807ba90ff6..aa3981aad0 100644 --- a/docs/src/components/LanguageSliders.tsx +++ b/docs/src/components/LanguageSliders.tsx @@ -105,6 +105,7 @@ export default function Sliders() { { name: 'flutter', title: 'Flutter' }, { name: 'v-wasm', title: 'V (Wasm)' }, { name: 'elixir-wasm', title: 'Elixir (Wasm)' }, + { name: 'erlang-wasm', title: 'Erlang (Wasm)' }, { name: 'rust-wasm', title: 'Rust (Wasm)' }, { name: 'swift-wasm', title: 'Swift (Wasm)' }, { name: 'haskell', title: 'Haskell' }, diff --git a/docs/src/components/TemplateList.tsx b/docs/src/components/TemplateList.tsx index 6e7e157fc8..c94667019b 100644 --- a/docs/src/components/TemplateList.tsx +++ b/docs/src/components/TemplateList.tsx @@ -59,6 +59,7 @@ const templates = [ { name: 'odin-wasm', title: 'Odin Starter', thumbnail: 'odin.svg' }, { name: 'v-wasm', title: 'V Starter', thumbnail: 'v.svg' }, { name: 'elixir-wasm', title: 'Elixir Starter', thumbnail: 'elixir.png' }, + { name: 'erlang-wasm', title: 'Erlang Starter', thumbnail: 'erlang.svg' }, { name: 'cpp-wasm', title: 'C++ (Wasm) Starter', thumbnail: 'cpp.svg' }, { name: 'objc-wasm', title: 'Objective-C (Wasm) Starter', thumbnail: 'objective-c.svg' }, { name: 'rust-wasm', title: 'Rust (Wasm) Starter', thumbnail: 'rust.svg' }, diff --git a/e2e/specs/elixir-wasm.spec.ts b/e2e/specs/elixir-wasm.spec.ts index a2badaef07..6792a763af 100644 --- a/e2e/specs/elixir-wasm.spec.ts +++ b/e2e/specs/elixir-wasm.spec.ts @@ -181,7 +181,7 @@ test.describe('Elixir (Wasm)', () => { isolated: window.crossOriginIsolated, sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', })); - expect(isolation).toEqual({ isolated: true, sharedArrayBuffer: true }); + expect(isolation).toEqual({ isolated: false, sharedArrayBuffer: false }); const warm = await requireResultFrame(page).evaluate(async () => { const elixir = (window as any).livecodes.elixir; @@ -377,9 +377,7 @@ test.describe('Elixir (Wasm)', () => { expect(rerun.error).toBeNull(); }); - test('serves the result frame with production cross-origin isolation headers', async ({ - page, - }) => { + test('runs the result frame in the dev sandbox without isolation headers', async ({ page }) => { await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); const frame = await (async () => { const start = Date.now(); @@ -393,19 +391,24 @@ test.describe('Elixir (Wasm)', () => { const response = await page.request.get(frame.url()); const headers = response.headers(); - expect(headers['cross-origin-opener-policy']).toBe('same-origin'); - expect(headers['cross-origin-embedder-policy']).toBe('credentialless'); + expect(headers['cross-origin-opener-policy']).toBeUndefined(); + expect(headers['cross-origin-embedder-policy']).toBeUndefined(); expect(headers['cross-origin-resource-policy']).toBe('cross-origin'); const isolated = await frame.evaluate(() => window.crossOriginIsolated); - expect(isolated).toBe(true); + expect(isolated).toBe(false); + }); - // `src/_headers` is the production/static-host configuration; the dev - // server middleware sends the same values. + test('does not declare isolation headers in the static deploy configs', async () => { const { readFileSync } = await import('fs'); const productionHeaders = readFileSync('src/_headers', 'utf8'); - expect(productionHeaders).toContain('Cross-Origin-Opener-Policy: same-origin'); - expect(productionHeaders).toContain('Cross-Origin-Embedder-Policy: credentialless'); + expect(productionHeaders).not.toContain('Cross-Origin-Opener-Policy'); + expect(productionHeaders).not.toContain('Cross-Origin-Embedder-Policy'); expect(productionHeaders).toContain('Cross-Origin-Resource-Policy: cross-origin'); + + const netlifyConfig = readFileSync('src/netlify.toml', 'utf8'); + expect(netlifyConfig).not.toContain('cross-origin-opener-policy'); + expect(netlifyConfig).not.toContain('cross-origin-embedder-policy'); + expect(netlifyConfig).toContain('cross-origin-resource-policy = "cross-origin"'); }); }); diff --git a/e2e/specs/erlang-wasm.spec.ts b/e2e/specs/erlang-wasm.spec.ts new file mode 100644 index 0000000000..7c4fba304b --- /dev/null +++ b/e2e/specs/erlang-wasm.spec.ts @@ -0,0 +1,200 @@ +import { expect, type Frame, type Page } from '@playwright/test'; +import { test } from '../test-fixtures'; + +const APP_URL = `${process.env.TEST_URL || 'http://localhost:8080'}?template=erlang-wasm`; + +interface RunEnd { + runId?: number; + exitCode?: number; + latest?: boolean; + hasError?: boolean; + empty?: boolean; +} + +interface ErlangState { + evals?: number; + runId?: number; + ready?: boolean; + failed?: boolean; + output?: string | null; + value?: string | null; + warnings?: string | null; + error?: string | null; + exitCode?: number | null; + popcornReady?: boolean; + timings: Array<{ name: string; detail?: Record }>; +} + +const getResultFrame = (page: Page): Frame | null => page.frame('result'); + +const requireResultFrame = (page: Page): Frame => { + const frame = getResultFrame(page); + if (frame == null) throw new Error('Result frame is not available'); + return frame; +}; + +const readState = async (page: Page): Promise => { + const frame = getResultFrame(page); + if (frame == null) return null; + try { + return await frame.evaluate(() => { + const erlang = (window as any).livecodes?.erlang; + if (erlang == null) return null; + return { + evals: erlang.evals, + runId: erlang.runId, + ready: erlang.ready, + failed: erlang.failed, + output: erlang.output, + value: erlang.value, + warnings: erlang.warnings, + error: erlang.error, + exitCode: erlang.exitCode, + popcornReady: erlang.popcorn?.isReady, + timings: erlang.timings ?? [], + }; + }); + } catch { + // LiveReload may be replacing the document; retry from the caller. + return null; + } +}; + +const latestRunEnd = (state: ErlangState, runId: number): RunEnd | undefined => + [...state.timings] + .reverse() + .find((timing) => timing.name === 'run-end' && timing.detail?.runId === runId)?.detail; + +const waitForState = async ( + page: Page, + predicate: (state: ErlangState) => boolean, + timeout = 120_000, +): Promise => { + const start = Date.now(); + let last: ErlangState | null = null; + while (Date.now() - start < timeout) { + last = await readState(page); + if (last != null && predicate(last)) return last; + await page.waitForTimeout(25); + } + throw new Error(`Timed out waiting for Erlang state: ${JSON.stringify(last)}`); +}; + +const waitForRun = async (page: Page, runId: number, timeout = 120_000) => + waitForState( + page, + (state) => + latestRunEnd(state, runId)?.latest === true || + state.timings.some( + (timing) => + timing.name === 'run-error' && + timing.detail?.runId === runId && + timing.detail?.latest === true, + ), + timeout, + ); + +const installApiHook = async (page: Page) => { + await page.addInitScript(() => { + window.addEventListener('livecodes-app-loaded', (event) => { + (window as any).livecodesApi = (event as CustomEvent).detail; + }); + }); +}; + +const waitForApi = async (page: Page) => { + await page.waitForFunction(() => (window as any).livecodesApi != null, undefined, { + timeout: 30_000, + }); +}; + +const setSource = async (frame: Frame, source: string) => { + await frame.evaluate((content) => { + const scripts = document.querySelectorAll('script[type="text/erlang-wasm"]'); + if (scripts.length === 0) throw new Error('No Erlang script tag found'); + scripts[scripts.length - 1].textContent = content; + }, source); +}; + +const runDirect = async (page: Page, source: string, input?: string) => { + const frame = requireResultFrame(page); + await setSource(frame, source); + const runId = await frame.evaluate((value) => { + const erlang = (window as any).livecodes.erlang; + if (value === undefined) { + void erlang.run(); + } else { + void erlang.run(value); + } + return erlang.runId; + }, input); + return waitForRun(page, runId); +}; + +test.describe('Erlang (Wasm)', () => { + test.describe.configure({ timeout: 120_000 }); + + test('runs expressions, modules, stdin, errors, and exits', async ({ page }) => { + await installApiHook(page); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await waitForApi(page); + const initial = await waitForRun(page, 1, 180_000); + + expect(initial.ready).toBe(true); + expect(initial.failed).toBe(false); + expect(initial.output).toBe('Erlang\n0'); + expect(initial.error).toBeNull(); + + const isolation = await requireResultFrame(page).evaluate(() => ({ + isolated: window.crossOriginIsolated, + sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', + })); + expect(isolation).toEqual({ isolated: false, sharedArrayBuffer: false }); + + const warm = await requireResultFrame(page).evaluate(async () => { + const erlang = (window as any).livecodes.erlang; + const start = performance.now(); + const result = await erlang.run(); + return { ms: performance.now() - start, ...result }; + }); + expect(warm.output).toBe('Erlang\n0'); + expect(warm.ms).toBeLessThan(5_000); + + const exit = await runDirect(page, 'erlang:halt(3).'); + expect(exit.exitCode).toBe(3); + expect(exit.error).toBeNull(); + expect(exit.failed).toBe(false); + + const hello = await runDirect(page, 'io:format("hello~n").'); + expect(hello.output).toBe('hello'); + expect(hello.error).toBeNull(); + + const value = await runDirect(page, '1 + 1.'); + expect(value.output).toBeNull(); + expect(value.value).toBe('2'); + expect(value.exitCode).toBe(0); + + const stdin = await runDirect(page, 'io:format("~s", [io:get_line("")]).', 'hello'); + expect(stdin.output).toBe('hello'); + expect(stdin.error).toBeNull(); + + const moduleRun = await runDirect( + page, + '-module(m).\n-export([double/1]).\n\ndouble(X) -> X * 2.\n', + ); + expect(moduleRun.error).toBeNull(); + const moduleCall = await runDirect(page, 'm:double(21).'); + expect(moduleCall.value).toBe('42'); + expect(moduleCall.error).toBeNull(); + + const runtime = await runDirect(page, '1/0.'); + expect(runtime.error).toContain('badarith'); + expect(runtime.exitCode).toBe(1); + expect(runtime.failed).toBe(false); + + const empty = await runDirect(page, ''); + expect(empty.output).toBeNull(); + expect(empty.error).toBeNull(); + expect(empty.exitCode).toBe(0); + }); +}); diff --git a/functions/vendors/templates.js b/functions/vendors/templates.js index da4d68a638..51706dd5f7 100644 --- a/functions/vendors/templates.js +++ b/functions/vendors/templates.js @@ -60,6 +60,7 @@ export const starterTemplates = { "fortran-wasm": "Fortran Starter", "d-wasm": "D Starter", "elixir-wasm": "Elixir Starter", + "erlang-wasm": "Erlang Starter", "v-wasm": "V Starter", "rust-wasm": "Rust (Wasm) Starter", "swift-wasm": "Swift (Wasm) Starter", diff --git a/scripts/app-headers.js b/scripts/app-headers.js index c5b6238f56..3a1a5192a4 100644 --- a/scripts/app-headers.js +++ b/scripts/app-headers.js @@ -1,16 +1,8 @@ // Connect middleware for the dev app server (`npm run serve:app`). -// It sends cross-origin isolation headers so the app page is -// `crossOriginIsolated`. Embedded sandbox (result/compiler) frames can only -// be isolated when the embedding page is isolated too, and threaded Wasm -// runtimes (e.g. Elixir on AtomVM) need isolation to boot. -// -// NOTE: this must be plain `same-origin`. `same-origin-allow-popups` does NOT -// produce an isolated context in browsers (verified), so it cannot be used -// here even though it would preserve OAuth popups. `credentialless` (instead -// of `require-corp`) keeps third-party CDN resources loading without CORP. -module.exports = function appIsolationHeaders(req, res, next) { - res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); - res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless'); +// Cross-origin isolation is deliberately not enabled: the Beam (Elixir/Erlang) +// runtime uses a single-threaded AtomVM build and does not need +// SharedArrayBuffer. Keeping COOP/COEP off also preserves popup-based OAuth. +module.exports = function appHeaders(req, res, next) { // The app is designed to be embedded cross-origin (SDK). Without an // explicit CORP header, COEP defaults it to same-origin and cross-origin // embeds are blocked (net::ERR_BLOCKED_BY_RESPONSE). diff --git a/scripts/build.js b/scripts/build.js index 0ee6f5de30..3309c2976c 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -239,6 +239,7 @@ const iifeBuild = () => 'languages/cobol-wasm/lang-cobol-wasm-script.ts', 'languages/v-wasm/lang-v-wasm-script.ts', 'languages/elixir-wasm/lang-elixir-wasm-script.ts', + 'languages/erlang-wasm/lang-erlang-wasm-script.ts', 'languages/go-wasm/lang-go-wasm-script.ts', 'languages/csharp-wasm/lang-csharp-wasm-script.ts', 'languages/fanak/lang-fanak-script.ts', diff --git a/scripts/sandbox-headers.js b/scripts/sandbox-headers.js index 0facf7e094..b72716b2b9 100644 --- a/scripts/sandbox-headers.js +++ b/scripts/sandbox-headers.js @@ -1,16 +1,8 @@ // Connect middleware for the dev sandbox server (`npm run serve:sandbox`). -// It sends cross-origin isolation headers so that result/compiler pages are -// `crossOriginIsolated`, which enables `SharedArrayBuffer`. Threaded Wasm -// runtimes (e.g. Elixir on AtomVM) need it to boot. -// -// `credentialless` (instead of `require-corp`) is used so that third-party -// CDN resources without CORP headers keep loading; browsers that do not -// support it simply ignore it and behave as before. -module.exports = function sandboxIsolationHeaders(req, res, next) { - res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); - res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless'); - // The sandbox is meant to be embedded cross-origin (the app embeds result - // and compiler frames), so opt into that explicitly. +// The sandbox is embedded cross-origin by the app, so allow that explicitly. +// No COOP/COEP: the Beam runtime is single-threaded and does not require +// cross-origin isolation. +module.exports = function sandboxHeaders(req, res, next) { res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); next(); }; diff --git a/server/php/inc/starter-templates.json b/server/php/inc/starter-templates.json index 3d95c8be64..5ffd8e16e3 100644 --- a/server/php/inc/starter-templates.json +++ b/server/php/inc/starter-templates.json @@ -60,6 +60,7 @@ "fortran-wasm": "Fortran Starter", "d-wasm": "D Starter", "elixir-wasm": "Elixir Starter", + "erlang-wasm": "Erlang Starter", "v-wasm": "V Starter", "rust-wasm": "Rust (Wasm) Starter", "swift-wasm": "Swift (Wasm) Starter", diff --git a/src/_headers b/src/_headers index e6f8baded8..4e0edb88eb 100644 --- a/src/_headers +++ b/src/_headers @@ -13,6 +13,4 @@ cache-control: public, max-age=14400, must-revalidate /* - Cross-Origin-Opener-Policy: same-origin - Cross-Origin-Embedder-Policy: credentialless Cross-Origin-Resource-Policy: cross-origin diff --git a/src/livecodes/UI/command-menu-actions.ts b/src/livecodes/UI/command-menu-actions.ts index 72d4139201..11b524d933 100644 --- a/src/livecodes/UI/command-menu-actions.ts +++ b/src/livecodes/UI/command-menu-actions.ts @@ -316,6 +316,7 @@ export const getCommandMenuActions = ({ 'crystal-wasm', 'odin-wasm', 'elixir-wasm', + 'erlang-wasm', 'v-wasm', 'rust-wasm', 'swift-wasm', diff --git a/src/livecodes/assets/templates/erlang.svg b/src/livecodes/assets/templates/erlang.svg new file mode 100644 index 0000000000..53761bd4cb --- /dev/null +++ b/src/livecodes/assets/templates/erlang.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/livecodes/html/language-info.html b/src/livecodes/html/language-info.html index 2d00f5b6f6..c0c831874e 100644 --- a/src/livecodes/html/language-info.html +++ b/src/livecodes/html/language-info.html @@ -3087,6 +3087,38 @@

    Elixir (Wasm)

  • +
    +

    Erlang (Wasm)

    +
    + +

    V (Wasm)

    diff --git a/src/livecodes/i18n/locales/en/language-info.lokalise.json b/src/livecodes/i18n/locales/en/language-info.lokalise.json index c9b0ee4ba9..73c2f85b88 100644 --- a/src/livecodes/i18n/locales/en/language-info.lokalise.json +++ b/src/livecodes/i18n/locales/en/language-info.lokalise.json @@ -372,6 +372,18 @@ "notes": "", "translation": "Elm" }, + "erlangWasm.desc": { + "notes": "### ###\n\n\n", + "translation": "Erlang runtime running on WebAssembly, using AtomVM" + }, + "erlangWasm.link": { + "notes": "### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n### ###\n
  • \n\n### ###\n\n\n", + "translation": " Erlang official website Erlang documentation Learn X in Y minutes, where X=Erlang LiveCodes Documentation Load starter template " + }, + "erlangWasm.name": { + "notes": "", + "translation": "Erlang (Wasm)" + }, "eta.desc": { "notes": "", "translation": "Embedded JS template engine for Node, Deno, and the browser. Lightweight, fast, and pluggable. Written in TypeScript." diff --git a/src/livecodes/i18n/locales/en/language-info.ts b/src/livecodes/i18n/locales/en/language-info.ts index ac1a18d62c..785323bcee 100644 --- a/src/livecodes/i18n/locales/en/language-info.ts +++ b/src/livecodes/i18n/locales/en/language-info.ts @@ -167,6 +167,11 @@ const languageInfo = { link: '<1><2>Elm official website <3><4>Elm guide <5> <6>LiveCodes Documentation <7> <8>Load starter template ', name: 'Elm', }, + erlangWasm: { + desc: 'Erlang runtime running on WebAssembly, using <1>AtomVM', + link: '<1> <2>Erlang official website <3> <4>Erlang documentation <5> <6>Learn X in Y minutes, where X=Erlang <7> <8>LiveCodes Documentation <9> <10>Load starter template ', + name: 'Erlang (Wasm)', + }, eta: { desc: 'Embedded JS template engine for Node, Deno, and the browser. Lightweight, fast, and pluggable. Written in TypeScript.', link: '<1><2>Official website <3> <4>Documentation <5> <6>LiveCodes Documentations ', diff --git a/src/livecodes/i18n/locales/en/translation.lokalise.json b/src/livecodes/i18n/locales/en/translation.lokalise.json index 53eed123dc..fe7e0f6d00 100644 --- a/src/livecodes/i18n/locales/en/translation.lokalise.json +++ b/src/livecodes/i18n/locales/en/translation.lokalise.json @@ -2664,6 +2664,10 @@ "notes": "", "translation": "Elm Starter" }, + "templates.starter.erlang-wasm": { + "notes": "", + "translation": "Erlang Starter" + }, "templates.starter.fanak": { "notes": "", "translation": "Fanak Starter" diff --git a/src/livecodes/i18n/locales/en/translation.ts b/src/livecodes/i18n/locales/en/translation.ts index a3542ec39a..4957a7eb7d 100644 --- a/src/livecodes/i18n/locales/en/translation.ts +++ b/src/livecodes/i18n/locales/en/translation.ts @@ -1012,6 +1012,7 @@ const translation = { duckdb: 'DuckDB Starter', 'elixir-wasm': 'Elixir Starter', elm: 'Elm Starter', + 'erlang-wasm': 'Erlang Starter', fanak: 'Fanak Starter', fennel: 'Fennel Starter', flutter: 'Flutter Starter', diff --git a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js b/src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js similarity index 86% rename from src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js rename to src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js index a81e02225e..f8af141f76 100644 --- a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-worker.raw.js +++ b/src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js @@ -1,7 +1,7 @@ /* eslint-disable no-eval -- indirect eval is required by AtomVM JS interop (onRunTrackedJs) */ // @ts-nocheck -- vendored fixed Popcorn worker runtime (plain JS, typed as text import) /* - * Persistent Popcorn worker runtime for LiveCodes Elixir (Wasm). + * Persistent Popcorn worker runtime for LiveCodes Beam (Elixir/Erlang) Wasm. * * Based on the iframe runtime shipped with * `live-codes/elixir-browser-eval` (itself from Software Mansion Popcorn). @@ -16,9 +16,10 @@ * created, so the runtime does not depend on a DOM meta element. * 2. Messages go through `self` instead of `window.parent`. The main thread * owns the Worker object, so no cross-origin relay is required. - * 3. Emscripten pthread workers are created from a Blob URL - * (`mainScriptUrlOrBlob`) because a module worker must be same-origin and - * the CDN mirror is cross-origin. + * 3. The AtomVM build is single-threaded (no Emscripten pthreads), so the + * runtime runs entirely in this Worker. `main()` is started explicitly with + * `Module.callMain()` after the JS interop hooks are installed; otherwise the + * synchronous Elixir boot would call `onElixirReady` before the hooks exist. * 4. The Elixir boot code generated by the pinned bundle expects * `window.parent.document` in its JS interop. A minimal shim is installed * immediately before `init()`; it is deliberately installed after the @@ -43,13 +44,12 @@ const MESSAGES = { FATAL: 'popcorn-fatal', }; -const BUNDLE_PATH = '__LIVECODES_ELIXIR_BUNDLE_URL__'; +const BUNDLE_PATH = '__LIVECODES_BEAM_BUNDLE_URL__'; const HEARTBEAT_INTERVAL_MS = 500; let Module = null; let binaryDir = ''; -let workerScript = ''; const mark = (name, detail) => { send(MESSAGES.TIMING, { name, at: performance.now(), detail }); @@ -57,10 +57,7 @@ const mark = (name, detail) => { mark('worker-start'); // The binaries sit next to the bundle, which may be a CDN mirror. -const locateBinary = (path) => { - send(MESSAGES.STDERR, `locateFile: ${path} -> ${binaryDir + path}\n`); - return binaryDir + path; -}; +const locateBinary = (path) => binaryDir + path; class TrackedValue { // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility -- plain JS file, no TS modifiers @@ -93,24 +90,14 @@ async function startPopcorn() { const bundlePath = BUNDLE_PATH; binaryDir = new URL('.', bundlePath).href; - // Emscripten spawns its pthread workers from the runtime module's own URL — - // `new Worker(new URL("AtomVM.mjs", import.meta.url), { type: "module" })` — - // and a worker script must be same-origin, which would pin the runtime to this - // origin. It accepts `mainScriptUrlOrBlob` as an override, so the module is - // fetched from the mirror and handed back as a Blob URL: same-origin, and with - // a usable base URL. mark('atomvm-import-start'); - const source = await fetch(binaryDir + 'AtomVM.mjs').then((resp) => resp.text()); - workerScript = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); const { default: init } = await import(binaryDir + 'AtomVM.mjs'); mark('atomvm-import-end'); - send(MESSAGES.STDERR, 'AtomVM module imported.\n'); mark('bundle-fetch-start'); const bundleBuffer = await fetch(bundlePath).then((resp) => resp.arrayBuffer()); const bundle = new Int8Array(bundleBuffer); mark('bundle-fetch-end', { bytes: bundle.length }); - send(MESSAGES.STDERR, `bundle fetched (${bundle.length} bytes).\n`); send(MESSAGES.INIT); // init() hanging would leave the playground loading forever with no output, // so fail loudly instead: the parent surfaces everything sent before dying. @@ -150,21 +137,14 @@ async function startVm(avmBundle, init) { globalThis.window.parent = globalThis; globalThis.window.document = globalThis.document || {}; Module = await init({ - mainScriptUrlOrBlob: workerScript, + noInitialRun: true, locateFile: locateBinary, - onRuntimeInitialized() { - send(MESSAGES.STDERR, 'AtomVM wasm runtime initialized.\n'); - }, - monitorRunDependencies(left) { - send(MESSAGES.STDERR, `AtomVM run dependencies left: ${left}\n`); - }, preRun: [ function ({ FS }) { FS.mkdir('/data'); FS.writeFile('/data/bundle.avm', avmBundle); }, ], - arguments: ['/data/bundle.avm'], print(text) { send(MESSAGES.STDOUT, text); }, @@ -258,6 +238,8 @@ async function startVm(avmBundle, init) { resolveResultPromise(initProcess); }; + Module.callMain(['/data/bundle.avm']); + return resultPromise; } diff --git a/src/livecodes/languages/beam-wasm/prelude.ts b/src/livecodes/languages/beam-wasm/prelude.ts new file mode 100644 index 0000000000..231ac2ad80 --- /dev/null +++ b/src/livecodes/languages/beam-wasm/prelude.ts @@ -0,0 +1,41 @@ +// AtomVM compiles this module on first boot, and compile time is the dominant +// cold-start cost. Keep it small; the worker keeps the compiled module alive +// across LiveReload edits. The device is shared by Elixir and Erlang. +export const BEAM_PRELUDE = `defmodule :livecodes_io do + def start(stdin) do + lines = + stdin + |> to_string() + |> String.split("\\n", trim: true) + |> Enum.map(&(&1 <> "\\n")) + + spawn(fn -> loop(lines) end) + end + + def stop(dev), do: send(dev, :lc_stop) + + def halt, do: throw({:lc_exit, 0}) + def halt(status), do: throw({:lc_exit, status}) + + defp loop(lines) do + receive do + :lc_stop -> :ok + {:io_request, from, reply_as, req} -> + {reply, rest} = handle(req, lines) + send(from, {:io_reply, reply_as, reply}) + loop(rest) + end + end + + defp handle({:get_line, _enc, _prompt}, [line | rest]), do: {line, rest} + defp handle({:get_line, _enc, _prompt}, []), do: {:eof, []} + defp handle({:put_chars, _enc, chars}, lines) do + IO.write(chars) + {:ok, lines} + end + defp handle({:put_chars, chars}, lines) do + IO.write(chars) + {:ok, lines} + end + defp handle(other, lines), do: {{:error, {:enotsup, other}}, lines} +end`; diff --git a/src/livecodes/languages/beam-wasm/runtime.ts b/src/livecodes/languages/beam-wasm/runtime.ts new file mode 100644 index 0000000000..00d6c54b11 --- /dev/null +++ b/src/livecodes/languages/beam-wasm/runtime.ts @@ -0,0 +1,856 @@ +/* eslint-disable no-console */ +import { getErrorMessage } from '../../utils/utils'; +import { createLoadingReporter } from '../wasm-runtime'; +// @ts-ignore +// eslint-disable-next-line import/no-unresolved +import workerSource from './lang-beam-wasm-worker.raw.js?raw'; + +export interface RunResult { + output: string | null; + error: string | null; + exitCode: number | null; + value?: string | null; + warnings?: string | null; + cancelled?: boolean; +} + +export interface ParsedResult { + value: string | null; + error: string | null; + exitCode: number; +} + +export interface BeamTiming { + name: string; + at: number; + detail?: Record; +} + +export interface BeamApi { + ready: boolean; + failed?: boolean; + input?: string; + output: string | null; + error: string | null; + exitCode: number | null; + value?: string | null; + warnings?: string | null; + loaded: Promise; + evals?: number; + runId?: number; + currentRun?: Promise | null; + init?: Promise; + ensureInit?: () => Promise; + retry?: () => Promise; + popcorn?: any; + stdout?: string[]; + stderr?: string[]; + timings?: BeamTiming[]; + run: (input?: string) => Promise; +} + +export interface BeamRuntimeConfig { + /** Key on `window.livecodes` used by the public API, e.g. `elixir`. */ + apiKey: string; + /** Optional alias key, e.g. `elixir-wasm`. */ + apiAlias?: string; + /** Human-readable language name used in error messages. */ + label: string; + /** Result script tag type, e.g. `text/elixir-wasm`. */ + scriptType: string; + /** Pinned runtime bundle URL. */ + bundleUrl: string; + /** Language prelude, compiled once per worker with `eval_elixir`. */ + prelude: string; + /** Builds the runtime call payload for a run. */ + buildCall: (code: string, stdin: string) => unknown[]; + /** Parses the language-specific result protocol. */ + parseResult: (text: string) => ParsedResult; + /** Parses the Elixir prelude result. Defaults to `parseTaggedResult`. */ + parsePreludeResult?: (text: string) => ParsedResult; + /** Normalizes the raw runtime result before parsing. */ + normalizeResult?: (data: unknown) => string; + /** Whether the wrapping language adds a trailing newline that should be dropped. */ + stripTrailingNewline?: boolean; +} + +declare const window: Window & { + livecodes: Record; +}; + +const EVAL_TIMEOUT_MS = 60_000; +// First boot downloads ~8MB (bundle.avm + AtomVM.wasm) and starts the VM, +// so allow generous time before giving up. +const INIT_VM_TIMEOUT_MS = 120_000; +const SUPERSEDE_TIMEOUT_MS = 10_000; + +// Message protocol spoken by the runtime worker (same types as upstream Popcorn). +const bridgeMessages = { + init: 'popcorn-init', + startVm: 'popcorn-startVm', + call: 'popcorn-call', + callAck: 'popcorn-callAck', + stdout: 'popcorn-stdout', + stderr: 'popcorn-stderr', + heartbeat: 'popcorn-heartbeat', + reload: 'popcorn-reload', + timing: 'popcorn-timing', + fatal: 'popcorn-fatal', +} as const; + +export const OK_TAG = '__LC_OK__'; +export const ERROR_TAG = '__LC_ERROR__'; +export const EXIT_TAG = '__LC_EXIT__'; +const LEGACY_EXIT_TAG = /^\{:?lc_exit, (-?\d+)\}$/; +const LEGACY_ERROR_TAG = /^\{:?lc_error, ([\s\S]*)\}$/; + +export const decodeBase64 = (value: string): string => { + try { + const binary = atob(value); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return value; + } +}; + +// Popcorn serializes a returned Elixir string as a JSON string. A tagged +// protocol string therefore arrives as `"\"__LC_OK__...\""`; unwrap it before +// parsing. Atoms/tuples arrive without the extra quoting. +export const normalizeResultText = (data: unknown): string => { + if (data == null) return ''; + let text = String(data); + if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) { + try { + const parsed = JSON.parse(text); + if (typeof parsed === 'string') text = parsed; + } catch { + // Keep the original text if it is not valid JSON. + } + } + return text; +}; + +export const parseTaggedResult = (text: string, defaultError = 'Runtime error'): ParsedResult => { + if (text.startsWith(OK_TAG)) { + return { value: decodeBase64(text.slice(OK_TAG.length)), error: null, exitCode: 0 }; + } + if (text.startsWith(EXIT_TAG)) { + const exitCode = Number(text.slice(EXIT_TAG.length)); + return { value: null, error: null, exitCode: Number.isFinite(exitCode) ? exitCode : 1 }; + } + if (text.startsWith(ERROR_TAG)) { + const [kind, message, stack] = text + .slice(ERROR_TAG.length) + .split('|') + .map((part) => decodeBase64(part)); + const detail = message || stack || defaultError; + const prefix = kind && kind !== 'error' && kind !== 'syntax' ? `${kind}: ` : ''; + return { value: null, error: `${prefix}${detail}`, exitCode: 1 }; + } + const legacyExit = LEGACY_EXIT_TAG.exec(text); + if (legacyExit) { + return { value: null, error: null, exitCode: Number(legacyExit[1]) }; + } + const legacyError = LEGACY_ERROR_TAG.exec(text); + if (legacyError) { + return { value: null, error: legacyError[1], exitCode: 1 }; + } + return { value: text === '' ? null : text, error: null, exitCode: 0 }; +}; + +export const readBeamCode = (scriptType: string): string => { + let code = ''; + document.querySelectorAll(`script[type="${scriptType}"]`).forEach((script) => { + code += `${script.textContent ?? script.innerHTML}\n`; + }); + return code; +}; + +const drain = (arr: string[]) => { + const out = arr.join('\n'); + arr.length = 0; + return out; +}; + +interface PendingCall { + startTimeMs: number; + resolve: (data: any) => void; + reject: (reason?: unknown) => void; + timer: ReturnType | null; +} + +interface Waiter { + resolve: (value: any) => void; + reject: (reason?: unknown) => void; + timer: ReturnType | null; +} + +export const createBeamRuntime = (config: BeamRuntimeConfig): BeamApi => { + const postLoading = createLoadingReporter(); + const normalizeResult = config.normalizeResult ?? normalizeResultText; + const stripTrailingNewline = config.stripTrailingNewline ?? true; + + window.livecodes ??= {}; + const existing = window.livecodes[config.apiKey] as BeamApi | undefined; + const timings: BeamTiming[] = existing?.timings ?? []; + const mark = (name: string, detail?: Record) => { + timings.push({ name, at: performance.now(), ...(detail == null ? {} : { detail }) }); + if (timings.length > 1000) timings.splice(0, timings.length - 1000); + }; + + const api = (window.livecodes[config.apiKey] ??= {} as BeamApi); + api.ready = false; + api.evals = (api.evals ?? 0) + 1; + const thisEval = api.evals; + mark('script-eval', { + evalId: thisEval, + isolated: window.crossOriginIsolated, + sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', + }); + + const readCode = () => readBeamCode(config.scriptType); + + /** + * Main-thread side of the Beam runtime. + * + * The runtime runs in a Worker owned by the result window. LiveReload rewrites + * the result document with `document.open()/write()/close()`, which keeps the + * window (and therefore this Worker) alive; editing code no longer destroys + * the VM. The bridge is stored on `window.livecodes[apiKey]`, which also + * survives document rewrites. + */ + class BeamBridge { + private bundlePath: string; + private label: string; + private markTiming: (name: string, detail?: Record) => void; + private onStdout: (text: string) => void; + private onStderr: (text: string) => void; + private onReady: ((bridge: BeamBridge) => Promise) | null; + private worker: Worker | null = null; + private workerUrl: string | null = null; + private initProcess: string | null = null; + private requestId = 0; + private calls = new Map(); + private waiters = new Map(); + private booting: Promise | null = null; + private ready = false; + private failed: string | null = null; + private generation = 0; + private destroyed = false; + + private constructor(options: { + bundlePath: string; + label: string; + mark: (name: string, detail?: Record) => void; + onStdout?: (text: string) => void; + onStderr?: (text: string) => void; + onReady?: (bridge: BeamBridge) => Promise; + }) { + this.bundlePath = options.bundlePath; + this.label = options.label; + this.markTiming = options.mark; + this.onStdout = options.onStdout ?? console.log; + this.onStderr = options.onStderr ?? console.warn; + this.onReady = options.onReady ?? null; + } + + public get error(): string | null { + return this.failed; + } + + public get isReady(): boolean { + return this.ready && this.worker != null; + } + + public static async init(options: { + bundlePath: string; + label: string; + mark: (name: string, detail?: Record) => void; + onStdout?: (text: string) => void; + onStderr?: (text: string) => void; + onReady?: (bridge: BeamBridge) => Promise; + }): Promise { + const bridge = new BeamBridge(options); + await bridge.start(); + return bridge; + } + + public ensureReady(): Promise { + return this.start(); + } + + public async call(args: unknown, options: { timeoutMs?: number } = {}): Promise { + await this.start(); + if (!this.isReady || this.initProcess == null) { + throw new Error(this.failed || `${this.label} runtime is not ready`); + } + const requestId = this.requestId; + this.requestId += 1; + const targetProcess = this.initProcess; + const timeoutMs = options.timeoutMs ?? EVAL_TIMEOUT_MS; + const callPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.calls.delete(requestId); + // Module.call is synchronous inside the worker. If it does not return, + // terminating the worker is the only reliable way to unblock newer + // work. This happens on timeouts only, never on a normal edit. + this.stopWorker(`${this.label} evaluation timed out after ${timeoutMs} ms`); + reject(new Error(`${this.label} evaluation timed out after ${timeoutMs} ms`)); + }, timeoutMs); + this.calls.set(requestId, { startTimeMs: performance.now(), resolve, reject, timer }); + }); + try { + this.post(bridgeMessages.call, { requestId, process: targetProcess, args }); + } catch (err) { + const call = this.calls.get(requestId); + if (call != null) { + this.calls.delete(requestId); + if (call.timer != null) clearTimeout(call.timer); + call.reject(err); + } + } + return callPromise; + } + + /** Terminate the worker without waiting for the current evaluation. */ + public cancel(reason = 'superseded by a newer run'): void { + if (this.worker == null) return; + this.stopWorker(reason); + } + + public async restart(): Promise { + this.stopWorker('manual restart'); + await this.start(); + } + + public destroy(): void { + this.destroyed = true; + this.stopWorker('destroyed'); + } + + private async start(): Promise { + if (this.destroyed) throw new Error(`${this.label} runtime was destroyed`); + if (this.isReady) return; + if (this.booting != null) return this.booting; + const attempt = this.boot(); + this.booting = attempt; + try { + await attempt; + } finally { + if (this.booting === attempt) this.booting = null; + } + } + + private async boot(): Promise { + this.stopWorker('restarting'); + this.failed = null; + const generation = this.generation; + this.markTiming('worker-create-start'); + const source = String(workerSource).replace('__LIVECODES_BEAM_BUNDLE_URL__', this.bundlePath); + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + this.workerUrl = url; + try { + const worker = new Worker(url, { type: 'module' }); + this.worker = worker; + worker.addEventListener('message', this.onWorkerMessage); + worker.addEventListener('error', this.onWorkerError); + worker.addEventListener('messageerror', this.onWorkerMessageError); + this.markTiming('worker-created'); + const initPromise = this.waitFor(bridgeMessages.init, INIT_VM_TIMEOUT_MS); + const startPromise = this.waitFor(bridgeMessages.startVm, INIT_VM_TIMEOUT_MS); + await initPromise; + this.markTiming('worker-init-message'); + this.initProcess = await startPromise; + if (generation !== this.generation) { + throw new Error(`${this.label} runtime was restarted`); + } + this.markTiming('worker-vm-ready'); + this.ready = true; + if (this.onReady != null) { + await this.onReady(this); + } + this.markTiming('worker-prelude-ready'); + } catch (err) { + this.stopWorker(`boot failed: ${getErrorMessage(err)}`); + throw err; + } + } + + private waitFor(type: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const waiter: Waiter = { resolve, reject, timer: null }; + waiter.timer = setTimeout(() => { + const list = this.waiters.get(type); + const index = list?.indexOf(waiter) ?? -1; + if (list != null && index >= 0) list.splice(index, 1); + reject(new Error(`Timed out waiting for ${type}`)); + }, timeoutMs); + const list = this.waiters.get(type) ?? []; + list.push(waiter); + this.waiters.set(type, list); + }); + } + + private resolveWaiter(type: string, value: any): void { + const list = this.waiters.get(type); + const waiter = list?.shift(); + if (list != null && list.length === 0) this.waiters.delete(type); + if (waiter == null) return; + if (waiter.timer != null) clearTimeout(waiter.timer); + waiter.resolve(value); + } + + private post(type: string, value: unknown): void { + if (this.worker == null) throw new Error(`${this.label} worker is not running`); + this.worker.postMessage({ type, value }); + } + + private onWorkerMessage = (event: MessageEvent): void => { + const data = event.data as { type?: unknown; value?: any } | null; + if (data == null || typeof data.type !== 'string') return; + const value = data.value; + switch (data.type) { + case bridgeMessages.init: + case bridgeMessages.startVm: + this.resolveWaiter(data.type, value); + break; + case bridgeMessages.stdout: + this.onStdout(typeof value === 'string' ? value : String(value)); + break; + case bridgeMessages.stderr: + this.onStderr(typeof value === 'string' ? value : String(value)); + break; + case bridgeMessages.call: + this.handleCall(value); + break; + case bridgeMessages.callAck: + break; + case bridgeMessages.heartbeat: + break; + case bridgeMessages.reload: + this.stopWorker(`${this.label} runtime requested a restart`); + break; + case bridgeMessages.fatal: + { + const detail = + typeof value?.message === 'string' ? value.message : 'unknown runtime failure'; + this.onStderr(`Runtime failed: ${detail}\n`); + this.stopWorker(`${this.label} runtime failed: ${detail}`); + } + break; + case bridgeMessages.timing: + this.recordWorkerTiming(value); + break; + default: + break; + } + }; + + private onWorkerError = (event: ErrorEvent): void => { + const detail = + event.error instanceof Error && event.error.message + ? event.error.message + : event.message || 'unknown error'; + const message = `${this.label} worker error: ${detail}`; + this.onStderr(`${message}\n`); + this.stopWorker(message); + }; + + private onWorkerMessageError = (): void => { + const message = `${this.label} worker message could not be deserialized`; + this.onStderr(`${message}\n`); + this.stopWorker(message); + }; + + private handleCall(value: { requestId: number; error?: unknown; data?: unknown }): void { + const call = this.calls.get(value.requestId); + if (call == null) return; + this.calls.delete(value.requestId); + if (call.timer != null) clearTimeout(call.timer); + if (value.error !== undefined) { + call.reject(value.error); + } else { + call.resolve({ data: value.data }); + } + } + + private recordWorkerTiming(value: { + name?: unknown; + at?: unknown; + detail?: Record; + }): void { + if (typeof value?.name !== 'string') return; + this.markTiming(`worker:${value.name}`, { + workerAt: typeof value.at === 'number' ? value.at : null, + ...(value.detail ?? {}), + }); + } + + private stopWorker(reason: string): void { + const pendingCalls = this.calls.size; + this.generation += 1; + this.ready = false; + this.initProcess = null; + this.failed = reason; + if (this.worker != null) { + this.worker.removeEventListener('message', this.onWorkerMessage); + this.worker.removeEventListener('error', this.onWorkerError); + this.worker.removeEventListener('messageerror', this.onWorkerMessageError); + this.worker.terminate(); + this.worker = null; + } + if (this.workerUrl != null) { + URL.revokeObjectURL(this.workerUrl); + this.workerUrl = null; + } + for (const call of this.calls.values()) { + if (call.timer != null) clearTimeout(call.timer); + call.reject(new Error(reason)); + } + this.calls.clear(); + for (const list of this.waiters.values()) { + for (const waiter of list) { + if (waiter.timer != null) clearTimeout(waiter.timer); + waiter.reject(new Error(reason)); + } + } + this.waiters.clear(); + mark('worker-stopped', { reason, pendingCalls }); + } + } + + const initBridge = async (): Promise => { + mark('init-start'); + postLoading(true); + const stdout: string[] = api.stdout ?? []; + const stderr: string[] = api.stderr ?? []; + api.stdout = stdout; + api.stderr = stderr; + try { + const bridge = await BeamBridge.init({ + bundlePath: config.bundleUrl, + label: config.label, + mark, + onStdout: (text: string) => stdout.push(text), + onStderr: (text: string) => stderr.push(text), + onReady: async (readyBridge) => { + // Drop boot chatter (module import, fetches, wasm init) so it does not + // leak into the first run's output after a boot or crash recovery. + drain(stdout); + drain(stderr); + mark('prelude-start'); + const prelude = await readyBridge.call(['eval_elixir', config.prelude], { + timeoutMs: EVAL_TIMEOUT_MS, + }); + mark('prelude-end'); + const preludeText = normalizeResultText(prelude?.data); + const parsePrelude = config.parsePreludeResult ?? parseTaggedResult; + const preludeError = parsePrelude(preludeText).error; + if (preludeError != null) { + throw new Error(`${config.label} prelude failed: ${preludeError}`); + } + // Warnings emitted while compiling the prelude are not user output. + drain(stdout); + drain(stderr); + }, + }); + api.popcorn = bridge; + api.failed = false; + mark('init-ready'); + } catch (err) { + // Surface anything the runtime reported before dying (e.g. bundle/AtomVM fetch failures). + if (stderr.length > 0) console.error(drain(stderr)); + if (stdout.length > 0) console.log(drain(stdout)); + api.failed = true; + api.error = getErrorMessage(err); + mark('init-failed', { error: api.error }); + throw err; + } finally { + postLoading(false); + } + }; + + // `ensureInit` is stored on the API object so it survives LiveReload + // document rewrites and can reuse the existing worker. A rejected attempt is + // cleared, so the next run (or retry()) starts a fresh boot instead of + // replaying a memoized failure forever. + api.ensureInit ??= async () => { + const existingBridge = api.popcorn as BeamBridge | undefined; + if (existingBridge != null) { + await existingBridge.ensureReady(); + return; + } + if (api.init == null) { + const attempt = initBridge(); + api.init = attempt; + attempt.catch(() => { + if (api.init === attempt) api.init = undefined; + }); + } + await api.init; + }; + + const runOnce = async (input?: string, runId?: number): Promise => { + if (input !== undefined) api.input = input; + const stdin = `${input ?? api.input ?? ''}`; + const isLatest = () => runId == null || runId === api.runId; + api.output = null; + api.error = null; + api.exitCode = null; + api.value = null; + api.warnings = null; + api.ready = false; + + const code = readCode(); + mark('run-start', { runId, codeBytes: code.length, inputBytes: stdin.length }); + if (!code.trim()) { + mark('run-empty', { runId }); + if (isLatest()) { + api.output = null; + api.error = null; + api.exitCode = 0; + api.value = null; + api.warnings = null; + api.ready = true; + api.failed = false; + } + mark('run-end', { + runId, + exitCode: 0, + stdoutBytes: 0, + stderrBytes: 0, + hasError: false, + latest: isLatest(), + empty: true, + }); + return { output: null, error: null, exitCode: 0, value: null, warnings: null }; + } + + try { + await api.ensureInit?.(); + const bridge = api.popcorn as BeamBridge | undefined; + if (bridge == null) throw new Error(`${config.label} runtime is not initialized`); + api.stdout!.length = 0; + api.stderr!.length = 0; + mark('eval-start', { runId }); + const { data } = await bridge.call(config.buildCall(code, stdin), { + timeoutMs: EVAL_TIMEOUT_MS, + }); + const printed = drain(api.stdout!); + const errPrinted = drain(api.stderr!); + const parsed = config.parseResult(normalizeResult(data)); + // The wrapper appends one newline to flush line-buffered IO.write/1. Drop + // exactly that newline; a single IO.puts chunk may include it. + const outputText = + stripTrailingNewline && printed.endsWith('\n') ? printed.slice(0, -1) : printed; + const output = outputText || null; + const warnings = errPrinted || null; + const latest = isLatest(); + if (latest) { + api.output = output; + api.error = parsed.error; + api.exitCode = parsed.exitCode; + api.value = parsed.value; + api.warnings = warnings; + api.ready = true; + api.failed = false; + if (parsed.error != null) console.error(parsed.error); + if (output != null) console.log(output); + } + mark('run-end', { + runId, + exitCode: parsed.exitCode, + stdoutBytes: printed.length, + stderrBytes: errPrinted.length, + hasError: parsed.error != null, + latest, + }); + return { + output, + error: parsed.error, + exitCode: parsed.exitCode, + value: parsed.value, + warnings, + ...(latest ? {} : { cancelled: true }), + }; + } catch (err) { + const latest = isLatest(); + const runtimeStderr = latest ? drain(api.stderr ?? []) : ''; + const base = 'Error: ' + getErrorMessage(err); + const message = runtimeStderr.trim() ? `${base}\n${runtimeStderr.trim()}` : base; + if (latest) { + api.output = null; + api.error = message; + api.exitCode = 1; + api.value = null; + api.warnings = null; + api.ready = true; + api.failed = true; + console.error(message); + } + mark('run-error', { runId, error: message, latest }); + return { + output: null, + error: message, + exitCode: 1, + value: null, + warnings: null, + ...(latest ? {} : { cancelled: true }), + }; + } + }; + + let activeRun: Promise | null = null; + let pendingRun: { + input?: string; + runId: number; + resolve: (result: RunResult) => void; + reject: (reason?: unknown) => void; + } | null = null; + let supersedeTimer: ReturnType | null = null; + + const clearSupersedeTimer = () => { + if (supersedeTimer == null) return; + clearTimeout(supersedeTimer); + supersedeTimer = null; + }; + + const scheduleSupersede = () => { + if (supersedeTimer != null) return; + supersedeTimer = setTimeout(() => { + supersedeTimer = null; + if (activeRun == null || pendingRun == null) return; + const bridge = api.popcorn as BeamBridge | undefined; + mark('run-supersede-timeout', { pendingRunId: pendingRun.runId }); + bridge?.cancel('superseded by a newer run'); + }, SUPERSEDE_TIMEOUT_MS); + }; + + const finishRun = (task: Promise) => { + if (activeRun !== task) return; + activeRun = null; + api.currentRun = null; + clearSupersedeTimer(); + const next = pendingRun; + pendingRun = null; + if (next != null) { + mark('run-dequeued', { runId: next.runId }); + const nextTask = startRun(next.input, next.runId); + void nextTask.then(next.resolve, next.reject); + } + }; + + const startRun = (input: string | undefined, runId: number): Promise => { + const task = runOnce(input, runId); + activeRun = task; + api.currentRun = task.then( + () => undefined, + () => undefined, + ); + void task.then( + () => finishRun(task), + () => finishRun(task), + ); + return task; + }; + + api.run ??= async (input?: string): Promise => { + const runId = (api.runId ?? 0) + 1; + api.runId = runId; + mark('run-queued', { runId, hasInput: input != null }); + if (activeRun == null) { + return startRun(input, runId); + } + if (pendingRun != null) { + const superseded = pendingRun; + pendingRun = null; + mark('run-superseded', { runId: superseded.runId, by: runId }); + superseded.resolve({ + output: null, + error: null, + exitCode: null, + value: null, + warnings: null, + cancelled: true, + }); + } + scheduleSupersede(); + return new Promise((resolve, reject) => { + pendingRun = { input, runId, resolve, reject }; + mark('run-pending', { runId }); + }); + }; + + api.retry ??= async () => { + api.failed = false; + api.error = null; + api.ready = false; + api.init = undefined; + const bridge = api.popcorn as BeamBridge | undefined; + try { + if (bridge != null) { + await bridge.restart(); + } else { + await api.ensureInit?.(); + } + } catch (err) { + api.failed = true; + api.error = getErrorMessage(err); + throw err; + } + }; + + // LiveReload updates rewrite the result document in rapid succession and the + // window `load` event is not reliably fired after coalesced rewrites, so runs + // are scheduled by polling the parser state instead of listening for `load`. + // Only the latest evaluation runs; superseded ones abort silently. + const waitForParseThenRun = async (evalId: number): Promise => { + const start = Date.now(); + while (document.readyState === 'loading' && Date.now() - start < 30_000) { + if (api.evals !== evalId) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + if (api.evals !== evalId) return; + postLoading(true); + try { + await api.run(api.input); + } finally { + postLoading(false); + } + }; + void waitForParseThenRun(thisEval); + + api.timings = timings; + const LOADED_TIMEOUT_MS = INIT_VM_TIMEOUT_MS + EVAL_TIMEOUT_MS; + api.loaded = new Promise((resolve, reject) => { + const started = performance.now(); + const interval = setInterval(() => { + if (api.failed) { + clearInterval(interval); + mark('loaded-rejected', { error: api.error }); + reject(new Error(api.error || `Failed to initialize the ${config.label} environment`)); + } else if (api.ready) { + clearInterval(interval); + mark('loaded-resolved'); + resolve(); + } else if (performance.now() - started > LOADED_TIMEOUT_MS) { + clearInterval(interval); + api.failed = true; + api.error = `Timed out initializing the ${config.label} runtime`; + mark('loaded-timeout'); + reject(new Error(api.error)); + } + }, 50); + }); + // The starter awaits `loaded` and may not catch. Attaching a no-op handler + // keeps a recoverable boot failure from becoming an unhandled rejection; the + // original promise still rejects for callers that want the error. + void api.loaded.catch(() => undefined); + + window.livecodes[config.apiKey] = api; + if (config.apiAlias != null) { + window.livecodes[config.apiAlias] = api; + } + return api; +}; diff --git a/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts b/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts index 1a3430a3d4..39e1ada97f 100644 --- a/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts +++ b/src/livecodes/languages/elixir-wasm/__tests__/lang-elixir-wasm.spec.ts @@ -2,7 +2,9 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; import { elixirWasm } from '../lang-elixir-wasm'; -const readSource = (file: string) => readFileSync(resolve(__dirname, '..', file), 'utf8'); +const readSource = (file: string) => + readFileSync(resolve(__dirname, '..', '..', 'beam-wasm', file), 'utf8'); +const readLanguageSource = (file: string) => readFileSync(resolve(__dirname, '..', file), 'utf8'); describe('Elixir (Wasm) language spec', () => { test('is registered as elixir-wasm, with the wasm compiler script', () => { @@ -33,8 +35,9 @@ describe('Elixir (Wasm) language spec', () => { }); describe('Elixir (Wasm) bridge', () => { - const bridge = readSource('lang-elixir-wasm-script.ts'); - const worker = readSource('lang-elixir-wasm-worker.raw.js'); + const bridge = readSource('runtime.ts'); + const worker = readSource('lang-beam-wasm-worker.raw.js'); + const script = readLanguageSource('lang-elixir-wasm-script.ts'); test('keeps the VM in a module worker instead of an iframe', () => { expect(bridge).toContain("new Worker(url, { type: 'module' })"); @@ -46,7 +49,7 @@ describe('Elixir (Wasm) bridge', () => { expect(bridge).toContain("OK_TAG = '__LC_OK__'"); expect(bridge).toContain("ERROR_TAG = '__LC_ERROR__'"); expect(bridge).toContain("EXIT_TAG = '__LC_EXIT__'"); - expect(bridge).toContain('Base.encode64'); + expect(script).toContain('Base.encode64'); }); test('bounds calls and supersedes a blocked run', () => { @@ -60,7 +63,8 @@ describe('Elixir (Wasm) bridge', () => { expect(worker).toContain("INIT: 'popcorn-init'"); expect(worker).toContain("CALL: 'popcorn-call'"); expect(worker).toContain('globalThis.window = globalThis'); - expect(worker).toContain('mainScriptUrlOrBlob'); + expect(worker).toContain('noInitialRun: true'); + expect(worker).toContain("Module.callMain(['/data/bundle.avm'])"); expect(worker).toContain('Module.onElixirReady'); expect(worker).toContain('self.postMessage'); expect(worker).not.toContain('window.parent.postMessage'); diff --git a/src/livecodes/languages/elixir-wasm/benchmark.md b/src/livecodes/languages/elixir-wasm/benchmark.md index 18b656bfb4..6449d1223a 100644 --- a/src/livecodes/languages/elixir-wasm/benchmark.md +++ b/src/livecodes/languages/elixir-wasm/benchmark.md @@ -13,7 +13,9 @@ The browser-level lifecycle and performance assertions live in `live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8`. - Popcorn comparison: `@swmansion/popcorn@0.4.0-next.9` (the same version as Hex `popcorn 0.4.0-next.9`), Elixir 1.20.4 on OTP 29.0.6, full BEAM. -- App and sandbox were served locally with COOP/COEP isolation headers. +- The numbers below were taken with the earlier threaded build, which required COOP/COEP. The + shipped no-pthread build runs without isolation headers; the E2E suite asserts + `crossOriginIsolated === false`. ## Before and after @@ -75,7 +77,7 @@ worker-level numbers. | Warm direct evaluation | 4.4 ms | 1.0-1.1 ms | | Total memory after warm evaluation | 32.9 MB | 101.2 MB | | Worker memory only | 31.9 MB | 100.0 MB | -| Isolation headers tested | COOP `same-origin`, COEP `credentialless` | COOP `same-origin`, COEP `require-corp` | +| Isolation headers (threaded build) | COOP `same-origin`, COEP `credentialless` | COOP `same-origin`, COEP `require-corp` | Notes: diff --git a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts index 7faf219296..10a9463ac8 100644 --- a/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts +++ b/src/livecodes/languages/elixir-wasm/lang-elixir-wasm-script.ts @@ -1,94 +1,10 @@ -/* eslint-disable no-console */ -import { getErrorMessage } from '../../utils/utils'; -import { elixirWasmBaseUrl } from '../../vendors'; -import { createLoadingReporter } from '../wasm-runtime'; -// @ts-ignore -// eslint-disable-next-line import/no-unresolved -import workerSource from './lang-elixir-wasm-worker.raw.js?raw'; - -interface RunResult { - output: string | null; - error: string | null; - exitCode: number | null; - value?: string | null; - warnings?: string | null; - cancelled?: boolean; -} - -interface ElixirTiming { - name: string; - at: number; - detail?: Record; -} - -interface ElixirWasmApi { - ready: boolean; - failed?: boolean; - input?: string; - output: string | null; - error: string | null; - exitCode: number | null; - value?: string | null; - warnings?: string | null; - loaded: Promise; - evals?: number; - runId?: number; - currentRun?: Promise | null; - init?: Promise; - ensureInit?: () => Promise; - retry?: () => Promise; - popcorn?: any; - stdout?: string[]; - stderr?: string[]; - timings?: ElixirTiming[]; - run: (input?: string) => Promise; -} - -declare const window: Window & { - livecodes: Record; -}; - -const BUNDLE_URL = `${elixirWasmBaseUrl}bundle.avm`; -const EVAL_TIMEOUT_MS = 60_000; -// First boot downloads ~8MB (bundle.avm + AtomVM.wasm) and starts the VM, -// so allow generous time before giving up. -const INIT_VM_TIMEOUT_MS = 120_000; +import { atomvmWasmBaseUrl } from '../../vendors'; +import { BEAM_PRELUDE } from '../beam-wasm/prelude'; +import { createBeamRuntime, parseTaggedResult, type ParsedResult } from '../beam-wasm/runtime'; +const BUNDLE_URL = `${atomvmWasmBaseUrl}bundle.avm`; const SCRIPT_TYPE = 'text/elixir-wasm'; -// Message protocol spoken by the runtime worker (same types as upstream Popcorn). -const bridgeMessages = { - init: 'popcorn-init', - startVm: 'popcorn-startVm', - call: 'popcorn-call', - callAck: 'popcorn-callAck', - stdout: 'popcorn-stdout', - stderr: 'popcorn-stderr', - heartbeat: 'popcorn-heartbeat', - reload: 'popcorn-reload', - timing: 'popcorn-timing', - fatal: 'popcorn-fatal', -} as const; - -const postLoading = createLoadingReporter(); - -const existingElixir = window.livecodes?.elixir as ElixirWasmApi | undefined; -const timings: ElixirTiming[] = existingElixir?.timings ?? []; -const mark = (name: string, detail?: Record) => { - timings.push({ name, at: performance.now(), ...(detail == null ? {} : { detail }) }); - if (timings.length > 1000) timings.splice(0, timings.length - 1000); -}; - -const readCode = () => { - let code = ''; - document - .querySelectorAll(`script[type="${SCRIPT_TYPE}"]`) - .forEach((script) => { - code += `${script.textContent ?? script.innerHTML}\n`; - }); - return code; -}; - const escapeElixirString = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/#{/g, '#\\{'); @@ -143,789 +59,16 @@ case result do end`; }; -const OK_TAG = '__LC_OK__'; -const ERROR_TAG = '__LC_ERROR__'; -const EXIT_TAG = '__LC_EXIT__'; -const LEGACY_EXIT_TAG = /^\{:?lc_exit, (-?\d+)\}$/; -const LEGACY_ERROR_TAG = /^\{:?lc_error, ([\s\S]*)\}$/; - -const decodeBase64 = (value: string): string => { - try { - const binary = atob(value); - const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); - return new TextDecoder().decode(bytes); - } catch { - return value; - } -}; - -// Popcorn serializes a returned Elixir string as a JSON string. A tagged -// protocol string therefore arrives as `"\"__LC_OK__...\""`; unwrap it before -// parsing. Atoms/tuples arrive without the extra quoting. -const normalizeResultText = (data: unknown): string => { - if (data == null) return ''; - let text = String(data); - if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) { - try { - const parsed = JSON.parse(text); - if (typeof parsed === 'string') text = parsed; - } catch { - // Keep the original text if it is not valid JSON. - } - } - return text; -}; - -const parseElixirResult = ( - text: string, -): { value: string | null; error: string | null; exitCode: number } => { - if (text.startsWith(OK_TAG)) { - return { value: decodeBase64(text.slice(OK_TAG.length)), error: null, exitCode: 0 }; - } - if (text.startsWith(EXIT_TAG)) { - const exitCode = Number(text.slice(EXIT_TAG.length)); - return { value: null, error: null, exitCode: Number.isFinite(exitCode) ? exitCode : 1 }; - } - if (text.startsWith(ERROR_TAG)) { - const [kind, message, stack] = text - .slice(ERROR_TAG.length) - .split('|') - .map((part) => decodeBase64(part)); - const detail = message || stack || 'Elixir error'; - const prefix = kind && kind !== 'error' && kind !== 'syntax' ? `${kind}: ` : ''; - return { value: null, error: `${prefix}${detail}`, exitCode: 1 }; - } - const legacyExit = LEGACY_EXIT_TAG.exec(text); - if (legacyExit) { - return { value: null, error: null, exitCode: Number(legacyExit[1]) }; - } - const legacyError = LEGACY_ERROR_TAG.exec(text); - if (legacyError) { - return { value: null, error: legacyError[1], exitCode: 1 }; - } - return { value: text === '' ? null : text, error: null, exitCode: 0 }; -}; +const parseElixirResult = (text: string): ParsedResult => parseTaggedResult(text, 'Elixir error'); -window.livecodes ??= {}; -window.livecodes.elixir ??= {} as ElixirWasmApi; -const elixir = window.livecodes.elixir; -elixir.ready = false; -elixir.evals = (elixir.evals ?? 0) + 1; -const thisEval = elixir.evals; -console.log( - `[elixir-wasm] script evaluated (#${thisEval}), ` + - `elixir script tags: ${document.querySelectorAll(`script[type="${SCRIPT_TYPE}"]`).length}, ` + - `isolated: ${window.crossOriginIsolated}, SAB: ${typeof SharedArrayBuffer !== 'undefined'}`, -); -mark('script-eval', { - evalId: thisEval, - isolated: window.crossOriginIsolated, - sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined', +createBeamRuntime({ + apiKey: 'elixir', + apiAlias: 'elixir-wasm', + label: 'Elixir', + scriptType: SCRIPT_TYPE, + bundleUrl: BUNDLE_URL, + prelude: BEAM_PRELUDE, + buildCall: (code, stdin) => ['eval_elixir', wrapElixir(code, stdin)], + parseResult: parseElixirResult, + parsePreludeResult: parseElixirResult, }); - -// LiveReload updates rewrite the result document in rapid succession and the -// window `load` event is not reliably fired after coalesced rewrites, so runs -// are scheduled by polling the parser state instead of listening for `load`. -// Only the latest evaluation runs; superseded ones abort silently. -const waitForParseThenRun = async (evalId: number): Promise => { - const start = Date.now(); - while (document.readyState === 'loading' && Date.now() - start < 30_000) { - if (elixir.evals !== evalId) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - if (elixir.evals !== evalId) return; - postLoading(true); - try { - await elixir.run(elixir.input); - } finally { - postLoading(false); - } -}; -void waitForParseThenRun(thisEval); - -// AtomVM compiles this module on first boot, and compile time is the dominant -// cold-start cost. Keep it small; the worker keeps the compiled module alive -// across LiveReload edits. -const PRELUDE = `defmodule :livecodes_io do - def start(stdin) do - lines = - stdin - |> to_string() - |> String.split("\\n", trim: true) - |> Enum.map(&(&1 <> "\\n")) - - spawn(fn -> loop(lines) end) - end - - def stop(dev), do: send(dev, :lc_stop) - - def halt, do: throw({:lc_exit, 0}) - def halt(status), do: throw({:lc_exit, status}) - - defp loop(lines) do - receive do - :lc_stop -> :ok - {:io_request, from, reply_as, req} -> - {reply, rest} = handle(req, lines) - send(from, {:io_reply, reply_as, reply}) - loop(rest) - end - end - - defp handle({:get_line, _enc, _prompt}, [line | rest]), do: {line, rest} - defp handle({:get_line, _enc, _prompt}, []), do: {:eof, []} - defp handle({:put_chars, _enc, chars}, lines) do - IO.write(chars) - {:ok, lines} - end - defp handle({:put_chars, chars}, lines) do - IO.write(chars) - {:ok, lines} - end - defp handle(other, lines), do: {{:error, {:enotsup, other}}, lines} -end`; - -interface PendingCall { - startTimeMs: number; - resolve: (data: any) => void; - reject: (reason?: unknown) => void; - timer: ReturnType | null; -} - -interface Waiter { - resolve: (value: any) => void; - reject: (reason?: unknown) => void; - timer: ReturnType | null; -} - -/** - * Main-thread side of the Elixir runtime. - * - * The runtime runs in a Worker owned by the result window. LiveReload rewrites - * the result document with `document.open()/write()/close()`, which keeps the - * window (and therefore this Worker) alive; editing code no longer destroys - * the VM. The bridge is stored on `window.livecodes.elixir`, which also - * survives document rewrites. - */ -class ElixirBridge { - private bundlePath: string; - private onStdout: (text: string) => void; - private onStderr: (text: string) => void; - private onReady: ((bridge: ElixirBridge) => Promise) | null; - private worker: Worker | null = null; - private workerUrl: string | null = null; - private initProcess: string | null = null; - private requestId = 0; - private calls = new Map(); - private waiters = new Map(); - private booting: Promise | null = null; - private ready = false; - private failed: string | null = null; - private generation = 0; - private destroyed = false; - - private constructor(options: { - bundlePath: string; - onStdout?: (text: string) => void; - onStderr?: (text: string) => void; - onReady?: (bridge: ElixirBridge) => Promise; - }) { - this.bundlePath = options.bundlePath; - this.onStdout = options.onStdout ?? console.log; - this.onStderr = options.onStderr ?? console.warn; - this.onReady = options.onReady ?? null; - } - - public get error(): string | null { - return this.failed; - } - - public get isReady(): boolean { - return this.ready && this.worker != null; - } - - public static async init(options: { - bundlePath: string; - onStdout?: (text: string) => void; - onStderr?: (text: string) => void; - onReady?: (bridge: ElixirBridge) => Promise; - }): Promise { - const bridge = new ElixirBridge(options); - await bridge.start(); - return bridge; - } - - public ensureReady(): Promise { - return this.start(); - } - - public async call(args: unknown, options: { timeoutMs?: number } = {}): Promise { - await this.start(); - if (!this.isReady || this.initProcess == null) { - throw new Error(this.failed || 'Elixir runtime is not ready'); - } - const requestId = this.requestId; - this.requestId += 1; - const targetProcess = this.initProcess; - const timeoutMs = options.timeoutMs ?? EVAL_TIMEOUT_MS; - const callPromise = new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.calls.delete(requestId); - // Module.call is synchronous inside the worker. If it does not return, - // terminating the worker is the only reliable way to unblock newer - // work. This happens on timeouts only, never on a normal edit. - this.stopWorker(`Elixir evaluation timed out after ${timeoutMs} ms`); - reject(new Error(`Elixir evaluation timed out after ${timeoutMs} ms`)); - }, timeoutMs); - this.calls.set(requestId, { startTimeMs: performance.now(), resolve, reject, timer }); - }); - try { - this.post(bridgeMessages.call, { requestId, process: targetProcess, args }); - } catch (err) { - const call = this.calls.get(requestId); - if (call != null) { - this.calls.delete(requestId); - if (call.timer != null) clearTimeout(call.timer); - call.reject(err); - } - } - return callPromise; - } - - /** Terminate the worker without waiting for the current evaluation. */ - public cancel(reason = 'superseded by a newer run'): void { - if (this.worker == null) return; - this.stopWorker(reason); - } - - public async restart(): Promise { - this.stopWorker('manual restart'); - await this.start(); - } - - public destroy(): void { - this.destroyed = true; - this.stopWorker('destroyed'); - } - - private async start(): Promise { - if (this.destroyed) throw new Error('Elixir runtime was destroyed'); - if (this.isReady) return; - if (this.booting != null) return this.booting; - const attempt = this.boot(); - this.booting = attempt; - try { - await attempt; - } finally { - if (this.booting === attempt) this.booting = null; - } - } - - private async boot(): Promise { - this.stopWorker('restarting'); - this.failed = null; - const generation = this.generation; - mark('worker-create-start'); - const source = String(workerSource).replace('__LIVECODES_ELIXIR_BUNDLE_URL__', this.bundlePath); - const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); - this.workerUrl = url; - try { - const worker = new Worker(url, { type: 'module' }); - this.worker = worker; - worker.addEventListener('message', this.onWorkerMessage); - worker.addEventListener('error', this.onWorkerError); - worker.addEventListener('messageerror', this.onWorkerMessageError); - mark('worker-created'); - const initPromise = this.waitFor(bridgeMessages.init, INIT_VM_TIMEOUT_MS); - const startPromise = this.waitFor(bridgeMessages.startVm, INIT_VM_TIMEOUT_MS); - await initPromise; - mark('worker-init-message'); - console.log('[elixir-wasm] worker started, waiting for the VM…'); - this.initProcess = await startPromise; - if (generation !== this.generation) { - throw new Error('Elixir runtime was restarted'); - } - mark('worker-vm-ready'); - console.log('[elixir-wasm] VM is ready.'); - this.ready = true; - if (this.onReady != null) { - await this.onReady(this); - } - mark('worker-prelude-ready'); - } catch (err) { - this.stopWorker(`boot failed: ${getErrorMessage(err)}`); - throw err; - } - } - - private waitFor(type: string, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const waiter: Waiter = { resolve, reject, timer: null }; - waiter.timer = setTimeout(() => { - const list = this.waiters.get(type); - const index = list?.indexOf(waiter) ?? -1; - if (list != null && index >= 0) list.splice(index, 1); - reject(new Error(`Timed out waiting for ${type}`)); - }, timeoutMs); - const list = this.waiters.get(type) ?? []; - list.push(waiter); - this.waiters.set(type, list); - }); - } - - private resolveWaiter(type: string, value: any): void { - const list = this.waiters.get(type); - const waiter = list?.shift(); - if (list != null && list.length === 0) this.waiters.delete(type); - if (waiter == null) return; - if (waiter.timer != null) clearTimeout(waiter.timer); - waiter.resolve(value); - } - - private post(type: string, value: unknown): void { - if (this.worker == null) throw new Error('Elixir worker is not running'); - this.worker.postMessage({ type, value }); - } - - private onWorkerMessage = (event: MessageEvent): void => { - const data = event.data as { type?: unknown; value?: any } | null; - if (data == null || typeof data.type !== 'string') return; - const value = data.value; - switch (data.type) { - case bridgeMessages.init: - case bridgeMessages.startVm: - this.resolveWaiter(data.type, value); - break; - case bridgeMessages.stdout: - this.onStdout(typeof value === 'string' ? value : String(value)); - break; - case bridgeMessages.stderr: - this.onStderr(typeof value === 'string' ? value : String(value)); - break; - case bridgeMessages.call: - this.handleCall(value); - break; - case bridgeMessages.callAck: - break; - case bridgeMessages.heartbeat: - break; - case bridgeMessages.reload: - this.stopWorker('Elixir runtime requested a restart'); - break; - case bridgeMessages.fatal: - { - const detail = - typeof value?.message === 'string' ? value.message : 'unknown runtime failure'; - this.onStderr(`Runtime failed: ${detail}\n`); - this.stopWorker(`Elixir runtime failed: ${detail}`); - } - break; - case bridgeMessages.timing: - this.recordWorkerTiming(value); - break; - default: - break; - } - }; - - private onWorkerError = (event: ErrorEvent): void => { - const detail = - event.error instanceof Error && event.error.message - ? event.error.message - : event.message || 'unknown error'; - const message = `Elixir worker error: ${detail}`; - this.onStderr(`${message}\n`); - this.stopWorker(message); - }; - - private onWorkerMessageError = (): void => { - const message = 'Elixir worker message could not be deserialized'; - this.onStderr(`${message}\n`); - this.stopWorker(message); - }; - - private handleCall(value: { requestId: number; error?: unknown; data?: unknown }): void { - const call = this.calls.get(value.requestId); - if (call == null) return; - this.calls.delete(value.requestId); - if (call.timer != null) clearTimeout(call.timer); - if (value.error !== undefined) { - call.reject(value.error); - } else { - call.resolve({ data: value.data }); - } - } - - private recordWorkerTiming(value: { - name?: unknown; - at?: unknown; - detail?: Record; - }): void { - if (typeof value?.name !== 'string') return; - mark(`worker:${value.name}`, { - workerAt: typeof value.at === 'number' ? value.at : null, - ...(value.detail ?? {}), - }); - } - - private stopWorker(reason: string): void { - const pendingCalls = this.calls.size; - this.generation += 1; - this.ready = false; - this.initProcess = null; - this.failed = reason; - if (this.worker != null) { - this.worker.removeEventListener('message', this.onWorkerMessage); - this.worker.removeEventListener('error', this.onWorkerError); - this.worker.removeEventListener('messageerror', this.onWorkerMessageError); - this.worker.terminate(); - this.worker = null; - } - if (this.workerUrl != null) { - URL.revokeObjectURL(this.workerUrl); - this.workerUrl = null; - } - for (const call of this.calls.values()) { - if (call.timer != null) clearTimeout(call.timer); - call.reject(new Error(reason)); - } - this.calls.clear(); - for (const list of this.waiters.values()) { - for (const waiter of list) { - if (waiter.timer != null) clearTimeout(waiter.timer); - waiter.reject(new Error(reason)); - } - } - this.waiters.clear(); - mark('worker-stopped', { reason, pendingCalls }); - } -} - -const drain = (arr: string[]) => { - const out = arr.join('\n'); - arr.length = 0; - return out; -}; - -const initBridge = async (): Promise => { - mark('init-start'); - postLoading(true); - const stdout: string[] = elixir.stdout ?? []; - const stderr: string[] = elixir.stderr ?? []; - elixir.stdout = stdout; - elixir.stderr = stderr; - try { - const bridge = await ElixirBridge.init({ - bundlePath: BUNDLE_URL, - onStdout: (text: string) => stdout.push(text), - onStderr: (text: string) => stderr.push(text), - onReady: async (readyBridge) => { - // Drop boot chatter (module import, fetches, wasm init) so it does not - // leak into the first run's output after a boot or crash recovery. - drain(stdout); - drain(stderr); - mark('prelude-start'); - const prelude = await readyBridge.call(['eval_elixir', PRELUDE], { - timeoutMs: EVAL_TIMEOUT_MS, - }); - mark('prelude-end'); - const preludeText = normalizeResultText(prelude?.data); - const preludeError = parseElixirResult(preludeText).error; - if (preludeError != null) { - throw new Error(`Elixir prelude failed: ${preludeError}`); - } - // Warnings emitted while compiling the prelude are not user output. - drain(stdout); - drain(stderr); - }, - }); - elixir.popcorn = bridge; - elixir.failed = false; - mark('init-ready'); - } catch (err) { - // Surface anything the runtime reported before dying (e.g. bundle/AtomVM fetch failures). - if (stderr.length > 0) console.error(drain(stderr)); - if (stdout.length > 0) console.log(drain(stdout)); - elixir.failed = true; - elixir.error = getErrorMessage(err); - mark('init-failed', { error: elixir.error }); - throw err; - } finally { - postLoading(false); - } -}; - -// `ensureInit` is stored on the Elixir API object so it survives LiveReload -// document rewrites and can reuse the existing worker. A rejected attempt is -// cleared, so the next run (or retry()) starts a fresh boot instead of -// replaying a memoized failure forever. -elixir.ensureInit ??= async () => { - const existing = elixir.popcorn as ElixirBridge | undefined; - if (existing != null) { - await existing.ensureReady(); - return; - } - if (elixir.init == null) { - const attempt = initBridge(); - elixir.init = attempt; - attempt.catch(() => { - if (elixir.init === attempt) elixir.init = undefined; - }); - } - await elixir.init; -}; - -const runOnce = async (input?: string, runId?: number): Promise => { - if (input !== undefined) elixir.input = input; - const stdin = `${input ?? elixir.input ?? ''}`; - const isLatest = () => runId == null || runId === elixir.runId; - elixir.output = null; - elixir.error = null; - elixir.exitCode = null; - elixir.value = null; - elixir.warnings = null; - elixir.ready = false; - - const code = readCode(); - mark('run-start', { runId, codeBytes: code.length, inputBytes: stdin.length }); - console.log(`[elixir-wasm] run requested, code bytes: ${code.length}`); - if (!code.trim()) { - console.log('[elixir-wasm] empty code, skipping run.'); - mark('run-empty', { runId }); - if (isLatest()) { - elixir.output = null; - elixir.error = null; - elixir.exitCode = 0; - elixir.value = null; - elixir.warnings = null; - elixir.ready = true; - elixir.failed = false; - } - mark('run-end', { - runId, - exitCode: 0, - stdoutBytes: 0, - stderrBytes: 0, - hasError: false, - latest: isLatest(), - empty: true, - }); - return { output: null, error: null, exitCode: 0, value: null, warnings: null }; - } - if (!window.crossOriginIsolated) { - const message = - 'Error: Elixir needs a cross-origin isolated result page ' + - '(COOP/COEP headers). The AtomVM runtime cannot boot without SharedArrayBuffer.'; - console.error(message); - mark('run-not-isolated', { runId }); - if (isLatest()) { - elixir.error = message; - elixir.exitCode = 1; - elixir.ready = true; - elixir.failed = true; - } - return { output: null, error: message, exitCode: 1, value: null, warnings: null }; - } - - try { - await elixir.ensureInit?.(); - const popcorn = elixir.popcorn as ElixirBridge | undefined; - if (popcorn == null) throw new Error('Elixir runtime is not initialized'); - elixir.stdout!.length = 0; - elixir.stderr!.length = 0; - mark('eval-start', { runId }); - const { data } = await popcorn.call(['eval_elixir', wrapElixir(code, stdin)], { - timeoutMs: EVAL_TIMEOUT_MS, - }); - const printed = drain(elixir.stdout!); - const errPrinted = drain(elixir.stderr!); - const parsed = parseElixirResult(normalizeResultText(data)); - // The wrapper appends one newline to flush line-buffered IO.write/1. Drop - // exactly that newline; a single IO.puts chunk may include it. - const outputText = printed.endsWith('\n') ? printed.slice(0, -1) : printed; - const output = outputText || null; - const warnings = errPrinted || null; - const latest = isLatest(); - if (latest) { - elixir.output = output; - elixir.error = parsed.error; - elixir.exitCode = parsed.exitCode; - elixir.value = parsed.value; - elixir.warnings = warnings; - elixir.ready = true; - elixir.failed = false; - if (parsed.error != null) console.error(parsed.error); - if (output != null) console.log(output); - } - mark('run-end', { - runId, - exitCode: parsed.exitCode, - stdoutBytes: printed.length, - stderrBytes: errPrinted.length, - hasError: parsed.error != null, - latest, - }); - return { - output, - error: parsed.error, - exitCode: parsed.exitCode, - value: parsed.value, - warnings, - ...(latest ? {} : { cancelled: true }), - }; - } catch (err) { - const latest = isLatest(); - const runtimeStderr = latest ? drain(elixir.stderr ?? []) : ''; - const base = 'Error: ' + getErrorMessage(err); - const message = runtimeStderr.trim() ? `${base}\n${runtimeStderr.trim()}` : base; - if (latest) { - elixir.output = null; - elixir.error = message; - elixir.exitCode = 1; - elixir.value = null; - elixir.warnings = null; - elixir.ready = true; - elixir.failed = true; - console.error(message); - } - mark('run-error', { runId, error: message, latest }); - return { - output: null, - error: message, - exitCode: 1, - value: null, - warnings: null, - ...(latest ? {} : { cancelled: true }), - }; - } -}; - -const SUPERSEDE_TIMEOUT_MS = 10_000; -let activeRun: Promise | null = null; -let pendingRun: { - input?: string; - runId: number; - resolve: (result: RunResult) => void; - reject: (reason?: unknown) => void; -} | null = null; -let supersedeTimer: ReturnType | null = null; - -const clearSupersedeTimer = () => { - if (supersedeTimer == null) return; - clearTimeout(supersedeTimer); - supersedeTimer = null; -}; - -const scheduleSupersede = () => { - if (supersedeTimer != null) return; - supersedeTimer = setTimeout(() => { - supersedeTimer = null; - if (activeRun == null || pendingRun == null) return; - const bridge = elixir.popcorn as ElixirBridge | undefined; - mark('run-supersede-timeout', { pendingRunId: pendingRun.runId }); - bridge?.cancel('superseded by a newer run'); - }, SUPERSEDE_TIMEOUT_MS); -}; - -const finishRun = (task: Promise) => { - if (activeRun !== task) return; - activeRun = null; - elixir.currentRun = null; - clearSupersedeTimer(); - const next = pendingRun; - pendingRun = null; - if (next != null) { - mark('run-dequeued', { runId: next.runId }); - const nextTask = startRun(next.input, next.runId); - void nextTask.then(next.resolve, next.reject); - } -}; - -const startRun = (input: string | undefined, runId: number): Promise => { - const task = runOnce(input, runId); - activeRun = task; - elixir.currentRun = task.then( - () => undefined, - () => undefined, - ); - void task.then( - () => finishRun(task), - () => finishRun(task), - ); - return task; -}; - -elixir.run ??= async (input?: string): Promise => { - const runId = (elixir.runId ?? 0) + 1; - elixir.runId = runId; - mark('run-queued', { runId, hasInput: input != null }); - if (activeRun == null) { - return startRun(input, runId); - } - if (pendingRun != null) { - const superseded = pendingRun; - pendingRun = null; - mark('run-superseded', { runId: superseded.runId, by: runId }); - superseded.resolve({ - output: null, - error: null, - exitCode: null, - value: null, - warnings: null, - cancelled: true, - }); - } - scheduleSupersede(); - return new Promise((resolve, reject) => { - pendingRun = { input, runId, resolve, reject }; - mark('run-pending', { runId }); - }); -}; - -elixir.retry ??= async () => { - elixir.failed = false; - elixir.error = null; - elixir.ready = false; - elixir.init = undefined; - const bridge = elixir.popcorn as ElixirBridge | undefined; - try { - if (bridge != null) { - await bridge.restart(); - } else { - await elixir.ensureInit?.(); - } - } catch (err) { - elixir.failed = true; - elixir.error = getErrorMessage(err); - throw err; - } -}; - -elixir.timings = timings; -const LOADED_TIMEOUT_MS = INIT_VM_TIMEOUT_MS + EVAL_TIMEOUT_MS; -elixir.loaded = new Promise((resolve, reject) => { - const started = performance.now(); - const interval = setInterval(() => { - if (elixir.failed) { - clearInterval(interval); - mark('loaded-rejected', { error: elixir.error }); - reject(new Error(elixir.error || 'Failed to initialize the Elixir environment')); - } else if (elixir.ready) { - clearInterval(interval); - mark('loaded-resolved'); - resolve(); - } else if (performance.now() - started > LOADED_TIMEOUT_MS) { - clearInterval(interval); - elixir.failed = true; - elixir.error = 'Timed out initializing the Elixir runtime'; - mark('loaded-timeout'); - reject(new Error(elixir.error)); - } - }, 50); -}); -// The starter awaits `loaded` and may not catch. Attaching a no-op handler -// keeps a recoverable boot failure from becoming an unhandled rejection; the -// original promise still rejects for callers that want the error. -void elixir.loaded.catch(() => undefined); - -window.livecodes['elixir-wasm'] = elixir; diff --git a/src/livecodes/languages/erlang-wasm/__tests__/lang-erlang-wasm.spec.ts b/src/livecodes/languages/erlang-wasm/__tests__/lang-erlang-wasm.spec.ts new file mode 100644 index 0000000000..e117276d23 --- /dev/null +++ b/src/livecodes/languages/erlang-wasm/__tests__/lang-erlang-wasm.spec.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { erlangWasm } from '../lang-erlang-wasm'; + +const readSource = (file: string) => readFileSync(resolve(__dirname, '..', file), 'utf8'); + +describe('Erlang (Wasm) language spec', () => { + test('is registered as erlang-wasm, with the wasm compiler script', () => { + expect(erlangWasm.name).toBe('erlang-wasm'); + expect(erlangWasm.title).toBe('Erlang (Wasm)'); + expect(erlangWasm.compiler).toMatchObject({ + scriptType: 'text/erlang-wasm', + compiledCodeLanguage: 'erlang', + liveReload: true, + }); + }); + + test('accepts the common Erlang extensions', () => { + expect(erlangWasm.extensions).toEqual( + expect.arrayContaining(['erl', 'erlang', 'erlang-wasm', 'wasm.erl']), + ); + }); + + test('warns that the first run is a large download', () => { + expect(erlangWasm.largeDownload).toBe(true); + }); + + test('uses a code editor with Erlang support', () => { + expect(erlangWasm.editor).toBe('script'); + expect(erlangWasm.editorSupport?.monaco?.language).toBe('erlang'); + expect(erlangWasm.editorSupport?.codejar?.language).toBe('erlang'); + }); +}); + +describe('Erlang (Wasm) runtime', () => { + const script = readSource('lang-erlang-wasm-script.ts'); + + test('reuses the shared beam runtime', () => { + expect(script).toContain("from '../beam-wasm/runtime'"); + expect(script).toContain("from '../beam-wasm/prelude'"); + expect(script).toContain("apiKey: 'erlang'"); + expect(script).toContain("apiAlias: 'erlang-wasm'"); + }); + + test('wraps expressions and compiles modules', () => { + expect(script).toContain("'eval_erlang_module'"); + expect(script).toContain('wrapErlang'); + expect(script).toContain('stripTrailingDot'); + expect(script).toContain("replace(/\\berlang:halt\\(/g, 'livecodes_io:halt(')"); + }); + + test('keeps the Erlang output newline and inspected result text', () => { + expect(script).toContain('stripTrailingNewline: false'); + expect(script).toContain('normalizeResult: (data) =>'); + }); +}); diff --git a/src/livecodes/languages/erlang-wasm/index.ts b/src/livecodes/languages/erlang-wasm/index.ts new file mode 100644 index 0000000000..7d3d705552 --- /dev/null +++ b/src/livecodes/languages/erlang-wasm/index.ts @@ -0,0 +1 @@ +export * from './lang-erlang-wasm'; diff --git a/src/livecodes/languages/erlang-wasm/lang-erlang-wasm-script.ts b/src/livecodes/languages/erlang-wasm/lang-erlang-wasm-script.ts new file mode 100644 index 0000000000..bbdca0fbe6 --- /dev/null +++ b/src/livecodes/languages/erlang-wasm/lang-erlang-wasm-script.ts @@ -0,0 +1,64 @@ +import { atomvmWasmBaseUrl } from '../../vendors'; +import { BEAM_PRELUDE } from '../beam-wasm/prelude'; +import { createBeamRuntime, parseTaggedResult, type ParsedResult } from '../beam-wasm/runtime'; + +const BUNDLE_URL = `${atomvmWasmBaseUrl}bundle.avm`; +const SCRIPT_TYPE = 'text/erlang-wasm'; + +const erlangEscapes: Record = { + '\\': '\\\\', + '"': '\\"', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', +}; + +// An Erlang string literal is a charlist; `:livecodes_io.start/1` normalizes it. +const escapeErlangString = (value: string) => + value.replace(/[\\"\n\r\t]/g, (char) => erlangEscapes[char] ?? char); + +const stripTrailingDot = (code: string) => code.replace(/\.(\s*(?:%[^\n]*)?\s*)$/, '$1'); + +const wrapErlang = (code: string, input: string) => { + // `erlang:halt/1` is a NIF this build does not have, so route it through the + // shared device. A bare `halt/0-1` is rewritten too, as in upstream Popcorn. + // The final expression dot is removed so the code can be spliced into `try`. + const source = code + .replace(/\berlang:halt\(/g, 'livecodes_io:halt(') + .replace(/(? {lc_exit, LcStatus}; LcKind:LcReason -> {lc_error, {LcKind, LcReason}} end, +group_leader(LcGL, self()), +livecodes_io:stop(LcDev), +LcValue.`; +}; + +// Erlang modules are compiled and loaded as written, so they stay callable in +// later runs; expressions are wrapped for stdin, exit codes and error handling. +const actionFor = (code: string) => + code.startsWith('-module(') ? 'eval_erlang_module' : 'eval_erlang'; + +const parseErlangResult = (text: string): ParsedResult => parseTaggedResult(text, 'Erlang error'); + +createBeamRuntime({ + apiKey: 'erlang', + apiAlias: 'erlang-wasm', + label: 'Erlang', + scriptType: SCRIPT_TYPE, + bundleUrl: BUNDLE_URL, + prelude: BEAM_PRELUDE, + buildCall: (code, stdin) => { + const action = actionFor(code); + return [action, action === 'eval_erlang_module' ? code : wrapErlang(code, stdin)]; + }, + parseResult: parseErlangResult, + parsePreludeResult: (text) => parseTaggedResult(text, 'Elixir prelude error'), + // Unlike the Elixir wrapper, the Erlang wrapper does not append a newline. + stripTrailingNewline: false, + // The runtime returns the inspected term as a string; keep it as-is. + normalizeResult: (data) => (data == null ? '' : String(data)), +}); diff --git a/src/livecodes/languages/erlang-wasm/lang-erlang-wasm.ts b/src/livecodes/languages/erlang-wasm/lang-erlang-wasm.ts new file mode 100644 index 0000000000..848880414e --- /dev/null +++ b/src/livecodes/languages/erlang-wasm/lang-erlang-wasm.ts @@ -0,0 +1,29 @@ +import { codemirrorLegacy } from '../../editor/codemirror/utils'; +import type { LanguageSpecs } from '../../models'; +import { codeMirrorBaseUrl, monacoLanguagesBaseUrl } from '../../vendors'; + +export const erlangWasm: LanguageSpecs = { + name: 'erlang-wasm', + title: 'Erlang (Wasm)', + compiler: { + factory: () => async (code) => code, + scripts: ({ baseUrl }) => [baseUrl + '{{hash:lang-erlang-wasm-script.js}}'], + scriptType: 'text/erlang-wasm', + compiledCodeLanguage: 'erlang', + liveReload: true, + }, + extensions: ['erl', 'erlang', 'erlang-wasm', 'wasm.erl'], + editor: 'script', + editorSupport: { + monaco: { + languageSupport: monacoLanguagesBaseUrl + 'erlang.js', + language: 'erlang', + }, + codemirror: { + languageSupport: async () => + codemirrorLegacy((await import(codeMirrorBaseUrl + 'codemirror-lang-erlang.js')).erlang), + }, + codejar: { language: 'erlang' }, + }, + largeDownload: true, +}; diff --git a/src/livecodes/languages/languages.ts b/src/livecodes/languages/languages.ts index ce10fb228f..12b1d9d986 100644 --- a/src/livecodes/languages/languages.ts +++ b/src/livecodes/languages/languages.ts @@ -29,6 +29,7 @@ import { duckdb } from './duckdb'; import { ejs } from './ejs'; import { elixirWasm } from './elixir-wasm'; import { elm } from './elm'; +import { erlangWasm } from './erlang-wasm'; import { eta } from './eta'; import { fanak } from './fanak'; import { fennel } from './fennel'; @@ -205,6 +206,7 @@ export const languages: LanguageSpecs[] = [ fsharpWasm, fanak, elixirWasm, + erlangWasm, haskell, haskellWasm, scheme, diff --git a/src/livecodes/models.ts b/src/livecodes/models.ts index dd8af7f163..c4b816dee3 100644 --- a/src/livecodes/models.ts +++ b/src/livecodes/models.ts @@ -242,6 +242,7 @@ export type ScriptType = | 'text/ada-wasm' | 'text/v-wasm' | 'text/elixir-wasm' + | 'text/erlang-wasm' | 'text/assembly' | 'application/json' | 'application/lua' diff --git a/src/livecodes/templates/starter/elixir-wasm-starter.ts b/src/livecodes/templates/starter/elixir-wasm-starter.ts index d020ce1d83..659bbb29d8 100644 --- a/src/livecodes/templates/starter/elixir-wasm-starter.ts +++ b/src/livecodes/templates/starter/elixir-wasm-starter.ts @@ -33,8 +33,12 @@ export const elixirWasmStarter: Template = { button.onclick = async () => { button.disabled = true; - const { output } = await livecodes.elixir.run(window.count); - update(output); + try { + const { output } = await livecodes.elixir.run(window.count); + update(output); + } finally { + button.disabled = false; + } }; function update(output) { diff --git a/src/livecodes/templates/starter/erlang-wasm-starter.ts b/src/livecodes/templates/starter/erlang-wasm-starter.ts new file mode 100644 index 0000000000..b89cc46628 --- /dev/null +++ b/src/livecodes/templates/starter/erlang-wasm-starter.ts @@ -0,0 +1,89 @@ +import type { Template } from '../../models'; + +export const erlangWasmStarter: Template = { + name: 'erlang-wasm', + title: window.deps.translateString('templates.starter.erlang-wasm', 'Erlang Starter'), + thumbnail: 'assets/templates/erlang.svg', + activeEditor: 'script', + markup: { + language: 'html', + content: ` +
    +

    Hello, World!

    + +

    You clicked 0 times.

    + +
    + + +`.trimStart(), + }, + style: { + language: 'css', + content: ` +.container, +.container button { + text-align: center; + font: 1em sans-serif; +} +.logo { + width: 150px; +} +`.trimStart(), + }, + script: { + language: 'erlang-wasm', + content: ` +Count = + case io:get_line("") of + eof -> 0; + Value -> binary_to_integer(binary:replace(Value, <<"\\n">>, <<>>, [global])) + end, +io:format("Erlang~n"), +io:format("~p~n", [Count + 1]). +`.trimStart(), + }, +}; diff --git a/src/livecodes/templates/starter/index.ts b/src/livecodes/templates/starter/index.ts index 6dcee37419..ec07aba5ac 100644 --- a/src/livecodes/templates/starter/index.ts +++ b/src/livecodes/templates/starter/index.ts @@ -29,6 +29,7 @@ import { diagramsStarter } from './diagrams-starter'; import { duckdbStarter } from './duckdb-starter'; import { elixirWasmStarter } from './elixir-wasm-starter'; import { elmStarter } from './elm-starter'; +import { erlangWasmStarter } from './erlang-wasm-starter'; import { fanakStarter } from './fanak-starter'; import { fennelStarter } from './fennel-starter'; import { flutterStarter } from './flutter-starter'; @@ -161,6 +162,7 @@ export const starterTemplates = [ fortranWasmStarter, dWasmStarter, elixirWasmStarter, + erlangWasmStarter, vWasmStarter, rustWasmStarter, swiftWasmStarter, diff --git a/src/livecodes/vendors.ts b/src/livecodes/vendors.ts index 40da240416..6155d8c2a9 100644 --- a/src/livecodes/vendors.ts +++ b/src/livecodes/vendors.ts @@ -152,9 +152,7 @@ export const duckdbWasmModuleUrl = /* @__PURE__ */ getModuleUrl('@duckdb/duckdb- export const ejsUrl = /* @__PURE__ */ getUrl('ejs@4.0.1/ejs.js'); -export const elixirWasmBaseUrl = /* @__PURE__ */ getUrl( - 'gh:live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8/wasm/', -); +export const atomvmWasmBaseUrl = /* @__PURE__ */ getUrl('@hmh-3080/atomvm-wasm@0.1.0/wasm/'); export const elkjsBaseUrl = /* @__PURE__ */ getUrl('elkjs@0.8.2/lib/'); diff --git a/src/netlify.toml b/src/netlify.toml index 35de1cb8b0..6127a17f22 100644 --- a/src/netlify.toml +++ b/src/netlify.toml @@ -2,11 +2,4 @@ for = "/*" [headers.values] access-control-allow-origin = "*" - # Cross-origin isolation (required by threaded Wasm runtimes such as - # Elixir on AtomVM, which runs in sandbox result frames). - # NOTE: COOP must be plain `same-origin`; `same-origin-allow-popups` - # does not yield an isolated context. This severs popup/opener links, - # so popup-based OAuth (Firebase signInWithPopup) needs a redirect flow. - cross-origin-opener-policy = "same-origin" - cross-origin-embedder-policy = "credentialless" cross-origin-resource-policy = "cross-origin" diff --git a/src/sdk/internal.ts b/src/sdk/internal.ts index 0bc3aca52c..57e12a07d0 100644 --- a/src/sdk/internal.ts +++ b/src/sdk/internal.ts @@ -39,7 +39,6 @@ const allowAttributes: Record<'chrome' | 'firefox' | 'default', string[]> = { 'camera', 'clipboard-read', 'clipboard-write', - 'cross-origin-isolated', 'display-capture', 'encrypted-media', 'geolocation', @@ -64,7 +63,6 @@ const allowAttributes: Record<'chrome' | 'firefox' | 'default', string[]> = { 'accelerometer', 'ambient-light-sensor', 'camera', - 'cross-origin-isolated', 'display-capture', 'encrypted-media', 'geolocation', diff --git a/src/sdk/models.ts b/src/sdk/models.ts index e2d0d2ec5a..a9726d4517 100644 --- a/src/sdk/models.ts +++ b/src/sdk/models.ts @@ -255,6 +255,10 @@ export type Language = | 'exs' | 'elixir-wasm' | 'wasm.ex' + | 'erlang' + | 'erl' + | 'erlang-wasm' + | 'wasm.erl' | 'rust' | 'rs' | 'rust-wasm' @@ -541,6 +545,7 @@ export type TemplateName = | 'cobol-wasm' | 'v-wasm' | 'elixir-wasm' + | 'erlang-wasm' | 'java' | 'csharp-wasm' | 'blazor-wasm' @@ -974,6 +979,7 @@ export type CustomSettings = Partial< | 'text/zig-wasm' | 'text/v-wasm' | 'text/elixir-wasm' + | 'text/erlang-wasm' | 'application/json' | 'application/lua' | 'text/fennel' diff --git a/storybook/_stories/EmbedOptions/template.ts b/storybook/_stories/EmbedOptions/template.ts index 5e338c76d5..a329894b6c 100644 --- a/storybook/_stories/EmbedOptions/template.ts +++ b/storybook/_stories/EmbedOptions/template.ts @@ -62,6 +62,7 @@ const storyDef: StoryDef = { FortranWasm: { props: { template: 'fortran-wasm' }, storyName: 'Fortran (Wasm)' }, VWasm: { props: { template: 'v-wasm' } }, ElixirWasm: { props: { template: 'elixir-wasm' } }, + ErlangWasm: { props: { template: 'erlang-wasm' } }, RustWasm: { props: { template: 'rust-wasm' } }, SwiftWasm: { props: { template: 'swift-wasm' } }, ZigWasm: { props: { template: 'zig-wasm' } }, diff --git a/storybook/preact/stories/EmbedOptions/template.stories.ts b/storybook/preact/stories/EmbedOptions/template.stories.ts index d15d8c22d9..14cbd343e4 100644 --- a/storybook/preact/stories/EmbedOptions/template.stories.ts +++ b/storybook/preact/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,7 @@ export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/react/stories/EmbedOptions/template.stories.ts b/storybook/react/stories/EmbedOptions/template.stories.ts index d15d8c22d9..14cbd343e4 100644 --- a/storybook/react/stories/EmbedOptions/template.stories.ts +++ b/storybook/react/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,7 @@ export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/solid/stories/EmbedOptions/template.stories.ts b/storybook/solid/stories/EmbedOptions/template.stories.ts index d15d8c22d9..14cbd343e4 100644 --- a/storybook/solid/stories/EmbedOptions/template.stories.ts +++ b/storybook/solid/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,7 @@ export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/svelte/stories/EmbedOptions/template.stories.ts b/storybook/svelte/stories/EmbedOptions/template.stories.ts index d15d8c22d9..14cbd343e4 100644 --- a/storybook/svelte/stories/EmbedOptions/template.stories.ts +++ b/storybook/svelte/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,7 @@ export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/vue/stories/EmbedOptions/template.stories.ts b/storybook/vue/stories/EmbedOptions/template.stories.ts index d15d8c22d9..14cbd343e4 100644 --- a/storybook/vue/stories/EmbedOptions/template.stories.ts +++ b/storybook/vue/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,7 @@ export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/storybook/web-components/stories/EmbedOptions/template.stories.ts b/storybook/web-components/stories/EmbedOptions/template.stories.ts index d15d8c22d9..14cbd343e4 100644 --- a/storybook/web-components/stories/EmbedOptions/template.stories.ts +++ b/storybook/web-components/stories/EmbedOptions/template.stories.ts @@ -79,6 +79,7 @@ export const FortranWasm = livecodesStory({ template: 'fortran-wasm' }); FortranWasm.storyName = 'Fortran (Wasm)'; export const VWasm = livecodesStory({ template: 'v-wasm' }); export const ElixirWasm = livecodesStory({ template: 'elixir-wasm' }); +export const ErlangWasm = livecodesStory({ template: 'erlang-wasm' }); export const RustWasm = livecodesStory({ template: 'rust-wasm' }); export const SwiftWasm = livecodesStory({ template: 'swift-wasm' }); export const ZigWasm = livecodesStory({ template: 'zig-wasm' }); diff --git a/vendor-licenses.md b/vendor-licenses.md index c1b481c533..f45e2a92ae 100644 --- a/vendor-licenses.md +++ b/vendor-licenses.md @@ -380,6 +380,8 @@ LFortran (Wasm): [BSD 3-Clause License](https://github.com/lfortran/lfortran/blo V (Wasm): [MIT License](https://github.com/vlang/v/blob/master/LICENSE) (the V compiler and its standard library, compiled to WebAssembly by @live-codes/v-wasm; the Clang toolchain it links through is covered by LLVM / LLD below) +@hmh-3080/atomvm-wasm: [Apache License 2.0](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm) (the single-threaded AtomVM WebAssembly runtime used by Elixir (Wasm) and Erlang (Wasm)) + Elixir (Wasm): [Apache License 2.0](https://github.com/elixir-lang/elixir/blob/main/LICENSE) (the Elixir 1.17.3 compiler and standard library, packaged for browser execution) Elixir logo: registered trademark of The Elixir Team, used under [nominative use](https://elixir-lang.org/trademarks/) to identify the supported language. From e86cd91917f92a9e4b4f71cfd970a34b5f8a4808 Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Fri, 23 Oct 2026 15:20:15 +0300 Subject: [PATCH 05/12] test: drop cross-origin-isolated allow expectation --- src/sdk/__tests__/createPlayground.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sdk/__tests__/createPlayground.spec.ts b/src/sdk/__tests__/createPlayground.spec.ts index 3c1c852684..c3d8cb27a4 100644 --- a/src/sdk/__tests__/createPlayground.spec.ts +++ b/src/sdk/__tests__/createPlayground.spec.ts @@ -265,7 +265,6 @@ describe('createPlayground – iframe creation', () => { expect(allow).toContain('writer'); expect(allow).toContain('rewriter'); expect(allow).toContain('language-detector'); - expect(allow).toContain('cross-origin-isolated'); // Should not contain Firefox/default-only attributes expect(allow).not.toContain('ambient-light-sensor'); expect(allow).not.toContain('payment'); From 73f719a96c720ef2fb510f0af1a75155656a29e6 Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Fri, 23 Oct 2026 15:21:20 +0300 Subject: [PATCH 06/12] style: format language info HTML --- src/livecodes/html/language-info.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/livecodes/html/language-info.html b/src/livecodes/html/language-info.html index c0c831874e..34d353876d 100644 --- a/src/livecodes/html/language-info.html +++ b/src/livecodes/html/language-info.html @@ -3098,9 +3098,7 @@

    Erlang (Wasm)

    Erlang official website
  • - Erlang documentation + Erlang documentation
  • Date: Fri, 23 Oct 2026 16:36:41 +0300 Subject: [PATCH 07/12] docs: move Wasm benchmark notes to browser-elixir --- docs/docs/languages/elixir-wasm.mdx | 10 -- .../languages/elixir-wasm/benchmark.md | 109 ------------------ 2 files changed, 119 deletions(-) delete mode 100644 src/livecodes/languages/elixir-wasm/benchmark.md diff --git a/docs/docs/languages/elixir-wasm.mdx b/docs/docs/languages/elixir-wasm.mdx index b2e8223b48..cdc15660cb 100644 --- a/docs/docs/languages/elixir-wasm.mdx +++ b/docs/docs/languages/elixir-wasm.mdx @@ -113,16 +113,6 @@ headers, so it runs in normal (non-isolated) result pages and keeps popup-based Not currently supported. -## Performance - -On the benchmark machine, a cold start took about 9 seconds from navigation to the first result; -about 5 seconds of that is the Elixir runtime, including a 3.6 second prelude compile. After the -first run, a warm `run()` takes about 120 ms and a normal edit takes about 200 ms because the VM is -reused. Three edits made during a 3 second evaluation complete in about 3.3 seconds, with pending runs -coalesced to the latest one. See -[`src/livecodes/languages/elixir-wasm/benchmark.md`](https://github.com/live-codes/livecodes/blob/develop/src/livecodes/languages/elixir-wasm/benchmark.md) -for methodology, the complete cold-start breakdown, and the AtomVM/Popcorn comparison. - ## Live Reload By default, new code changes are sent to the result page for re-evaluation without a full page reload diff --git a/src/livecodes/languages/elixir-wasm/benchmark.md b/src/livecodes/languages/elixir-wasm/benchmark.md deleted file mode 100644 index 6449d1223a..0000000000 --- a/src/livecodes/languages/elixir-wasm/benchmark.md +++ /dev/null @@ -1,109 +0,0 @@ -# Elixir (Wasm) performance notes - -These measurements were taken while fixing the Elixir (Wasm) runtime. They are -representative numbers for this machine, not a cross-device benchmark suite. -The browser-level lifecycle and performance assertions live in -`e2e/specs/elixir-wasm.spec.ts`. - -## Environment - -- Linux, AMD Ryzen 5 4600G (6 cores / 12 threads), 13 GiB RAM. -- Google Chrome for Testing 148.0.7778.96, headless, `--enable-precise-memory-info`. -- AtomVM runtime: Elixir 1.17.3 on OTP 27, AtomVM 0.7.0-dev (32-bit), pinned to - `live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8`. -- Popcorn comparison: `@swmansion/popcorn@0.4.0-next.9` (the same version as - Hex `popcorn 0.4.0-next.9`), Elixir 1.20.4 on OTP 29.0.6, full BEAM. -- The numbers below were taken with the earlier threaded build, which required COOP/COEP. The - shipped no-pthread build runs without isolation headers; the E2E suite asserts - `crossOriginIsolated === false`. - -## Before and after - -The old bridge recreated the runtime iframe for every run. A code edit therefore -downloaded and initialized the VM again and recompiled the Elixir prelude. - -| Measurement | Before | After | -| -------------------------------------- | ----------------------------: | ----------------------------: | -| Cold start, navigation to first result | ~8.8 s | ~8.8-9.5 s (network variance) | -| Warm run (`livecodes.elixir.run()`) | ~6.0 s (new iframe + VM boot) | ~119 ms (reuses worker) | -| Edit, including LiveReload and result | ~64 s | ~200 ms | -| Three edits while a 3 s run was active | ~63.6 s | ~3.29 s | - -The cold start is still dominated by the one-time prelude compile. The worker -removes that cost from edits, but it does not hide it on first boot. Cold-start -variation is mostly network and app startup; the Elixir-specific portion was -about 5.3 s in the final run. This change does not claim a cold-start win: the -first boot still compiles the same prelude, and the measured range overlaps the -old baseline. It removes the repeated compile from edit iterations. - -### Final cold-start breakdown - -Measured from the result-page script evaluation: - -| Phase | Time | -| ----------------------------------------------------------- | -----: | -| Asset fetch / module load until the worker's `init` message | 1.23 s | -| AtomVM WASM initialization | 281 ms | -| Elixir prelude compile | 3.57 s | -| First user evaluation | 121 ms | -| Total from result script to first completed run | ~5.3 s | - -Another ~4.2 s before the result-page script runs is LiveCodes app startup; it is -not part of the Elixir runtime. The 3.57 s prelude compile happens once per -worker boot. A normal edit reuses the booted worker, which is why the edit path -drops from tens of seconds to ~200 ms. - -### Rapid edits - -While a `Process.sleep(3000)` evaluation is active, three further edits are -coalesced to the latest one. The final result appears after the active run (or -after the 10 s supersede timeout), with one worker boot. This avoids building a -queue of stale evaluations and prevents a newer edit from being blocked behind a -hung one. - -## AtomVM vs Popcorn OTP/BEAM - -Both runtimes were measured in minimal pages with a single worker, after boot -and warm evaluation, so app-level LiveCodes work is excluded from the -worker-level numbers. - -| Metric | AtomVM (production) | Popcorn OTP/BEAM | -| ---------------------------------- | ----------------------------------------: | --------------------------------------: | -| Runtime | Elixir 1.17.3 / OTP 27 | Elixir 1.20.4 / OTP 29.0.6 | -| First-load transfer | ~3.48 MB Brotli | ~7.88 MB (local server) | -| Decoded runtime assets | ~7.88 MB | ~30 MB build output | -| Init time, local assets | 266 ms | 574 ms | -| First direct evaluation | 29.3 ms | 31.2 ms | -| Warm direct evaluation | 4.4 ms | 1.0-1.1 ms | -| Total memory after warm evaluation | 32.9 MB | 101.2 MB | -| Worker memory only | 31.9 MB | 100.0 MB | -| Isolation headers (threaded build) | COOP `same-origin`, COEP `credentialless` | COOP `same-origin`, COEP `require-corp` | - -Notes: - -- The AtomVM transfer figure is the actual Brotli-compressed CDN response: - `AtomVM.mjs` 35.8 KB, `AtomVM.wasm` 248 KB, and `bundle.avm` 3.36 MB. The - decoded sizes are 150 KB, 997 KB, and 7.11 MB. -- The Popcorn local benchmark server compressed the OTP tarballs but left - `beam.wasm` and the JS modules uncompressed. A static host with Brotli enabled - could reduce that transfer further. -- Popcorn's ~30 MB cook output is a build-time artifact, not all of it is - downloaded by the browser. It shows the packaging cost of shipping a complete - OTP/BEAM image. -- The direct warm-evaluation numbers are worker calls. A LiveCodes `run()` takes - ~119 ms end-to-end because it also parses the wrapper with - `Code.string_to_quoted/1`, evaluates it with `Code.eval_string/2`, and decodes - the tagged result. That overhead is common to both runtimes in the app; it is - not the VM call itself. -- Memory was measured with - `performance.measureUserAgentSpecificMemory()` in an isolated page after one - boot and two or three evaluations. It includes the page, worker, and shared - memory; it excludes the browser process, GPU process, and page cache. The - numbers are representative single runs and should be treated as approximate. - -Popcorn is not a drop-in replacement for the production runtime. It uses the -full BEAM, has broader OTP compatibility, but requires Mix build-time packaging -and a substantially larger download and memory footprint. The AtomVM runtime -keeps the current pinned artifact and the persistent-worker architecture. Any -future migration should be justified by a compatibility need that cannot be -fixed in the AtomVM runtime, not by the benchmark alone. From a093ff4f9deaa540f56c8ef849bc936d313d1a64 Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Fri, 23 Oct 2026 16:52:30 +0300 Subject: [PATCH 08/12] docs: clarify when loaded resolves for empty programs --- docs/docs/languages/elixir-wasm.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/docs/languages/elixir-wasm.mdx b/docs/docs/languages/elixir-wasm.mdx index cdc15660cb..576131a0bb 100644 --- a/docs/docs/languages/elixir-wasm.mdx +++ b/docs/docs/languages/elixir-wasm.mdx @@ -31,9 +31,10 @@ The program runs in a persistent worker owned by the result page, not in the res context. The browser global `livecodes.elixir` object is the result page's interface to that worker: - `livecodes.elixir.input`: the standard input passed to the program. -- `livecodes.elixir.loaded`: a promise that resolves after the runtime has downloaded and the first - program run has completed. It rejects if the environment fails to load. Other helpers should be - used after it resolves. +- `livecodes.elixir.loaded`: a promise that resolves after the initial program run has completed. + For a non-empty program, that includes downloading and booting the runtime; an empty initial + program resolves before the runtime is downloaded, and initialization starts on the first + non-empty run. It rejects if initialization fails. Other helpers should be used after it resolves. - `livecodes.elixir.output`: the program's standard output. - `livecodes.elixir.value`: the inspected return value of the program. - `livecodes.elixir.warnings`: warnings emitted while compiling or running the program. From 61159a815dedb0d09895d95b63af417751d50a9b Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Fri, 23 Oct 2026 18:59:27 +0300 Subject: [PATCH 09/12] test: fix flaky Elixir Wasm spec due to live reload race Move the setConfigAndRun step out of the mixed direct-run test into its own test so a delayed LiveReload rewrite can no longer supersede subsequent direct runs (which caused runId shifts and 120s waits in CI). --- e2e/specs/elixir-wasm.spec.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/e2e/specs/elixir-wasm.spec.ts b/e2e/specs/elixir-wasm.spec.ts index 6792a763af..db563ba67d 100644 --- a/e2e/specs/elixir-wasm.spec.ts +++ b/e2e/specs/elixir-wasm.spec.ts @@ -192,17 +192,6 @@ test.describe('Elixir (Wasm)', () => { expect(warm.output).toBe('Elixir\n0'); expect(warm.ms).toBeLessThan(5_000); - const workerCreatesBefore = initial.timings.filter( - (timing) => timing.name === 'worker-create-start', - ).length; - const edited = await setConfigAndRun(page, 'IO.puts("edited")\n:edited_value'); - expect(edited.output).toBe('edited'); - expect(edited.value).toBe(':edited_value'); - expect(edited.error).toBeNull(); - expect(edited.timings.filter((timing) => timing.name === 'worker-create-start').length).toBe( - workerCreatesBefore, - ); - const stdout = await runDirect(page, 'IO.puts("hello")'); expect(stdout.output).toBe('hello'); expect(stdout.value).toBe(':ok'); @@ -244,6 +233,21 @@ test.describe('Elixir (Wasm)', () => { expect(empty.ready).toBe(true); }); + test('keeps the VM alive across config edits', async ({ page }) => { + const initial = await boot(page); + const workerCreatesBefore = initial.timings.filter( + (timing) => timing.name === 'worker-create-start', + ).length; + + const edited = await setConfigAndRun(page, 'IO.puts("edited")\n:edited_value'); + expect(edited.output).toBe('edited'); + expect(edited.value).toBe(':edited_value'); + expect(edited.error).toBeNull(); + expect(edited.timings.filter((timing) => timing.name === 'worker-create-start').length).toBe( + workerCreatesBefore, + ); + }); + test('coalesces rapid edits and recovers from a VM crash', async ({ page }) => { const initial = await boot(page); const workerCreates = initial.timings.filter( From 8ed7f33b9972729cf55c21454210703f70aacdce Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Sat, 24 Oct 2026 22:14:23 +0300 Subject: [PATCH 10/12] chore: revert cross-origin isolation header changes Restore the upstream dev server commands and remove the header middleware scripts, _headers, and netlify.toml additions requested in review. The single-threaded runtime does not need COOP/COEP. Update the Playwright header assertions to match. --- e2e/specs/elixir-wasm.spec.ts | 9 +++++---- package.json | 4 ++-- scripts/app-headers.js | 11 ----------- scripts/sandbox-headers.js | 8 -------- scripts/serve-app.js | 14 -------------- scripts/serve-sandbox.js | 16 ---------------- src/_headers | 3 --- src/netlify.toml | 1 - 8 files changed, 7 insertions(+), 59 deletions(-) delete mode 100644 scripts/app-headers.js delete mode 100644 scripts/sandbox-headers.js delete mode 100644 scripts/serve-app.js delete mode 100644 scripts/serve-sandbox.js diff --git a/e2e/specs/elixir-wasm.spec.ts b/e2e/specs/elixir-wasm.spec.ts index db563ba67d..637f83815b 100644 --- a/e2e/specs/elixir-wasm.spec.ts +++ b/e2e/specs/elixir-wasm.spec.ts @@ -381,7 +381,9 @@ test.describe('Elixir (Wasm)', () => { expect(rerun.error).toBeNull(); }); - test('runs the result frame in the dev sandbox without isolation headers', async ({ page }) => { + test('runs the result frame in the dev sandbox without cross-origin isolation', async ({ + page, + }) => { await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 }); const frame = await (async () => { const start = Date.now(); @@ -397,7 +399,6 @@ test.describe('Elixir (Wasm)', () => { const headers = response.headers(); expect(headers['cross-origin-opener-policy']).toBeUndefined(); expect(headers['cross-origin-embedder-policy']).toBeUndefined(); - expect(headers['cross-origin-resource-policy']).toBe('cross-origin'); const isolated = await frame.evaluate(() => window.crossOriginIsolated); expect(isolated).toBe(false); @@ -408,11 +409,11 @@ test.describe('Elixir (Wasm)', () => { const productionHeaders = readFileSync('src/_headers', 'utf8'); expect(productionHeaders).not.toContain('Cross-Origin-Opener-Policy'); expect(productionHeaders).not.toContain('Cross-Origin-Embedder-Policy'); - expect(productionHeaders).toContain('Cross-Origin-Resource-Policy: cross-origin'); + expect(productionHeaders).not.toContain('Cross-Origin-Resource-Policy'); const netlifyConfig = readFileSync('src/netlify.toml', 'utf8'); expect(netlifyConfig).not.toContain('cross-origin-opener-policy'); expect(netlifyConfig).not.toContain('cross-origin-embedder-policy'); - expect(netlifyConfig).toContain('cross-origin-resource-policy = "cross-origin"'); + expect(netlifyConfig).not.toContain('cross-origin-resource-policy'); }); }); diff --git a/package.json b/package.json index b88cd09446..1c5b1bdd59 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "docs": "cd docs && npm run start", "storybook": "run-p storybook:*", "serve": "run-p serve:*", - "serve:app": "node ./scripts/serve-app.js", - "serve:sandbox": "node ./scripts/serve-sandbox.js", + "serve:app": "live-server build --watch=build/tmp/ --cors --quiet", + "serve:sandbox": "live-server src/livecodes/html/sandbox/ --port=8085 --cors --quiet --no-browser", "watch": "nodemon --watch src -e \"*\" --ignore src/livecodes/i18n/locale-paths.ts ./scripts/build.js --dev", "prebuild": "npm run i18n-exclude pre", "postbuild": "npm run i18n-exclude post", diff --git a/scripts/app-headers.js b/scripts/app-headers.js deleted file mode 100644 index 3a1a5192a4..0000000000 --- a/scripts/app-headers.js +++ /dev/null @@ -1,11 +0,0 @@ -// Connect middleware for the dev app server (`npm run serve:app`). -// Cross-origin isolation is deliberately not enabled: the Beam (Elixir/Erlang) -// runtime uses a single-threaded AtomVM build and does not need -// SharedArrayBuffer. Keeping COOP/COEP off also preserves popup-based OAuth. -module.exports = function appHeaders(req, res, next) { - // The app is designed to be embedded cross-origin (SDK). Without an - // explicit CORP header, COEP defaults it to same-origin and cross-origin - // embeds are blocked (net::ERR_BLOCKED_BY_RESPONSE). - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); - next(); -}; diff --git a/scripts/sandbox-headers.js b/scripts/sandbox-headers.js deleted file mode 100644 index b72716b2b9..0000000000 --- a/scripts/sandbox-headers.js +++ /dev/null @@ -1,8 +0,0 @@ -// Connect middleware for the dev sandbox server (`npm run serve:sandbox`). -// The sandbox is embedded cross-origin by the app, so allow that explicitly. -// No COOP/COEP: the Beam runtime is single-threaded and does not require -// cross-origin isolation. -module.exports = function sandboxHeaders(req, res, next) { - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); - next(); -}; diff --git a/scripts/serve-app.js b/scripts/serve-app.js deleted file mode 100644 index 1cadc108a9..0000000000 --- a/scripts/serve-app.js +++ /dev/null @@ -1,14 +0,0 @@ -// Dev server for the app. -// Mirrors the old `live-server build ...` flags, but loads the -// isolation-headers middleware via an absolute path (live-server resolves -// relative `--middleware` paths from its own package, not the repo root). -const path = require('path'); -const liveServer = require('live-server'); - -liveServer.start({ - root: path.join(__dirname, '..', 'build'), - watch: path.join(__dirname, '..', 'build', 'tmp'), - cors: true, - quiet: true, - middleware: [path.join(__dirname, 'app-headers.js')], -}); diff --git a/scripts/serve-sandbox.js b/scripts/serve-sandbox.js deleted file mode 100644 index 2e3119befd..0000000000 --- a/scripts/serve-sandbox.js +++ /dev/null @@ -1,16 +0,0 @@ -// Dev server for the sandbox (result/compiler pages). -// Mirrors the old `live-server ... --port=8085` flags, but loads the -// isolation-headers middleware via an absolute path (live-server resolves -// relative `--middleware` paths from its own package, not the repo root). -const path = require('path'); -const liveServer = require('live-server'); - -liveServer.start({ - root: path.join(__dirname, '..', 'src', 'livecodes', 'html', 'sandbox'), - port: 8085, - cors: true, - quiet: true, - open: false, - noBrowser: true, - middleware: [path.join(__dirname, 'sandbox-headers.js')], -}); diff --git a/src/_headers b/src/_headers index 4e0edb88eb..d466dc3a5d 100644 --- a/src/_headers +++ b/src/_headers @@ -11,6 +11,3 @@ /livecodes/assets/* ! cache-control cache-control: public, max-age=14400, must-revalidate - -/* - Cross-Origin-Resource-Policy: cross-origin diff --git a/src/netlify.toml b/src/netlify.toml index 6127a17f22..51d507f223 100644 --- a/src/netlify.toml +++ b/src/netlify.toml @@ -2,4 +2,3 @@ for = "/*" [headers.values] access-control-allow-origin = "*" - cross-origin-resource-policy = "cross-origin" From 0db68b3a68f13a8efb6116663b4bf89ca1d272e6 Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Sat, 24 Oct 2026 22:42:18 +0300 Subject: [PATCH 11/12] chore: pin the Beam Wasm runtime to browser-elixir Load AtomVM and the bytecode bundle from the committed artifacts in live-codes/browser-elixir instead of the interim npm package. Update the language docs and license entry to match. --- docs/docs/languages/elixir-wasm.mdx | 10 +++++----- docs/docs/languages/erlang-wasm.mdx | 10 +++++----- .../languages/beam-wasm/lang-beam-wasm-worker.raw.js | 4 ++-- src/livecodes/vendors.ts | 4 +++- vendor-licenses.md | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/docs/languages/elixir-wasm.mdx b/docs/docs/languages/elixir-wasm.mdx index 576131a0bb..9884dd083f 100644 --- a/docs/docs/languages/elixir-wasm.mdx +++ b/docs/docs/languages/elixir-wasm.mdx @@ -88,10 +88,10 @@ reported in `error`; the next run starts a fresh worker automatically. Use The Elixir toolchain compiled to WebAssembly, running on [AtomVM](https://github.com/atomvm/AtomVM) with the -[Popcorn](https://popcorn.swmansion.com/) browser runtime. The single-threaded runtime is published -as [`@hmh-3080/atomvm-wasm`](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm); the Elixir -and Erlang bytecode bundle is pinned to -`live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8`. +[Popcorn](https://popcorn.swmansion.com/) browser runtime. The single-threaded runtime and the +Elixir/Erlang bytecode bundle are built from source in +[`live-codes/browser-elixir`](https://github.com/live-codes/browser-elixir); this playground pins +commit `e9bd2faed8eb6e2bcacd4b42f8b57ea26a770831`. ### Version @@ -132,5 +132,5 @@ https://livecodes.io/?template=elixir-wasm - [Elixir documentation](https://hexdocs.pm/elixir/) - [Learn X in Y minutes, where X=Elixir](https://learnxinyminutes.com/elixir/) - [AtomVM](https://atomvm.org/) -- [@hmh-3080/atomvm-wasm](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm) +- [browser-elixir](https://github.com/live-codes/browser-elixir) - [Popcorn: Elixir and Wasm](https://popcorn.swmansion.com/) diff --git a/docs/docs/languages/erlang-wasm.mdx b/docs/docs/languages/erlang-wasm.mdx index 5fe3da6696..135eb45c06 100644 --- a/docs/docs/languages/erlang-wasm.mdx +++ b/docs/docs/languages/erlang-wasm.mdx @@ -105,10 +105,10 @@ reported in `error`; the next run starts a fresh worker automatically. Use The Erlang runtime compiled to WebAssembly, running on [AtomVM](https://github.com/atomvm/AtomVM) with the -[Popcorn](https://popcorn.swmansion.com/) browser runtime. The single-threaded runtime is published -as [`@hmh-3080/atomvm-wasm`](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm); it shares the -pinned `live-codes/elixir-browser-eval@6b354dab35fb7454ba2812f935b52b398ca1bec8` bytecode bundle -with Elixir (Wasm). +[Popcorn](https://popcorn.swmansion.com/) browser runtime. The single-threaded runtime and the +shared Elixir/Erlang bytecode bundle are built from source in +[`live-codes/browser-elixir`](https://github.com/live-codes/browser-elixir); this playground pins +commit `e9bd2faed8eb6e2bcacd4b42f8b57ea26a770831`. ### Version @@ -146,5 +146,5 @@ https://livecodes.io/?template=erlang-wasm - [Erlang documentation](https://www.erlang.org/docs) - [Learn X in Y minutes, where X=Erlang](https://learnxinyminutes.com/erlang/) - [AtomVM](https://atomvm.org/) -- [@hmh-3080/atomvm-wasm](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm) +- [browser-elixir](https://github.com/live-codes/browser-elixir) - [Popcorn: Elixir and Wasm](https://popcorn.swmansion.com/) diff --git a/src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js b/src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js index f8af141f76..609594d3e1 100644 --- a/src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js +++ b/src/livecodes/languages/beam-wasm/lang-beam-wasm-worker.raw.js @@ -3,8 +3,8 @@ /* * Persistent Popcorn worker runtime for LiveCodes Beam (Elixir/Erlang) Wasm. * - * Based on the iframe runtime shipped with - * `live-codes/elixir-browser-eval` (itself from Software Mansion Popcorn). + * Based on Software Mansion Popcorn's iframe runtime, originally vendored through + * `live-codes/elixir-browser-eval`. * The runtime is hosted in a Worker owned by the result window, not in an * iframe owned by the result document. `document.open()/write()/close()` used * by LiveReload replaces the document but keeps the window (and therefore this diff --git a/src/livecodes/vendors.ts b/src/livecodes/vendors.ts index 4c3bbc8d0a..fbee989714 100644 --- a/src/livecodes/vendors.ts +++ b/src/livecodes/vendors.ts @@ -152,7 +152,9 @@ export const duckdbWasmModuleUrl = /* @__PURE__ */ getModuleUrl('@duckdb/duckdb- export const ejsUrl = /* @__PURE__ */ getUrl('ejs@4.0.1/ejs.js'); -export const atomvmWasmBaseUrl = /* @__PURE__ */ getUrl('@hmh-3080/atomvm-wasm@0.1.0/wasm/'); +export const atomvmWasmBaseUrl = /* @__PURE__ */ getUrl( + 'gh:live-codes/browser-elixir@e9bd2faed8eb6e2bcacd4b42f8b57ea26a770831/runtime/wasm/', +); export const elkjsBaseUrl = /* @__PURE__ */ getUrl('elkjs@0.8.2/lib/'); diff --git a/vendor-licenses.md b/vendor-licenses.md index 96591504b1..de8b512b41 100644 --- a/vendor-licenses.md +++ b/vendor-licenses.md @@ -384,7 +384,7 @@ LFortran (Wasm): [BSD 3-Clause License](https://github.com/lfortran/lfortran/blo V (Wasm): [MIT License](https://github.com/vlang/v/blob/master/LICENSE) (the V compiler and its standard library, compiled to WebAssembly by @live-codes/v-wasm; the Clang toolchain it links through is covered by LLVM / LLD below) -@hmh-3080/atomvm-wasm: [Apache License 2.0](https://www.npmjs.com/package/@hmh-3080/atomvm-wasm) (the single-threaded AtomVM WebAssembly runtime used by Elixir (Wasm) and Erlang (Wasm)) +@live-codes/browser-elixir: [MIT License](https://github.com/live-codes/browser-elixir/blob/main/LICENSE) (the build scripts and committed artifacts for the single-threaded AtomVM WebAssembly runtime and bytecode bundle used by Elixir (Wasm) and Erlang (Wasm)) Elixir (Wasm): [Apache License 2.0](https://github.com/elixir-lang/elixir/blob/main/LICENSE) (the Elixir 1.17.3 compiler and standard library, packaged for browser execution) From 4cb259a584c3fe37e2f1b5a2c2d26a91b8e746bf Mon Sep 17 00:00:00 2001 From: HMH-3080 Date: Sat, 24 Oct 2026 22:59:02 +0300 Subject: [PATCH 12/12] docs: describe the Beam Wasm artifact licenses accurately --- vendor-licenses.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vendor-licenses.md b/vendor-licenses.md index de8b512b41..f3a997b23c 100644 --- a/vendor-licenses.md +++ b/vendor-licenses.md @@ -384,15 +384,15 @@ LFortran (Wasm): [BSD 3-Clause License](https://github.com/lfortran/lfortran/blo V (Wasm): [MIT License](https://github.com/vlang/v/blob/master/LICENSE) (the V compiler and its standard library, compiled to WebAssembly by @live-codes/v-wasm; the Clang toolchain it links through is covered by LLVM / LLD below) -@live-codes/browser-elixir: [MIT License](https://github.com/live-codes/browser-elixir/blob/main/LICENSE) (the build scripts and committed artifacts for the single-threaded AtomVM WebAssembly runtime and bytecode bundle used by Elixir (Wasm) and Erlang (Wasm)) +@live-codes/browser-elixir: [MIT License](https://github.com/live-codes/browser-elixir/blob/main/LICENSE) (the build scripts and checksums for the single-threaded AtomVM WebAssembly runtime and bytecode bundle used by Elixir (Wasm) and Erlang (Wasm); the artifacts those scripts produce are built from AtomVM and Popcorn, both Apache-2.0 and listed below) Elixir (Wasm): [Apache License 2.0](https://github.com/elixir-lang/elixir/blob/main/LICENSE) (the Elixir 1.17.3 compiler and standard library, packaged for browser execution) Elixir logo: registered trademark of The Elixir Team, used under [nominative use](https://elixir-lang.org/trademarks/) to identify the supported language. -Popcorn: [Apache License 2.0](https://github.com/software-mansion/popcorn/blob/main/LICENSE) (the browser bridge and AtomVM packaging used by the pinned Elixir runtime) +Popcorn: [Apache License 2.0](https://github.com/software-mansion/popcorn/blob/main/LICENSE) (the eval server cooked into the bytecode bundle, and the browser bridge the worker runtime derives from) -AtomVM: [Apache License 2.0](https://github.com/atomvm/AtomVM/blob/main/LICENSE) (the lightweight Erlang VM running the compiled Elixir code in WebAssembly) +AtomVM: [Apache License 2.0](https://github.com/atomvm/AtomVM/blob/main/LICENSE) (the lightweight Erlang VM running the compiled Elixir code in WebAssembly; the Emscripten build comes from [FissionVM](https://github.com/software-mansion-labs/FissionVM), also Apache-2.0) @live-codes/janet-wasm: [MIT License](https://github.com/live-codes/browser-janet/blob/main/packages/janet-wasm/LICENSE)