# El JavaScript Backend (codegen-js) **Status:** scaffolded. Hello-world compiles and runs. ~50% language coverage. Core runtime (~30 builtins) implemented. CGI / DHARMA / LLM / Engram intentionally stubbed. **Authoritative files** | File | Role | |---|---| | `el-compiler/src/codegen-js.el` | El → JS code generator (mirrors `codegen.el`) | | `el-compiler/runtime/el_runtime.js` | Browser/Node runtime that compiled programs link against | | `el-compiler/src/compiler.el` | Adds `compile_js()` and `--target=js` CLI dispatch | | `spec/codegen-js.md` | This document | --- ## 1. Why a JS backend exists El compiles to C today. C is the right substrate for the agent runtime, the DHARMA daemon, and Engram. But three first-class consumers of El need to **run in a browser**, where C is not an option: 1. **`el-ui/runtime/`** — the activation-based frontend framework written in JS. The long-term plan is to author components and the runtime itself in El and compile them down to JS. 2. **`cgi-studio`** — the web app for cultivating CGIs. Today it is hand-written JS. Once the JS backend is mature, the studio's UI logic can be authored in El and share types/identifier names with the CGI it cultivates. 3. **Marketplace plugin UIs** — third parties writing browser-side El that runs untrusted in a sandbox. They need a JS target. A secondary motivation: **El-on-Node**. CLI tooling, build scripts, and tests benefit from a tight `el → js → node` cycle without a `cc` step. --- ## 2. Type representation strategy The C backend pretends every value is `int64_t`. That is a deliberate runtime trick to avoid dynamic dispatch in generated C. JavaScript already has tagged dynamic values, so the JS backend is **simpler**: every El value is a native JS value, and the tag of `el_val_t` collapses into the JS type system. | El type | C representation | JS representation | |---|---|---| | `Int` | `int64_t` (direct) | `number` (with `Number.isSafeInteger` caveat — see §6) | | `Float` | `int64_t` bit-cast of `double` via `el_from_float` | `number` (no bit-cast — JS number IS a double) | | `Bool` | `int64_t`, 0 = false, nonzero = true | `boolean` | | `String` | `(int64_t)(uintptr_t)cstring` | `string` | | `Void` | C `void` | `undefined` | | `[T]` (List) | `el_val_t` pointer to refcounted struct | `Array` | | `Map` | `el_val_t` pointer to refcounted struct | plain object `{[key]: any}` | | `EL_NULL` (`0`) | `(el_val_t)0` | `null` | | Any | `el_val_t` | `any` (no compile-time check) | **Key consequences:** - `+` on two strings is JS `+` (string concat) — no `el_str_concat()` runtime call needed for the common case. The runtime DOES export `el_str_concat` for the cases where codegen does not know the types. - `==` on strings is `===` — not `str_eq()`. Same disambiguation logic as the C backend (look at left/right kind, fall back to `str_eq` for identifiers without int annotation). - `Map` access `m["foo"]` compiles to JS `m["foo"]` (no `el_get_field`). For `Field` access (`m.foo`) we emit `m["foo"]` so it works on plain objects regardless of prototype shape. - List access `arr[i]` is JS `arr[i]`. No bounds checking — same as C (which segfaults on bad index). Could add `el_list_get` wrapper later for safe access. - `EL_NULL` becomes JS `null`, not `undefined`. The runtime checks for `=== null` consistently. This avoids the JS undefined/null fork and matches El's single null value. --- ## 3. Builtin runtime layer (`el_runtime.js`) Same function names as `el_runtime.c` wherever possible, so codegen-js can emit the same call sites. The runtime is a single ES module that exposes every builtin as a named export AND attaches them to a `globalThis.__el` namespace (so generated code can do either `import * as el from './el_runtime.js'` or assume globals). **The codegen-js generated output uses the global-namespace style:** every emitted file starts with `import './el_runtime.js'` (which side-effects the globals) so call sites stay flat — `println(x)` not `el.println(x)`. This matches the C backend's flat call surface and keeps the generated code grep-compatible across targets. ### Implemented today (~30 builtins) | Category | Functions | |---|---| | I/O | `println`, `print` | | String | `el_str_concat`, `str_concat`, `str_eq`, `str_starts_with`, `str_ends_with`, `str_len`, `int_to_str`, `str_to_int`, `str_slice`, `str_contains`, `str_replace`, `str_to_upper`, `str_to_lower`, `str_trim`, `str_index_of`, `str_split`, `str_char_at`, `str_char_code`, `str_lower`, `str_upper` | | Math | `el_abs`, `el_max`, `el_min` | | List | `el_list_new`, `el_list_len`, `el_list_get`, `el_list_append`, `el_list_empty`, `el_list_clone`, `list_push`, `list_join`, `list_range` | | Map | `el_map_new`, `el_get_field`, `el_map_get`, `el_map_set` | | HTTP | `http_get`, `http_post`, `http_post_json` (via `fetch()`, returns `Promise` — see §5 async caveat) | | FS | `fs_read`, `fs_write`, `fs_list` (Node-only, throw in browser) | | JSON | `json_parse`, `json_stringify`, `json_get`, `json_get_string`, `json_get_int` | | Time | `time_now`, `time_now_utc`, `sleep_secs` (Node), `sleep_ms` | | Bool | `bool_to_str` | | Process | `exit_program` (Node `process.exit`, throw in browser) | | Refcount | `el_retain`, `el_release` (no-ops — JS has GC) | | ARC method-call shortforms | `append`, `len`, `get`, `map_get`, `map_set` | | Native VM aliases | `native_list_get`, `native_list_len`, `native_list_append`, `native_list_empty`, `native_list_clone`, `native_string_chars`, `native_int_to_str` | | `args` | `args()` returns `process.argv.slice(2)` in Node, `[]` in browser | | `state_*` | In-memory `Map` keyed by string | | `env` | `process.env[k]` in Node, throws in browser | ### Stubbed (throw at runtime) Every function in this list compiles successfully but throws `Error("not supported in JS target — needs server-side delegation: ")` when called. This is a **runtime** error, not a compile error, so it doesn't block compilation of code that has dead-code paths through these functions. - All `dharma_*` (membership in DHARMA network requires the daemon) - All `engram_*` (needs the embedded SQLite + activation engine — could be reimplemented in JS later) - All `llm_*` (CORS + API key handling — must go through a server-side proxy) - `http_serve` (browsers don't host servers; Node could, but that's a separate runtime mode) - `el_cgi_init` (CGI identity is a server-side concept) - Crypto: `sha256_*`, `hmac_sha256_*`, `base64*` (deferred — can use `crypto.subtle` later) ### Browser-side specific behavior When running in a browser: - `println` / `print` map to `console.log` (no stdout in browsers) - `http_get` / `http_post` use `fetch()` (CORS applies) - `fs_*` throws (browsers have no fs) - `args()` returns `[]` - `env(k)` throws (or could read from a global config object — TBD) When running in Node: - `println` / `print` map to `console.log` and `process.stdout.write` - `fs_*` use `node:fs/promises` (sync versions for the simple cases) - `args()` returns `process.argv.slice(2)` - `env(k)` returns `process.env[k] ?? null` The runtime auto-detects via `typeof window === 'undefined'`. --- ## 4. Tradeoffs vs the C backend | Concern | C backend | JS backend | |---|---|---| | **Static types** | El's `Int` becomes `int64_t`, real arithmetic | El's `Int` becomes `number` — loses precision past 2^53 | | **Linking model** | Static link against `el_runtime.c` + libcurl + libpthread | ES module import of `el_runtime.js` | | **Dynamic dispatch** | `dlsym` for `http_set_handler` / `llm_register_tool` (requires `-rdynamic`) | JS function value lookup via `globalThis[name]` — no compiler flag | | **Tool registry** | dlsym walks symbol table; tool fns must be top-level C symbols | Tool fns live as exports of the generated module; trivially callable | | **Memory model** | Refcounted lists/maps with `el_retain`/`el_release` to avoid leaks | JS GC handles all of it; `el_retain`/`el_release` are no-ops | | **`+` overload** | Has to dispatch in codegen between `el_str_concat` and integer `+` because at C level both are `int64_t` | JS `+` is already overloaded: `"a" + "b"` → `"ab"`, `1 + 2` → `3`. Codegen still preserves the existing dispatch for safety, but the runtime fallback is correct | | **Concurrency** | `pthread`-backed `http_serve` | Single-threaded event loop; `http_serve` not supported in this target | | **HTTP client** | libcurl, blocking, returns body string | `fetch()` is async — see §5 | | **CGI identity** | `el_cgi_init` runs at start of `main()` | Not supported; UI code is not a CGI principal | | **DHARMA / LLM** | Native, blocking, libcurl-backed | Not supported — all such calls throw and the program is expected to delegate to a server-side El daemon via plain HTTP | | **Compile speed** | El → C → cc → binary (cc is the slow step) | El → JS → done. Faster iteration | | **Output size** | Static binary ~2MB | Source `.js` + ~10kb runtime | --- ## 5. The async problem (the big deferred decision) `fetch()` is async. The C backend's `http_get(url)` is synchronous and returns the body string directly. El source was written assuming sync. Three options: 1. **Pretend it's sync from El's POV; use synchronous XHR (browser) or `child_process.execSync('curl …')` (Node).** Bad: synchronous XHR is deprecated and frozen on the main thread; `execSync` is a hack. 2. **Make every `http_*` builtin in the JS runtime return a `Promise`, and rewrite codegen-js to insert `await` everywhere.** This requires turning every El function that transitively calls a network builtin into an `async fn` in JS. Doable, but invasive — the El AST does not currently mark async-ness. 3. **Compile El's call sites with implicit await; compile-time taint tracking marks every fn that transitively calls a network builtin as `async`. Generated JS uses `async function` and `await`.** This is the right answer long-term. **Decision for this scaffold:** option 3, but only the runtime side is implemented. `http_get` in `el_runtime.js` returns a `Promise`. `codegen-js.el` does NOT yet emit `async`/`await`. Calling `http_get` from compiled El will return a Promise that the El program will treat as a string (which produces `"[object Promise]"`). This is documented and accepted for the scaffold; the compile-time taint pass is a follow-up. For now, programs that don't touch HTTP work correctly. That covers `el-ui/runtime` (which only manipulates the DOM and a graph), most of cgi-studio's pure UI components, and all hello-world style programs. --- ## 6. Number precision JS `number` is IEEE 754 double — only 53 bits of integer precision. El `Int` is `int64_t` and the runtime sometimes uses the full 64 bits (e.g. `time_now_utc` returns nanoseconds-since-epoch, which exceeds 2^53 in practice). **Decision for this scaffold:** accept the precision loss. Document it. UI code does not use 64-bit timestamps. If/when a use case demands it, `time_now_utc` can return a `BigInt` and we can introduce a `BigInt` sub-mode. That's a follow-up. --- ## 7. What's NOT supported in JS target initially This is the canonical list. Programs that use any of these compile (no `#error`-style fail-fast like the C backend's capability check) but throw at runtime or behave as documented. | Feature | Status | Notes | |---|---|---| | `cgi {}` block | Compiled to a no-op + warning comment | CGI identity is server-side. UI code is not a CGI. | | `service {}` block | Compiled to a no-op + warning comment | Same. | | All `dharma_*` | Stub throws | Programs needing DHARMA must call a server-side daemon over HTTP | | All `engram_*` | Stub throws | Could be ported to in-browser (IndexedDB-backed) later | | All `llm_*` | Stub throws | Browser cannot hold API keys; route through server | | `llm_register_tool` | Stub throws | Same | | `http_serve` | Stub throws | Browsers cannot serve. Node-mode could, deferred | | `http_set_handler` | Stub throws | Same | | `match` expressions | Compiled (basic) | LitInt/LitStr/LitBool/Wildcard/Binding all work via `if/else` chain. Tagged-union match deferred | | `type` (struct) defs | Skipped at codegen | Treated as documentation; structs are plain JS objects. `t["field"]` works | | `enum` defs | Skipped at codegen | Same — enum values are bare strings or ints | | `?` postfix (nil-prop) | No-op | Same as C backend's current state | | `try` postfix | Stripped to inner | Same as C backend | | Capability enforcement | Not enforced | The C backend uses `#error` directives; the JS backend lets the runtime stubs throw. Future: emit `throw new Error('capability violation')` at compile time | | VBD role check | Not enforced | Same | | Float bit-cast | Not needed | JS number is already a double | | Crypto primitives | Stub throws | Easy to add via `crypto.subtle` later | | `state_*` | In-memory only | No persistence; resets on page reload | | `args()` | Node-only | Browser returns `[]` | | `fs_*` | Node-only | Browser throws | --- ## 8. CLI dispatch — `--target=js` The compiler entry point `compiler.el` adds a `compile_js(source: String) -> String` alongside the existing `compile()`. The CLI behavior: ``` elc # default — emit C elc --target=c # explicit — emit C elc --target=js # emit JS elc --target=js source.el # write JS to stdout (no out path) ``` The argv parser scans for a `--target=` token; remaining positional args are `` and optional ``. The dispatch logic stays in El: a `compile_dispatch(target, source) -> String` switch. --- ## 9. The path to compiling el-ui/runtime through this backend This is the real-world test. `el-ui/runtime/src/` is currently 5 hand-written `.js` files. The path to authoring them in El: 1. **Phase 1 — Hello-world** (this scaffold). Done. 2. **Phase 2 — language coverage.** Get codegen-js to ~95% parity with codegen.el for non-network features. Specifically: `match`, struct/enum field access, `?`-propagation, full `for`-over-list, complete unary/binary operators, lexical closures (the C backend doesn't have these but we'll need them for el-ui's component model). 3. **Phase 3 — DOM bridge.** Add `dom_*` builtins to el_runtime.js: `dom_create_element`, `dom_set_text`, `dom_append_child`, `dom_query`, `dom_listen`, etc. These are Node-as-El builtins for the browser; the C backend will add a stub set that errors. Source-shareable El UI code becomes possible. 4. **Phase 4 — Component class lowering.** El doesn't have classes; el-ui's `Component` is a JS class. Decide: extend El with a `component` keyword that compiles to JS class + C struct? Or have el-ui authors define components as `fn render_(state) -> String` and provide a small bootstrap. The latter is the lower-impact path. 5. **Phase 5 — Async taint pass.** Implement compile-time async tracking so `http_get` and friends produce `await fetch()` correctly. Required before authoring code that fetches data. 6. **Phase 6 — Port `el-ui/runtime/`.** Translate the 5 JS files to El, compile to JS, swap in. Run el-ui's existing tests. Iterate. 7. **Phase 7 — Port cgi-studio UI.** Larger surface area; same pattern. 8. **Phase 8 — Marketplace plugins.** Open the door for third-party UI El. The blocking item between phase 1 and phase 2 is incremental — every El construct used by el-ui's source needs codegen-js coverage. Phase 5 (async) is the architectural decision that needs explicit user buy-in, because it changes the language's effective semantics on the JS target. --- ## 10. Test ```bash echo 'fn main() -> Void { println("hello from el-js") }' > /tmp/hello.el elc --target=js /tmp/hello.el > /tmp/hello.js node /tmp/hello.js # → hello from el-js ``` This should pass after the bootstrap rebuild. See §11. --- ## 11. Bootstrap status Adding `--target=js` to `compile()` requires regenerating the shipped `elc` binary at `dist/platform/elc`. The rebuild path is: 1. Existing `elc` binary compiles updated `elc-combined.el` (which now includes `codegen-js.el` and the `--target=js` dispatch) → `elc.c`. 2. `cc` compiles `elc.c` → new `elc` binary. 3. New `elc` binary supports `--target=js`. The scaffold checks all four scaffold files in. The bootstrap rebuild happens as a follow-up step, gated on review of this design doc.