Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
264 changes: 264 additions & 0 deletions examples/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/// Arc Web REPL — browser-based JS console powered by Arc.
///
/// Usage: cd examples && bun run server.ts
/// Open http://localhost:3000

const PORT = 3000;
const PROJECT_ROOT = new URL("..", import.meta.url).pathname;

const HTML = /* html */ `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arc Console</title>
<style>
:root {
--bg: #242424;
--surface: #1e1e1e;
--border: #3c3c3c;
--text: #d4d4d4;
--muted: #808080;
--prompt: #4e9cff;
--accent: #8ab4f8;
--number: #b5cea8;
--string: #ce9178;
--bool: #569cd6;
--symbol: #c586c0;
--err: #f28b82;
--err-icon: #ff6b6b;
--err-border: #4e2020;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font: 12px/1.4 Menlo, "DejaVu Sans Mono", Consolas, monospace;
background: var(--bg);
color: var(--text);
height: 100vh;
display: flex;
flex-direction: column;
}
.toolbar {
height: 28px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 8px;
font-size: 11px;
color: var(--muted);
}
.toolbar .tab {
padding: 4px 8px;
border-bottom: 2px solid var(--accent);
color: var(--accent);
font-weight: 500;
}
.toolbar button {
margin-left: auto;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 2px 6px;
border-radius: 2px;
font-size: 12px;
}
.toolbar button:hover { background: var(--border); color: var(--text); }
#output { flex: 1; overflow-y: auto; }
.row {
display: flex;
align-items: baseline;
padding: 2px 12px 2px 4px;
min-height: 20px;
border-bottom: 1px solid var(--border);
}
.row.err {
background: rgba(255, 0, 0, 0.07);
border-color: var(--err-border);
}
.gutter {
width: 20px;
flex-shrink: 0;
text-align: center;
user-select: none;
color: var(--muted);
}
.row.in .gutter { color: var(--prompt); }
.row.err .gutter { color: var(--err-icon); font-size: 10px; }
.row .body {
flex: 1;
white-space: pre-wrap;
word-break: break-all;
}
.row.err .body { color: var(--err); }
#prompt {
display: flex;
align-items: baseline;
padding: 3px 12px 3px 4px;
border-top: 1px solid var(--border);
}
#prompt .gutter { color: var(--prompt); }
textarea {
flex: 1;
background: none;
color: var(--text);
border: none;
font: inherit;
resize: none;
outline: none;
min-height: 17px;
max-height: 200px;
}
textarea::placeholder { color: #555; }
</style>
</head>
<body>
<div class="toolbar">
<span class="tab">Console</span>
<button id="clear" title="Clear console">\u2718</button>
</div>
<div id="output"></div>
<div id="prompt">
<span class="gutter">&gt;</span>
<textarea id="code" rows="1" placeholder="Expression" spellcheck="false" autofocus></textarea>
</div>
<script>
const $ = document.getElementById.bind(document);
const output = $("output");
const code = $("code");
$("clear").onclick = () => output.innerHTML = "";

const history = [];
let histIdx = -1;

const VALUE_COLORS = {
undefined: "var(--muted)", null: "var(--muted)",
true: "var(--bool)", false: "var(--bool)",
NaN: "var(--number)", Infinity: "var(--number)", "-Infinity": "var(--number)",
};

function valueColor(val) {
if (val in VALUE_COLORS) return VALUE_COLORS[val];
if (val[0] === '"') return "var(--string)";
if (val.startsWith("Symbol(")) return "var(--symbol)";
if (/^-?\\d/.test(val)) return "var(--number)";
return "";
}

function addRow(type, gutter, body, color) {
const row = document.createElement("div");
row.className = "row " + type;
const g = document.createElement("span");
g.className = "gutter";
g.textContent = gutter;
const b = document.createElement("span");
b.className = "body";
b.textContent = body;
if (color) b.style.color = color;
row.append(g, b);
output.appendChild(row);
return row;
}

code.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); evaluate(); return; }
const singleLine = !code.value.includes("\\n");
if (e.key === "ArrowUp" && singleLine) {
if (histIdx < history.length - 1) code.value = history[history.length - 1 - ++histIdx];
e.preventDefault();
}
if (e.key === "ArrowDown" && singleLine) {
code.value = histIdx > 0 ? history[history.length - 1 - --histIdx] : (histIdx = -1, "");
e.preventDefault();
}
};

code.oninput = () => { code.style.height = "auto"; code.style.height = Math.min(code.scrollHeight, 200) + "px"; };

async function evaluate() {
const src = code.value.trim();
if (!src) return;
history.push(src);
histIdx = -1;
code.value = "";
code.style.height = "auto";

addRow("in", ">", src);

try {
const res = await fetch("/eval", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: src }),
});
const data = await res.json();
if (data.ok) {
addRow("out", "\u21B5", data.result, valueColor(data.result));
} else {
addRow("err", "\u2715", data.error);
}
} catch (e) {
addRow("err", "\u2715", "Network error: " + e.message);
}
output.scrollTop = output.scrollHeight;
code.focus();
}
</script>
</body>
</html>`;

function parseGleamOutput(exitCode: number, stdout: string, stderr: string) {
const result = stdout.trim();
// Filter out gleam's own build output from stderr
const appErrors = stderr
Copy link
Owner

Choose a reason for hiding this comment

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

To merge this we should do something better than spawning a gleam subprocess per request. We should have an actor that consumes Arc as a library and maybe over websockets we send/receive messages to it. Reki would be good fit to implement that actor.

.split("\n")
.filter((l) => !/^\s*(Compil|Running|Download|$)/.test(l))
.join("\n")
.trim();

if (appErrors) return { ok: false, error: appErrors };
if (exitCode !== 0) return { ok: false, error: "Process exited with code " + exitCode };
return { ok: true, result: result || "undefined" };
}

const server = Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);

if (url.pathname === "/" && req.method === "GET") {
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}

if (url.pathname === "/eval" && req.method === "POST") {
try {
const { code = "" } = (await req.json()) as { code?: string };
if (!code.trim()) return Response.json({ ok: true, result: "undefined" });

const proc = Bun.spawn(["gleam", "run"], {
cwd: PROJECT_ROOT,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.write(code);
proc.stdin.end();

const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);

return Response.json(parseGleamOutput(exitCode, stdout, stderr));
} catch (err) {
return Response.json({ ok: false, error: String(err) }, { status: 500 });
}
}

return new Response("Not Found", { status: 404 });
},
});

console.log(`Arc REPL server running at http://localhost:${server.port}`);
15 changes: 14 additions & 1 deletion src/arc.gleam
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import arc/eval
import gleam/io
import gleam/string

@external(erlang, "arc_ffi", "read_stdin")
fn read_stdin() -> String

pub fn main() -> Nil {
io.println("arc: JavaScript engine for the BEAM")
let input = read_stdin()
case string.trim(input) {
"" -> io.println("arc: JavaScript engine for the BEAM")
source ->
case eval.eval(source) {
Ok(result) -> io.println(result)
Error(err) -> io.println_error(err)
}
}
}
66 changes: 66 additions & 0 deletions src/arc/eval.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/// Evaluate JavaScript source code through the full Arc pipeline.
///
/// Pipeline: parse → compile → vm.run_with_globals
import arc/compiler
import arc/parser
import arc/vm/builtins
import arc/vm/heap
import arc/vm/value
import arc/vm/vm
import gleam/int
import gleam/string

/// Evaluate JS source and return a human-readable result string.
pub fn eval(source: String) -> Result(String, String) {
case parser.parse(source, parser.Script) {
Error(err) -> Error("SyntaxError: " <> parser.parse_error_to_string(err))
Ok(program) ->
case compiler.compile(program) {
Error(compiler.Unsupported(desc)) ->
Error("CompileError: unsupported " <> desc)
Error(compiler.BreakOutsideLoop) ->
Error("CompileError: break outside loop")
Error(compiler.ContinueOutsideLoop) ->
Error("CompileError: continue outside loop")
Ok(template) -> {
let h = heap.new()
let #(h, b) = builtins.init(h)
let globals = builtins.globals(b)
case vm.run_with_globals(template, h, b, globals) {
Ok(vm.NormalCompletion(val, _)) -> Ok(inspect_value(val))
Ok(vm.ThrowCompletion(val, _)) ->
Error("Uncaught " <> inspect_value(val))
Error(vm_err) -> Error("InternalError: " <> string.inspect(vm_err))
}
}
}
}
}

/// Convert a JsValue to a human-readable string representation.
pub fn inspect_value(val: value.JsValue) -> String {
case val {
value.JsUndefined -> "undefined"
value.JsNull -> "null"
value.JsBool(True) -> "true"
value.JsBool(False) -> "false"
value.JsNumber(value.Finite(n)) -> format_number(n)
value.JsNumber(value.NaN) -> "NaN"
value.JsNumber(value.Infinity) -> "Infinity"
value.JsNumber(value.NegInfinity) -> "-Infinity"
value.JsString(s) -> "\"" <> s <> "\""
value.JsObject(_) -> "[object]"
value.JsSymbol(_) -> "Symbol()"
value.JsBigInt(value.BigInt(n)) -> int.to_string(n) <> "n"
value.JsUninitialized -> "<uninitialized>"
}
}

/// Format a float nicely: 3.0 → "3", 3.14 → "3.14"
fn format_number(n: Float) -> String {
let s = string.inspect(n)
case string.ends_with(s, ".0") {
True -> string.drop_end(s, 2)
False -> s
}
}
19 changes: 19 additions & 0 deletions src/arc/vm/builtins.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,22 @@ pub fn init(h: Heap) -> #(Heap, Builtins) {
),
)
}

/// Standard ECMAScript global bindings (NaN, Infinity, undefined, Object, etc.)
/// Maps global variable names to their JsValue representations.
pub fn globals(b: Builtins) -> dict.Dict(String, value.JsValue) {
dict.from_list([
#("NaN", value.JsNumber(value.NaN)),
#("Infinity", value.JsNumber(value.Infinity)),
#("undefined", value.JsUndefined),
#("Object", value.JsObject(b.object.constructor)),
#("Function", value.JsObject(b.function.constructor)),
#("Array", value.JsObject(b.array.constructor)),
#("Error", value.JsObject(b.error.constructor)),
#("TypeError", value.JsObject(b.type_error.constructor)),
#("ReferenceError", value.JsObject(b.reference_error.constructor)),
#("RangeError", value.JsObject(b.range_error.constructor)),
#("SyntaxError", value.JsObject(b.syntax_error.constructor)),
#("Math", value.JsObject(b.math)),
])
}
18 changes: 18 additions & 0 deletions src/arc_ffi.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-module(arc_ffi).
-export([read_stdin/0]).

%% Read all available data from stdin as a binary string.
%% Returns an empty binary if stdin is empty/EOF immediately.
read_stdin() ->
read_stdin_loop(<<>>).

read_stdin_loop(Acc) ->
case io:get_chars(standard_io, <<>>, 4096) of
eof -> Acc;
{error, _} -> Acc;
Data when is_list(Data) ->
Bin = unicode:characters_to_binary(Data),
read_stdin_loop(<<Acc/binary, Bin/binary>>);
Data when is_binary(Data) ->
read_stdin_loop(<<Acc/binary, Data/binary>>)
end.
Loading