diff --git a/build.zig b/build.zig
index ff762b0e84..3519786ec7 100644
--- a/build.zig
+++ b/build.zig
@@ -590,7 +590,7 @@ pub fn build(b: *std.Build) !void {
if (is_wasm) {
nvim_mod.addCSourceFiles(.{ .files = &.{
- "src/wasm_stubs.c",
+ "src/wasm/wasm_stubs.c",
"src/static_ts_registry.c",
}, .flags = &flags });
}
@@ -637,14 +637,12 @@ pub fn build(b: *std.Build) !void {
emcc.addArgs(&.{
b.fmt("--sysroot={s}", .{s}),
"-lidbfs.js",
- "-sALLOW_MEMORY_GROWTH=1",
"--profiling-funcs",
"-sEXPORTED_FUNCTIONS=_nvim_main,_malloc,_free",
"-Wno-undefined",
- "-sEXPORTED_RUNTIME_METHODS=stringToUTF8,lengthBytesUTF8,setValue,getValue,UTF8ToString,ENV,FS,HEAPU8,IDBFS",
+ "-sEXPORTED_RUNTIME_METHODS=stringToUTF8,lengthBytesUTF8,setValue,getValue,UTF8ToString,ENV,FS,HEAPU8,IDBFS,ccall,cwrap",
"-sSTACK_SIZE=8388608",
"-sINITIAL_MEMORY=268435456",
- "-sMAXIMUM_MEMORY=2147483648",
"-sFORCE_FILESYSTEM=1",
"-sNODERAWFS=0",
"-sERROR_ON_UNDEFINED_SYMBOLS=1",
@@ -657,8 +655,8 @@ pub fn build(b: *std.Build) !void {
"-flto",
"-sSTACK_OVERFLOW_CHECK=2",
"-sASSERTIONS=1",
- "-sASYNCIFY=1",
- "-sASYNCIFY_STACK_SIZE=65536",
+ "-pthread",
+ "-sSHARED_MEMORY=1",
b.fmt("--preload-file={s}@/runtime", .{merged_path}),
});
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
index 0fd7f18e27..86b3b6846a 100644
--- a/runtime/doc/news.txt
+++ b/runtime/doc/news.txt
@@ -220,6 +220,10 @@ BUILD
(empty string) to assume that target binaries can run on the host during the
build process (e.g. if target is x86 on a x86_64 system, or if emulation set
up with binfmt or similar).
+• zig build: Nvim can now be built to produce a WebAssembly binary via
+ "-Dtarget=wasm32-emscripten", which can run inside a web browser. A basic
+ browser-based demo for running it is also included. Still very
+ experimental.
DEFAULTS
diff --git a/src/nvim/CMakeLists.txt b/src/nvim/CMakeLists.txt
index 96872ea846..046e1b6593 100644
--- a/src/nvim/CMakeLists.txt
+++ b/src/nvim/CMakeLists.txt
@@ -445,7 +445,7 @@ list(APPEND UNCRUSTIFY_NVIM_SOURCES ${NVIM_SOURCES} ${NVIM_HEADERS})
list(APPEND UNCRUSTIFY_NVIM_SOURCES
${PROJECT_SOURCE_DIR}/src/tee/tee.c
${PROJECT_SOURCE_DIR}/src/xxd/xxd.c
- ${PROJECT_SOURCE_DIR}/src/wasm_stubs.c
+ ${PROJECT_SOURCE_DIR}/src/wasm/wasm_stubs.c
${PROJECT_SOURCE_DIR}/src/static_ts_registry.c
)
diff --git a/src/nvim/event/wstream.c b/src/nvim/event/wstream.c
index 6af286ae8f..f471ed3d86 100644
--- a/src/nvim/event/wstream.c
+++ b/src/nvim/event/wstream.c
@@ -80,8 +80,13 @@ int wstream_write(Stream *stream, WBuffer *buffer)
uv_fs_t req;
// Synchronous write
- err = uv_fs_write(stream->uv.idle.loop, &req, stream->fd, &uvbuf, 1, stream->fpos, NULL);
+#ifdef __EMSCRIPTEN__
+ // Pass -1 so the write uses the current file position.
+ err = uv_fs_write(stream->uv.idle.loop, &req, stream->fd, &uvbuf, 1, -1, NULL);
+#else
+ err = uv_fs_write(stream->uv.idle.loop, &req, stream->fd, &uvbuf, 1, stream->fpos, NULL);
+#endif
uv_fs_req_cleanup(&req);
wstream_release_wbuffer(buffer);
diff --git a/src/wasm/app.js b/src/wasm/app.js
new file mode 100644
index 0000000000..57d16286b5
--- /dev/null
+++ b/src/wasm/app.js
@@ -0,0 +1,393 @@
+const DEBUG = false;
+const statusEl = document.getElementById("status");
+const setStatus = (s) => (statusEl.textContent = s);
+const log = (...args) => {
+ if (!DEBUG) return;
+ if (logEl) {
+ logEl.textContent +=
+ args
+ .map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
+ .join(" ") + "\n";
+ }
+ console.log(...args);
+};
+
+// UiState below is ported from (github.com/MuNeNiCK/nvim-wasm)
+// but, extended with win_viewport tracking.
+class UiState {
+ constructor(cols, rows) {
+ this.defaultWidth = Math.max(1, cols || 80);
+ this.defaultHeight = Math.max(1, rows || 24);
+ this.defaultGrid = 1;
+ this.activeGrid = this.defaultGrid;
+ this.grids = new Map();
+ this.grids.set(
+ this.defaultGrid,
+ this.#createGrid(this.defaultWidth, this.defaultHeight),
+ );
+ this.cursor = { grid: this.defaultGrid, row: 0, col: 0 };
+ this.mode = "-";
+ this.modeIdx = 0;
+ this.cursorStyleEnabled = false;
+ this.modeInfo = [];
+ this.cursorHlId = 0;
+ this.hls = new Map();
+ this.hls.set(0, { foreground: null, background: null, reverse: false });
+ this.windows = new Map();
+ }
+
+ resize(gridId, width, height) {
+ const grid = this.#ensureGrid(gridId);
+ const w = Math.max(1, width || 0);
+ const h = Math.max(1, height || 0);
+ grid.width = w;
+ grid.height = h;
+ grid.cells = Array.from({ length: h }, () => this.#blankRow(w));
+ }
+
+ clear(gridId) {
+ const grid = this.#ensureGrid(gridId);
+ grid.cells = Array.from({ length: grid.height }, () =>
+ this.#blankRow(grid.width),
+ );
+ }
+
+ destroy(gridId) {
+ this.grids.delete(gridId);
+ this.windows.delete(gridId);
+ if (this.activeGrid === gridId) this.activeGrid = this.defaultGrid;
+ }
+
+ line(gridId, row, colStart, cells) {
+ const grid = this.#ensureGrid(gridId);
+ const rowIdx = row || 0;
+ if (rowIdx < 0 || rowIdx >= grid.height) return;
+ const rowCells =
+ grid.cells[rowIdx] || (grid.cells[rowIdx] = this.#blankRow(grid.width));
+ let col = colStart || 0;
+ let currentHl = 0;
+ for (const cell of cells) {
+ const text = cell[0];
+ const hlId =
+ cell.length > 1 && cell[1] !== undefined ? cell[1] : currentHl;
+ currentHl = hlId;
+ const repeat = cell[2] || 1;
+ for (let r = 0; r < repeat && col < grid.width; r += 1) {
+ rowCells[col] = { ch: text || " ", hl: hlId };
+ col += 1;
+ }
+ }
+ }
+
+ scroll(gridId, top, bot, left, right, rows) {
+ const grid = this.#ensureGrid(gridId);
+ const height = bot - top;
+ const width = right - left;
+ const slice = [];
+ for (let i = 0; i < height; i += 1) {
+ const row = grid.cells[top + i] || this.#blankRow(grid.width);
+ slice.push(row.slice(left, right));
+ }
+ if (rows > 0) {
+ for (let i = 0; i < height - rows; i += 1) {
+ grid.cells[top + i].splice(left, width, ...slice[i + rows]);
+ }
+ for (let i = height - rows; i < height; i += 1) {
+ grid.cells[top + i].splice(left, width, ...this.#blankRow(width));
+ }
+ } else if (rows < 0) {
+ for (let i = height - 1; i >= -rows; i -= 1) {
+ grid.cells[top + i].splice(left, width, ...slice[i + rows]);
+ }
+ for (let i = 0; i < -rows; i += 1) {
+ grid.cells[top + i].splice(left, width, ...this.#blankRow(width));
+ }
+ }
+ }
+
+ setCursor(gridId, row, col) {
+ this.activeGrid = gridId;
+ this.#ensureGrid(gridId);
+ this.cursor = { grid: gridId, row: row || 0, col: col || 0 };
+ }
+
+ setMode(mode, modeIdx) {
+ this.mode = mode || "-";
+ if (Number.isInteger(modeIdx)) this.modeIdx = modeIdx;
+ this.#updateCursorHl();
+ }
+
+ setModeInfo(cursorStyleEnabled, modeInfo) {
+ this.cursorStyleEnabled = Boolean(cursorStyleEnabled);
+ this.modeInfo = Array.isArray(modeInfo) ? modeInfo : [];
+ this.#updateCursorHl();
+ }
+
+ defineHl(id, rgbAttr = {}) {
+ this.hls.set(id, {
+ foreground: this.#toHex(rgbAttr.foreground),
+ background: this.#toHex(rgbAttr.background),
+ reverse: Boolean(rgbAttr.reverse),
+ });
+ }
+
+ setWindowViewport(gridId, winHandle, topline, botline) {
+ this.windows.set(gridId, { winHandle, topline, botline });
+ }
+
+ snapshot() {
+ const grid =
+ this.grids.get(this.activeGrid) || this.grids.get(this.defaultGrid);
+ if (!grid)
+ return {
+ cells: [[{ ch: " ", hl: 0 }]],
+ cursor: { row: 0, col: 0 },
+ mode: this.mode,
+ hls: this.hls,
+ };
+ const row = Math.min(Math.max(this.cursor.row, 0), grid.height - 1);
+ const col = Math.min(Math.max(this.cursor.col, 0), grid.width - 1);
+ return {
+ cells: grid.cells.map((r) =>
+ r.map((c) => ({ ch: c?.ch ?? " ", hl: c?.hl ?? 0 })),
+ ),
+ cursor: { row, col },
+ cursorHlId: this.cursorHlId,
+ mode: this.mode,
+ hls: this.hls,
+ };
+ }
+
+ #ensureGrid(gridId) {
+ if (!this.grids.has(gridId))
+ this.grids.set(
+ gridId,
+ this.#createGrid(this.defaultWidth, this.defaultHeight),
+ );
+ return this.grids.get(gridId);
+ }
+ #createGrid(width, height) {
+ return {
+ width,
+ height,
+ cells: Array.from({ length: height }, () => this.#blankRow(width)),
+ };
+ }
+ #blankRow(width) {
+ return Array.from({ length: width }, () => ({ ch: " ", hl: 0 }));
+ }
+ #updateCursorHl() {
+ if (!this.cursorStyleEnabled) {
+ this.cursorHlId = 0;
+ return;
+ }
+ const info = this.modeInfo[this.modeIdx] || null;
+ this.cursorHlId = info?.attr_id ?? 0;
+ }
+ #toHex(value) {
+ if (value === undefined || value === null) return null;
+ return `#${(value >>> 0).toString(16).padStart(6, "0").slice(-6)}`;
+ }
+}
+
+function handleRedrawEvents(uiState, gridEl, modeEl, events) {
+ for (const ev of events) {
+ const [name, ...entries] = ev;
+ switch (name) {
+ case "grid_resize":
+ for (const [grid, w, h] of entries) uiState.resize(grid, w, h);
+ break;
+ case "grid_clear":
+ for (const [grid] of entries) uiState.clear(grid);
+ break;
+ case "grid_destroy":
+ for (const [grid] of entries) uiState.destroy(grid);
+ break;
+ case "grid_line":
+ for (const [grid, row, col, cells] of entries)
+ uiState.line(grid, row, col, cells);
+ break;
+ case "grid_scroll":
+ for (const [grid, top, bot, left, right, rows] of entries)
+ uiState.scroll(grid, top, bot, left, right, rows);
+ break;
+ case "grid_cursor_goto":
+ for (const [grid, row, col] of entries)
+ uiState.setCursor(grid, row, col);
+ break;
+ case "win_viewport":
+ for (const [grid, win, topline, botline] of entries)
+ uiState.setWindowViewport(grid, win, topline, botline);
+ break;
+ case "mode_info_set":
+ for (const [enabled, modeInfo] of entries)
+ uiState.setModeInfo(enabled, modeInfo);
+ break;
+ case "mode_change":
+ for (const [mode, idx] of entries) uiState.setMode(mode, idx);
+ break;
+ case "hl_attr_define":
+ for (const [id, rgbAttr] of entries) uiState.defineHl(id, rgbAttr);
+ break;
+ case "flush":
+ paintGrid(uiState, gridEl, modeEl);
+ break;
+ default:
+ break; // mouse on/off, busy start/stop, etc.
+ }
+ }
+}
+
+function paintGrid(uiState, gridEl, modeEl) {
+ const { cells, cursor, cursorHlId, mode, hls } = uiState.snapshot();
+ let html = "";
+ for (let r = 0; r < cells.length; r += 1) {
+ for (let c = 0; c < cells[r].length; c += 1) {
+ const cell = cells[r][c];
+ const isCursor = r === cursor.row && c === cursor.col;
+ const hl = hls.get(isCursor ? cursorHlId : cell.hl) || {};
+ let style = "";
+ if (hl.reverse) {
+ style = `color:${hl.background || "#050912"};background:${hl.foreground || "#dfe5f1"}`;
+ } else {
+ if (hl.foreground) style += `color:${hl.foreground};`;
+ if (hl.background) style += `background:${hl.background};`;
+ }
+ const ch = escapeHtml(cell.ch || " ");
+ if (isCursor) {
+ html += `${ch}`;
+ } else if (style) {
+ html += `${ch}`;
+ } else {
+ html += ch;
+ }
+ }
+ if (r < cells.length - 1) html += "\n";
+ }
+ gridEl.innerHTML = html || " ";
+ if (modeEl) modeEl.textContent = `mode: ${mode}`;
+}
+
+function escapeHtml(ch) {
+ if (ch === "&") return "&";
+ if (ch === "<") return "<";
+ if (ch === ">") return ">";
+ return ch;
+}
+
+function translateKey(ev) {
+ const isCtrl = ev.ctrlKey || ev.metaKey;
+ const isAlt = ev.altKey;
+ const named = {
+ Backspace: "",
+ Enter: "",
+ Escape: "",
+ Tab: "",
+ ArrowUp: "",
+ ArrowDown: "",
+ ArrowLeft: "",
+ ArrowRight: "",
+ Delete: "",
+ Home: "",
+ End: "",
+ PageUp: "",
+ PageDown: "",
+ };
+ if (named[ev.key]) return named[ev.key];
+ if (ev.key.length === 1) {
+ let char = ev.shiftKey ? ev.key : ev.key.toLowerCase();
+ if (char === "<") char = "";
+ if (!isCtrl && !isAlt) return char;
+ let mod = "";
+ if (isCtrl) mod += "C-";
+ if (isAlt) mod += "A-";
+ return `<${mod}${char}>`;
+ }
+ return null;
+}
+
+function main() {
+ const transport = new WorkerTransport("nvim-worker.js", {
+ cols: 120,
+ rows: 40,
+ });
+ // internal FS.write/stderr trace line the worker emits.
+ transport.onStatus((text) => {
+ log("[status]", text);
+ if (/^(FS\.write|WRITE HOOK|\[stderr\])/.test(text)) return;
+ setStatus(text);
+ });
+
+ const nvim = new RpcClient(transport);
+ window.nvim = nvim; // for console debugging
+
+ const uiState = new UiState(120, 40);
+ const gridEl = document.getElementById("grid");
+ const modeEl = document.getElementById("mode");
+
+ let apiInfoPromise = null;
+ function ensureHandleTypesRegistered() {
+ if (!apiInfoPromise) {
+ apiInfoPromise = (async () => {
+ const result = await nvim.request("nvim_get_api_info", []);
+ log("RESPONSE nvim_get_api_info ->", result);
+
+ const [channelId, metadata] = result;
+ log("types metadata ->", metadata.types);
+
+ registerNvimHandleTypes(metadata.types);
+ setStatus("got nvim_get_api_info response, handle types registered");
+
+ return result;
+ })();
+ }
+ return apiInfoPromise;
+ }
+
+ // Auto-attach + visible failure status
+ transport.onReady(async () => {
+ const maxAttempts = 5;
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ try {
+ await ensureHandleTypesRegistered();
+ await nvim.request("nvim_ui_attach", [
+ 110,
+ 40,
+ { rgb: true, ext_linegrid: true },
+ ]);
+ setStatus("UI attached");
+ return;
+ } catch (e) {
+ console.error(`auto-attach attempt ${attempt} failed`, e);
+ log("auto-attach failed ->", e && e.message ? e.message : String(e));
+ if (attempt === maxAttempts) {
+ setStatus("auto-attach failed after retries, see console (F12)");
+ } else {
+ await new Promise((r) => setTimeout(r, 300 * attempt));
+ }
+ }
+ }
+ });
+
+ nvim.on("redraw", (params) =>
+ handleRedrawEvents(uiState, gridEl, modeEl, params),
+ );
+
+ gridEl.addEventListener("click", () => gridEl.focus());
+ gridEl.addEventListener("keydown", (ev) => {
+ const keys = translateKey(ev);
+ if (!keys) return;
+ ev.preventDefault();
+ nvim
+ .request("nvim_input", [keys])
+ .catch((e) => console.error("nvim_input failed", e));
+ });
+}
+
+if (!crossOriginIsolated) {
+ setStatus(
+ "NOT cross-origin isolated -- SharedArrayBuffer unavailable. Serve with COOP/COEP headers.",
+ );
+} else {
+ main();
+}
diff --git a/src/wasm/index.html b/src/wasm/index.html
new file mode 100644
index 0000000000..db24ceb17e
--- /dev/null
+++ b/src/wasm/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+ nvim.wasm RPC client
+
+
+
+
+
+
+
+
+
+ booting…
+
+
+
+
+
+
+
+
+
diff --git a/src/wasm/msgpack.js b/src/wasm/msgpack.js
new file mode 100644
index 0000000000..a275a1a612
--- /dev/null
+++ b/src/wasm/msgpack.js
@@ -0,0 +1,62 @@
+if (typeof msgpackr === 'undefined') {
+ throw new Error(
+ 'msgpackr library not loaded. ' +
+ 'Check that the script tag for msgpackr is present and loads correctly.'
+ );
+}
+
+// Buffer/Window/Tabpage handles come back as msgpack EXT values.
+// - Type code: not fixed across builds, so read it from
+// nvim_get_api_info()'s types metadata at runtime instead of
+// hardcoding it.
+// - Payload: itself a variable length msgpack integer,
+// so it needs a real unpack call, not a fixed width DataView read.
+function registerNvimHandleTypes(types) {
+ for (const [name, info] of Object.entries(types)) {
+ msgpackr.addExtension({
+ type: info.id,
+ unpack(payload) {
+ return { __nvimType: name, id: msgpackr.unpack(payload) };
+ },
+ pack(value) {
+ return msgpackr.pack(value.id);
+ },
+ });
+ console.log(`[msgpack] registered Nvim EXT type ${info.id} -> ${name}`);
+ }
+}
+
+const MsgpackCodec = {
+ encode(value) {
+ const encoded = msgpackr.pack(value);
+ const prefix = Array.from(encoded.slice(0, Math.min(encoded.length, 8)))
+ .map(b => b.toString(16).padStart(2, '0'))
+ .join(' ');
+ console.log(`[MsgpackCodec.encode] ${encoded.length} bytes, prefix: ${prefix}`);
+ return encoded;
+ },
+
+ decodeMultiple(bytes) {
+ const messages = [];
+ let consumed = 0;
+
+ try {
+ msgpackr.unpackMultiple(bytes, (value, start, end) => {
+ messages.push(value);
+ consumed = end;
+ });
+ consumed = bytes.length;
+ } catch (e) {
+ if (!isIncompleteDataError(e)) {
+ console.error('[MsgpackCodec.decodeMultiple] fatal decode error', e, 'consumed so far:', consumed);
+ throw e;
+ }
+ }
+
+ return { messages, consumed };
+ },
+};
+
+function isIncompleteDataError(e) {
+ return /unexpected end|incomplete|out of bounds|out of range/i.test(e?.message || '');
+}
diff --git a/src/wasm/msgpackrpc.js b/src/wasm/msgpackrpc.js
new file mode 100644
index 0000000000..11d467d9cb
--- /dev/null
+++ b/src/wasm/msgpackrpc.js
@@ -0,0 +1,34 @@
+// msgpack-rpc message shapes:
+// request: [0, msgid, method, params]
+// response: [1, msgid, error, result]
+// notification: [2, method, params]
+
+const Protocol = {
+ encodeRequest(msgid, method, params) {
+ return MsgpackCodec.encode([0, msgid, method, params]);
+ },
+
+ encodeNotification(method, params) {
+ return MsgpackCodec.encode([2, method, params]);
+ },
+
+ // classifies a decoded msgpack-rpc array into a tagged object.
+ // throws if msg doesn't look like a valid rpc message.
+ parseMessage(msg) {
+ if (!Array.isArray(msg)) throw new Error("rpc message is not an array");
+ const [type, ...rest] = msg;
+ if (type === 0) {
+ const [msgid, method, params] = rest;
+ return { kind: "request", msgid, method, params };
+ }
+ if (type === 1) {
+ const [msgid, error, result] = rest;
+ return { kind: "response", msgid, error, result };
+ }
+ if (type === 2) {
+ const [method, params] = rest;
+ return { kind: "notification", method, params };
+ }
+ throw new Error("unknown rpc message type: " + type);
+ },
+};
diff --git a/src/wasm/nvim-worker.js b/src/wasm/nvim-worker.js
new file mode 100644
index 0000000000..f277bf3576
--- /dev/null
+++ b/src/wasm/nvim-worker.js
@@ -0,0 +1,597 @@
+const CAP = 1 << 16;
+
+let state = null;
+let ringData = null;
+
+let moduleRef = null;
+
+let totalBytesRead = 0;
+const consumedBytes = [];
+const STATUS_MSG_CAP = 2000;
+let statusMsgCount = 0;
+let statusCapNoticeSent = false;
+
+function safeStatus(text) {
+ statusMsgCount++;
+ if (statusMsgCount > STATUS_MSG_CAP) {
+ if (!statusCapNoticeSent) {
+ statusCapNoticeSent = true;
+ postMessage({
+ type: "status",
+ text:
+ "STATUS LOGGING SUPPRESSED after " +
+ STATUS_MSG_CAP +
+ " messages -- likely a busy/runaway loop. " +
+ "See perFdWriteCount below for which fd is spinning.",
+ });
+ }
+ return;
+ }
+ postMessage({ type: "status", text: text });
+}
+
+const perFdWriteCount = {};
+function bumpFdWriteCount(fd) {
+ perFdWriteCount[fd] = (perFdWriteCount[fd] || 0) + 1;
+}
+
+/* File descriptor monitored by libuv for stdin. Verify this value
+ against the startup FD dump, as it may differ across environments. */
+
+const STDIN_FD = 9;
+
+/*
+ SharedArrayBuffer layout:
+ offset 0:
+ Int32Array:
+ [0] head
+ [1] tail
+ [2] closed
+
+ offset 12:
+ Uint8Array data
+*/
+
+function unreadCount() {
+ const head = Atomics.load(state, 0);
+ const tail = Atomics.load(state, 1);
+ return (head - tail + CAP) % CAP;
+}
+
+// Synchronizes the JS ring buffer with the shared atomics used by
+// uv__io_poll to allow libuv to detect newly available input.
+function markReadable() {
+ safeStatus(
+ "MARK READABLE called moduleRef=" + !!moduleRef + " STDIN_FD=" + STDIN_FD,
+ );
+ if (!moduleRef) return;
+ const n = unreadCount();
+ safeStatus("MARK READABLE n=" + n);
+ try {
+ moduleRef.ccall(
+ "uv_browser_set_readable",
+ null,
+ ["number", "number"],
+ [STDIN_FD, n],
+ );
+ safeStatus("MARK READABLE ccall succeeded");
+ } catch (e) {
+ safeStatus("MARK READABLE FAILED: " + e);
+ }
+}
+
+// Debug helper
+function dumpFD(fd) {
+ const s = moduleRef.FS.streams[fd];
+
+ safeStatus(" FD " + fd);
+
+ if (!s) {
+ safeStatus("NO STREAM");
+ return;
+ }
+
+ safeStatus("path=" + s.path + " fd=" + s.fd + " tty=" + !!s.tty);
+
+ safeStatus(
+ "same stdout ops=" + (s.stream_ops === moduleRef.FS.streams[1].stream_ops),
+ );
+
+ try {
+ const st = moduleRef.FS.fstat(fd);
+
+ safeStatus(
+ "mode=" +
+ st.mode.toString(8) +
+ " isChrdev=" +
+ moduleRef.FS.isChrdev(st.mode),
+ );
+ } catch (e) {
+ safeStatus("fstat failed: " + e);
+ }
+}
+
+function popByte() {
+ const head = Atomics.load(state, 0);
+ const tail = Atomics.load(state, 1);
+
+ if (head === tail) return -1;
+
+ const b = ringData[tail];
+
+ Atomics.store(state, 1, (tail + 1) % CAP);
+
+ totalBytesRead++;
+ consumedBytes.push(b);
+
+ console.log("POP BYTE", b.toString(16));
+
+ return b;
+}
+
+// Called when new bytes are pushed into the ring buffer from the main
+// thread. Advances head and notifies both the JS side Atomics.wait() consumers and the C-side
+// futex consumers (uv__io_poll).
+function pushBytes(bytes) {
+ if (!bytes || !bytes.length) return;
+
+ let head = Atomics.load(state, 0);
+
+ for (let i = 0; i < bytes.length; i++) {
+ const tail = Atomics.load(state, 1);
+ const nextHead = (head + 1) % CAP;
+
+ /* If the ring buffer is full, drop new data instead of overwriting
+ unread bytes. Overwriting would corrupt the input stream. If this
+ happens often, increase CAP or add backpressure on the producer. */
+ if (nextHead === tail) {
+ console.warn("stdin ring buffer full, dropping byte");
+ break;
+ }
+
+ ringData[head] = bytes[i];
+ head = nextHead;
+ }
+
+ Atomics.store(state, 0, head);
+
+ // wake anything doing an Atomics.wait() on head, if anything still does
+ Atomics.notify(state, 0);
+
+ // tell the C side (uv__io_poll) that fd 9 now has data
+ markReadable();
+}
+
+function flushAll() {
+ if (stdoutBuffer.length) {
+ postMessage({
+ type: "stdout",
+ bytes: stdoutBuffer,
+ });
+ stdoutBuffer = [];
+ }
+
+ if (stderrBuffer.length) {
+ postMessage({
+ type: "stderr",
+ bytes: stderrBuffer,
+ });
+ stderrBuffer = [];
+ }
+}
+
+let stdoutBuffer = [];
+let stderrBuffer = [];
+
+function writeStderr(c) {
+ stderrBuffer.push(c);
+
+ if (stderrBuffer.length > 50) {
+ postMessage({
+ type: "stderr",
+ bytes: stderrBuffer,
+ });
+
+ stderrBuffer = [];
+ }
+}
+
+function makeArgv(M, args) {
+ const ptrs = args.map((s) => {
+ const len = M.lengthBytesUTF8(s) + 1;
+
+ const p = M._malloc(len);
+
+ M.stringToUTF8(s, p, len);
+
+ return p;
+ });
+
+ const argv = M._malloc((ptrs.length + 1) * 4);
+
+ ptrs.forEach((p, i) => M.setValue(argv + i * 4, p, "*"));
+
+ M.setValue(argv + ptrs.length * 4, 0, "*");
+
+ return {
+ argc: ptrs.length,
+ argv,
+ };
+}
+
+self.onerror = (e) => {
+ safeStatus("WORKER ERROR " + e.message);
+};
+
+self.onunhandledrejection = (e) => {
+ const r = e.reason;
+ const info =
+ r && typeof r === "object"
+ ? `name=${r.name} message=${r.message} status=${r.status}`
+ : String(r);
+ safeStatus("REJECTION " + info);
+};
+
+/* Hook the shared TTY get_char callback instead of stream_ops.read.
+ All duplicated file descriptors share the same TTY object, making
+ this work for stdin and any dup()ed descriptors.
+ Return one byte when available, undefined when no data is ready
+ (causing Emscripten to report EAGAIN), or null on EOF. Blocking is
+ handled by uv__io_poll so this callback always remains non-blocking.
+ */
+function installStdoutWrite(m, stdoutStream) {
+ if (
+ !stdoutStream.stream_ops ||
+ typeof stdoutStream.stream_ops.write !== "function"
+ ) {
+ throw new Error(
+ "stdout stream has no stream_ops.write: " + stdoutStream.path,
+ );
+ }
+ // The worker owns the transport, so forwarding the chunk via postMessage
+ // completes the write.
+ // Skip the original stream_ops.write to avoid duplicate output.
+ stdoutStream.stream_ops.write = function (
+ stream,
+ buffer,
+ offset,
+ length,
+ position,
+ ) {
+ safeStatus(
+ "WRITE HOOK fd=" + stream.fd + " path=" + stream.path + " len=" + length,
+ );
+
+ try {
+ console.log("HOOK: entered");
+ /* Forward the entire chunk as a single message to preserve msgpack-RPC
+ framing. Then copy it into a Uint8Array to ensure the transmitted bytes
+ remain correct regardless of the source buffer's signedness. */
+ const chunk = new Uint8Array(length);
+ for (let i = 0; i < length; i++) {
+ chunk[i] = buffer[offset + i];
+ }
+ console.log(
+ "HOOK: chunk built",
+ chunk.length,
+ chunk[0],
+ chunk[1],
+ chunk[2],
+ chunk[3],
+ );
+ console.log(
+ "stdout chunk",
+ length,
+ chunk[0],
+ chunk[1],
+ chunk[2],
+ chunk[3],
+ );
+ console.log("HOOK: BEFORE postMessage");
+
+ postMessage({
+ type: "stdout",
+ bytes: chunk,
+ });
+ console.log("HOOK: AFTER postMessage");
+ } catch (e) {
+ console.error("HOOK: postMessage failed", e);
+
+ safeStatus("STDOUT HOOK ERROR: " + e + " stack=" + e.stack);
+ }
+ return length;
+ };
+}
+
+function installStdinGetChar(m, stdinStream) {
+ if (!stdinStream.tty || !stdinStream.tty.ops) {
+ throw new Error(
+ "stdin stream has no .tty.ops -- stream is not TTY-backed: " +
+ stdinStream.path,
+ );
+ }
+
+ stdinStream.tty.ops.get_char = function (tty) {
+ const b = popByte();
+
+ if (b !== -1) {
+ markReadable(); // reflect the new unread count
+ return b;
+ }
+
+ if (Atomics.load(state, 2) === 1) {
+ console.log("stdin closed");
+ return null; // real EOF
+ }
+
+ return undefined; // no data yet -> Emscripten turns this into EAGAIN
+ };
+}
+
+self.onmessage = async (ev) => {
+ const msg = ev.data;
+
+ if (msg.type === "init") {
+ state = new Int32Array(msg.sab, 0, 3); // [head, tail, closed]
+ ringData = new Uint8Array(msg.sab, 12, CAP); // offset moved from 8 → 12
+ safeStatus("loading wasm...");
+ importScripts("../../zig-out/bin/nvim.js");
+
+ const m = await createNvim({
+ locateFile: (p) =>
+ p.endsWith(".data")
+ ? "../../zig-out/bin/nvim.data"
+ : "../../zig-out/bin/" + p,
+ noInitialRun: true,
+ interactive: false,
+
+ stdout: () => {},
+
+ stderr: (c) => writeStderr(c),
+ tty: false,
+ print: (t) => safeStatus("[print] " + t),
+ printErr: (t) => safeStatus("[printErr] " + t),
+ preRun: [
+ (m) => {
+ m.ENV.TERM = "xterm-256color";
+ m.ENV.HOME = "/home/user";
+ m.ENV.VIMRUNTIME = "/runtime";
+ m.ENV.NVIM_LOG_FILE = "/home/user/nvim.log";
+ },
+ ],
+ });
+
+ moduleRef = m;
+
+ const statePtr = m.ccall(
+ "uv_browser_get_shared_state_ptr",
+ "number",
+ [],
+ [],
+ );
+ postMessage({
+ type: "shared-info",
+ memory: m.HEAPU8.buffer,
+ statePtr: statePtr,
+ });
+ postMessage({ type: "ready" });
+ const stdinStream = m.FS.streams[0];
+
+ console.log("stdin stream", stdinStream.path);
+
+ installStdinGetChar(m, stdinStream);
+ const stdoutStream = m.FS.streams[1];
+
+ console.log("stdoutStream ", stdoutStream);
+ console.log(stdoutStream.node);
+ console.log(stdoutStream.node.rdev);
+ console.log(stdoutStream.tty);
+ console.log(stdoutStream.stream_ops);
+
+ console.log("stdout stream", stdoutStream.path);
+ installStdoutWrite(m, stdoutStream);
+ console.log("PATCHED STDOUT WRITE =", m.FS.streams[1].stream_ops.write);
+ await new Promise((res, rej) =>
+ m.FS.syncfs(true, (e) => (e ? rej(e) : res())),
+ );
+ try {
+ m.FS.mkdir("/home/user/.config/nvim");
+ } catch (e) {}
+ try {
+ m.FS.mkdir("/home/user/.local/share/nvim");
+ } catch (e) {}
+ try {
+ m.FS.mkdir("/runtime/parser");
+ } catch (e) {}
+ [
+ "lua",
+ "c",
+ "vim",
+ "vimdoc",
+ "query",
+ "markdown",
+ "markdown_inline",
+ ].forEach((p) => {
+ try {
+ m.FS.writeFile(`/runtime/parser/${p}.so`, "");
+ } catch (e) {}
+ });
+
+ const patchedOps = stdinStream.tty && stdinStream.tty.ops;
+ // Dump every open fd and confirm which fd libuv is actually polling
+ // on and whether it shares the patched tty record. If STDIN_FD
+ // above doesn't match what shows up here as the dup'd stdin fd then
+ // fix the constant at the top of this file.
+ for (let i = 0; i < 16; i++) {
+ const s = m.FS.streams[i];
+ console.log("FD", i, s && s.path, s && s.tty && s.tty.ops === patchedOps);
+ }
+
+ function sanitizeAttr(attr) {
+ attr.dev = attr.dev ?? 1;
+ attr.ino = attr.ino ?? 1;
+ attr.mode = attr.mode ?? 0o666;
+ attr.nlink = attr.nlink ?? 1;
+ attr.uid = attr.uid ?? 0;
+ attr.gid = attr.gid ?? 0;
+ attr.rdev = attr.rdev ?? 0;
+ attr.size =
+ typeof attr.size === "number" && !isNaN(attr.size) ? attr.size : 0;
+ attr.blksize = attr.blksize ?? 4096;
+ attr.blocks = attr.blocks ?? 0;
+ const validDate = (d) => d instanceof Date && !isNaN(d.getTime());
+ attr.atime = validDate(attr.atime) ? attr.atime : new Date(0);
+ attr.mtime = validDate(attr.mtime) ? attr.mtime : new Date(0);
+ attr.ctime = validDate(attr.ctime) ? attr.ctime : new Date(0);
+ return attr;
+ }
+
+ function isStdinLike(path) {
+ const p = path || "";
+ return (
+ p.startsWith("pipe[") || p.includes("my_stdin") || p === "/dev/stdin"
+ );
+ }
+
+ ["fstat", "stat", "lstat"].forEach((name) => {
+ if (typeof m.FS[name] !== "function") return;
+ const orig = m.FS[name].bind(m.FS);
+ m.FS[name] = function (...args) {
+ const attr = orig(...args);
+ const path =
+ name === "fstat"
+ ? m.FS.streams[args[0]] && m.FS.streams[args[0]].path
+ : args[0];
+ if (isStdinLike(path)) {
+ attr.mode = 0o010666;
+ }
+ return sanitizeAttr(attr);
+ };
+ });
+
+ const origFSWrite = m.FS.write;
+ m.FS.write = function (stream, buffer, offset, length, position, canOwn) {
+ console.log("FS.write-----");
+ console.log("fd =", stream.fd);
+ console.log("path =", stream.path);
+
+ console.log("stream_ops =", stream.stream_ops);
+
+ console.log("write fn =", stream.stream_ops && stream.stream_ops.write);
+
+ console.log("stdout write fn =", m.FS.streams[1].stream_ops.write);
+
+ console.log(
+ "same write?",
+ stream.stream_ops &&
+ stream.stream_ops.write === m.FS.streams[1].stream_ops.write,
+ );
+ bumpFdWriteCount(stream.fd);
+ safeStatus(
+ "FS.write fd=" +
+ stream.fd +
+ " path=" +
+ stream.path +
+ " len=" +
+ length +
+ " (fd" +
+ stream.fd +
+ " call#" +
+ perFdWriteCount[stream.fd] +
+ ")",
+ );
+ return origFSWrite.call(
+ this,
+ stream,
+ buffer,
+ offset,
+ length,
+ position,
+ canOwn,
+ );
+ };
+
+ const { argc, argv } = makeArgv(m, ["nvim", "--embed", "--clean"]);
+
+ let ret;
+
+ try {
+ safeStatus("Starting Neovim...");
+
+ console.log("CALLING NVIM MAIN");
+
+ const nvimPromise = m._nvim_main(argc, argv);
+
+ console.log("returned:", nvimPromise);
+ console.log("instanceof Promise:", nvimPromise instanceof Promise);
+ console.log("constructor:", nvimPromise?.constructor?.name);
+
+ setTimeout(() => {
+ dumpFD(1);
+ dumpFD(9);
+ dumpFD(10);
+ }, 1000);
+
+ ret = await nvimPromise;
+
+ console.log("NVIM EXITED");
+ const unread = unreadCount();
+ safeStatus(
+ `_nvim_main RETURNED ret=${ret}, unread bytes still in buffer=${unread}`,
+ );
+ } catch (e) {
+ flushAll();
+ safeStatus("EXCEPTION: " + e.message + "\nSTACK:\n" + e.stack);
+ throw e;
+ }
+
+ flushAll();
+
+ const hexStr = consumedBytes
+ .slice(0, 50)
+ .map((x) => x.toString(16).padStart(2, "0"))
+ .join(" ");
+ const remaining =
+ consumedBytes.length > 50
+ ? `... (${consumedBytes.length - 50} more bytes)`
+ : "";
+ safeStatus(
+ `EXIT CODE ${ret}. Read ${totalBytesRead} bytes: ${hexStr}${remaining}`,
+ );
+ }
+
+ if (msg.type === "stdin") {
+ pushBytes(msg.bytes);
+ }
+
+ if (msg.type === "persist") {
+ if (!moduleRef) {
+ postMessage({ type: "persisted", error: "module not ready" });
+ return;
+ }
+ moduleRef.FS.syncfs(false, (e) =>
+ postMessage({ type: "persisted", error: e ? String(e) : null }),
+ );
+ }
+ if (msg.type === "readable-hint") {
+ markReadable();
+ }
+
+ if (msg.type === "dump-counters") {
+ safeStatus(
+ "perFdWriteCount=" +
+ JSON.stringify(perFdWriteCount) +
+ " statusMsgCount=" +
+ statusMsgCount +
+ " suppressed=" +
+ statusCapNoticeSent,
+ );
+ }
+ if (msg.type === "shutdown") {
+ if (state) {
+ Atomics.store(state, 2, 1);
+ Atomics.notify(state, 0); // wake up anything Atomics.wait()ing
+ markReadable(); // also wake the C side futex so uv__io_poll rescans and sees the close
+ }
+ }
+};
diff --git a/src/wasm/rpc.js b/src/wasm/rpc.js
new file mode 100644
index 0000000000..ecaf47942c
--- /dev/null
+++ b/src/wasm/rpc.js
@@ -0,0 +1,245 @@
+// TODO: Add a fallback when SharedArrayBuffer is unavailable.
+// Transport: owns the worker and SharedArrayBuffer
+class WorkerTransport {
+ constructor(workerPath, { cols = 80, rows = 24, cap = 1 << 16 } = {}) {
+ this.CAP = cap;
+ this.sab = new SharedArrayBuffer(12 + this.CAP);
+ this.state = new Int32Array(this.sab, 0, 3); // [head, tail, closed]
+ this.ringData = new Uint8Array(this.sab, 12, this.CAP); // offset 12
+
+ this._bytesHandlers = [];
+ this._statusHandlers = [];
+ this._readyHandlers = [];
+
+ this.worker = new Worker(workerPath);
+ this.worker.onmessage = (ev) => {
+ console.log("[main] worker message", ev.data.type, ev.data);
+ this._onWorkerMessage(ev.data);
+ };
+ this.worker.onerror = (e) => this._emitStatus("worker error: " + e.message);
+ this.worker.postMessage({ type: "init", sab: this.sab, cols, rows });
+ }
+
+ shutdown() {
+ Atomics.store(this.state, 2, 1);
+ Atomics.notify(this.state, 0);
+ this.worker.postMessage({ type: "shutdown" });
+ }
+
+ _onWorkerMessage(msg) {
+ console.log("[main] _onWorkerMessage got", msg.type);
+
+ if (msg.type === "stdout") {
+ console.log(`[main] stdout bytes:`, msg.bytes);
+ this._bytesHandlers.forEach((h) => h(msg.bytes));
+ } else if (msg.type === "stderr") {
+ const text = new TextDecoder().decode(new Uint8Array(msg.bytes));
+ this._emitStatus("[stderr] " + text);
+ } else if (msg.type === "status") {
+ this._emitStatus(msg.text);
+ } else if (msg.type === "shared-info") {
+ this.sharedState = new Int32Array(
+ msg.memory,
+ msg.statePtr,
+ 1 + 256 + 256,
+ );
+ console.log("[main] shared-info received, direct futex wake enabled");
+ } else if (msg.type === "ready") {
+ console.log("[main] worker signaled ready, RPC channel should be live");
+ this._readyHandlers.forEach((h) => h());
+ }
+ }
+
+ _emitStatus(text) {
+ console.log("[main] status:", text);
+ this._statusHandlers.forEach((h) => h(text));
+ }
+
+ send(bytes) {
+ console.log("[main] Send: writing", bytes.length, "bytes");
+ const hexStr = Array.from(bytes)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join(" ");
+ console.log(`[main] Send: ${bytes.length} bytes: ${hexStr}`);
+
+ let lastHead = 0;
+ for (let i = 0; i < bytes.length; i++) {
+ const b = bytes[i];
+ const head = Atomics.load(this.state, 0);
+ const tail = Atomics.load(this.state, 1);
+ const next = (head + 1) % this.CAP;
+ if (next === tail) {
+ console.error("[main] Send: ring buffer full");
+ return false;
+ }
+ this.ringData[head] = b;
+ Atomics.store(this.state, 0, next);
+ lastHead = next;
+ }
+
+ if (this.sharedState) {
+ // Write straight into the C-side shared struct, then wake the futex.
+ // This only works if the worker is already inside
+ // emscripten_futex_wait() at the moment call notify() if it is
+ // not waiting yet, the notify does nothing. Atomics.notify() does not
+ // queue up a wake for some future wait() call as it only wakes threads
+ // that are waiting right now.
+ const STDIN_FD = 9;
+ const tail = Atomics.load(this.state, 1);
+ const n = (lastHead - tail + this.CAP) % this.CAP;
+ Atomics.store(this.sharedState, 1 + STDIN_FD, n); // readable[STDIN_FD] = n
+ Atomics.add(this.sharedState, 0, 1); // generation++
+ Atomics.notify(this.sharedState, 0, 1); // wake the futex_wait, if any
+ console.log("[main] Send Done, direct notified futex, n=", n);
+ }
+ // There's a real startup race here: if this send() happens before the
+ // worker's event loop has reached its first Atomics.wait() (e.g.
+ // auto-attach fires the instant ready arrives), the direct notify
+ // above lands on nobody and is silently wasted. And if the C side poll
+ // loop only reacts to notify events instead of rechecking the shared
+ // value on every scan that missed wake is gone for good and the
+ // request just sits in the ring buffer forever.
+ //
+ // But until the worker actually starts blocking, it is still free to
+ // process its own message queue so this postMessage gets handled
+ // right away, routing through the worker's own markReadable() ->
+ // uv_browser_set_readable ccall path. That makes send() correct
+ // whether or not nvim has reached its poll loop yet instead of
+ // depending on lucky notify timing.
+ this.worker.postMessage({ type: "readable-hint" });
+ console.log(
+ "[main] SEND DONE, also sent readable-hint as belt-and-suspenders",
+ );
+
+ return true;
+ }
+
+ onBytes(cb) {
+ this._bytesHandlers.push(cb);
+ }
+ onStatus(cb) {
+ this._statusHandlers.push(cb);
+ }
+ onReady(cb) {
+ this._readyHandlers.push(cb);
+ }
+ persist() {
+ this.worker.postMessage({ type: "persist" });
+ }
+}
+
+class RpcClient {
+ constructor(transport) {
+ this.transport = transport;
+ this.nextMsgId = 1;
+ this.pending = new Map();
+ this.notificationHandlers = new Map();
+
+ this._buffer = new Uint8Array(0);
+
+ transport.onBytes((bytes) => this._handleBytes(bytes));
+ }
+
+ _handleBytes(newBytes) {
+ console.log(
+ "[RpcClient] _handleBytes",
+ newBytes.length,
+ newBytes.slice(0, 32),
+ );
+
+ const combined = new Uint8Array(this._buffer.length + newBytes.length);
+ combined.set(this._buffer, 0);
+ combined.set(newBytes, this._buffer.length);
+ this._buffer = combined;
+
+ const { messages, consumed } = MsgpackCodec.decodeMultiple(this._buffer);
+
+ for (const msg of messages) {
+ console.log("[RpcClient] raw decoded:", JSON.stringify(msg));
+ this._dispatch(msg);
+ }
+
+ this._buffer = this._buffer.slice(consumed);
+ }
+
+ _dispatch(rawMsg) {
+ console.log("[RpcClient] dispatching:", rawMsg);
+
+ let msg;
+ try {
+ msg = Protocol.parseMessage(rawMsg);
+ } catch (e) {
+ console.error("[RpcClient] parse failed", rawMsg, e);
+ return;
+ }
+
+ console.log("[RpcClient] parsed:", msg);
+
+ if (msg.kind === "response") {
+ const p = this.pending.get(msg.msgid);
+ if (!p) {
+ console.warn("[RpcClient] unknown msgid", msg.msgid, "pending:", [
+ ...this.pending.keys(),
+ ]);
+ return;
+ }
+ this.pending.delete(msg.msgid);
+ clearTimeout(p.timeoutId);
+ console.log("[RpcClient] resolved msgid", msg.msgid);
+ if (msg.error) {
+ p.reject(new Error(msg.error));
+ } else {
+ p.resolve(msg.result);
+ }
+ } else if (msg.kind === "notification") {
+ console.log("[RpcClient] notification:", msg.method);
+ const handlers = this.notificationHandlers.get(msg.method) || [];
+ handlers.forEach((h) => h(msg.params));
+ } else if (msg.kind === "request") {
+ console.warn("[RpcClient] unhandled request", msg);
+ }
+ }
+
+ request(method, params = []) {
+ const msgid = this.nextMsgId++;
+ const bytes = Protocol.encodeRequest(msgid, method, params);
+ console.log(
+ `[RpcClient] sending request ${msgid} (${method}), ${bytes.length} bytes`,
+ );
+
+ return new Promise((resolve, reject) => {
+ this.pending.set(msgid, {
+ resolve,
+ reject,
+ method,
+ timestamp: Date.now(),
+ });
+
+ const timeoutId = setTimeout(() => {
+ if (this.pending.has(msgid)) {
+ this.pending.delete(msgid);
+ reject(new Error(`Request ${msgid} (${method}) timed out after 30s`));
+ }
+ }, 30000);
+
+ this.pending.get(msgid).timeoutId = timeoutId;
+
+ this.transport.send(bytes);
+ });
+ }
+
+ notify(method, params = []) {
+ const bytes = Protocol.encodeNotification(method, params);
+ console.log(
+ `[RpcClient] sending notification ${method}, ${bytes.length} bytes`,
+ );
+ this.transport.send(bytes);
+ }
+
+ on(method, handler) {
+ if (!this.notificationHandlers.has(method)) {
+ this.notificationHandlers.set(method, []);
+ }
+ this.notificationHandlers.get(method).push(handler);
+ }
+}
diff --git a/src/wasm/serve.py b/src/wasm/serve.py
new file mode 100644
index 0000000000..bff568c96b
--- /dev/null
+++ b/src/wasm/serve.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+
+"""
+Local server for the WASM demo.
+Usage: python3 src/wasm/serve.py
+"""
+
+import http.server
+import socketserver
+from pathlib import Path
+from functools import partial
+
+PORT = 8002
+
+ROOT = Path(__file__).resolve().parents[2]
+
+class Handler(http.server.SimpleHTTPRequestHandler):
+ def end_headers(self):
+ self.send_header("Cross-Origin-Opener-Policy", "same-origin")
+ self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
+ super().end_headers()
+
+if __name__ == "__main__":
+ handler = partial(Handler, directory=str(ROOT))
+
+ with socketserver.TCPServer(("", PORT), handler) as httpd:
+ print(f"Serving {ROOT} on http://localhost:{PORT}")
+ httpd.serve_forever()
diff --git a/src/wasm/style.css b/src/wasm/style.css
new file mode 100644
index 0000000000..640b80682a
--- /dev/null
+++ b/src/wasm/style.css
@@ -0,0 +1,16 @@
+body {
+ font-family: monospace;
+ background: #1e1e1e;
+ color: #ddd;
+ padding: 10px;
+ margin: 0;
+}
+#bar {
+ margin-bottom: 10px;
+}
+#bar button {
+ margin-right: 8px;
+}
+#status {
+ color: #9c9;
+}
diff --git a/src/wasm/wasm_stubs.c b/src/wasm/wasm_stubs.c
new file mode 100644
index 0000000000..a83bf6d66a
--- /dev/null
+++ b/src/wasm/wasm_stubs.c
@@ -0,0 +1,314 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+void uv__io_cb(uv_loop_t *loop, uv__io_t *w, unsigned events);
+
+// System info stubs
+
+uint64_t uv_get_free_memory(void)
+{
+ return 8 * 1024 * 1024;
+}
+uint64_t uv_get_total_memory(void)
+{
+ return 256 * 1024 * 1024;
+}
+uint64_t uv_get_available_memory(void)
+{
+ return uv_get_free_memory();
+}
+uint64_t uv_get_constrained_memory(void)
+{
+ return uv_get_total_memory();
+}
+
+void uv_loadavg(double avg[3])
+{
+ avg[0] = 0.0; avg[1] = 0.0; avg[2] = 0.0;
+}
+
+int uv_uptime(double *uptime)
+{
+ if (!uptime) {
+ return UV_EINVAL;
+ }
+ *uptime = emscripten_get_now() / 1000.0;
+ return 0;
+}
+
+int uv_resident_set_memory(size_t *rss)
+{
+ if (!rss) {
+ return UV_EINVAL;
+ }
+ *rss = uv_get_total_memory() / 2; // fake value
+ return 0;
+}
+
+int uv_exepath(char *buffer, size_t *size)
+{
+ if (!buffer || !size) {
+ return UV_EINVAL;
+ }
+ const char *exepath = "/nvim.wasm";
+ size_t len = strlen(exepath);
+ if (*size <= len) {
+ *size = len + 1;
+ return UV_ENOBUFS;
+ }
+ memcpy(buffer, exepath, len);
+ buffer[len] = '\0';
+ *size = len;
+ return 0;
+}
+
+// Browsers have no native CPU information. So return a single virtual CPU
+int uv_cpu_info(uv_cpu_info_t * *cpu_infos, int *count)
+{
+ if (!cpu_infos || !count) {
+ return UV_EINVAL;
+ }
+ *cpu_infos = (uv_cpu_info_t *)malloc(sizeof(uv_cpu_info_t));
+ if (!*cpu_infos) {
+ return UV_ENOMEM;
+ }
+
+ uv_cpu_info_t *cpu = *cpu_infos;
+ cpu->model = "WebAssembly Virtual CPU";
+ cpu->speed = 0;
+ cpu->cpu_times.user = 0; cpu->cpu_times.nice = 0; cpu->cpu_times.sys = 0;
+ cpu->cpu_times.idle = 0; cpu->cpu_times.irq = 0;
+ *count = 1;
+ return 0;
+}
+
+// Browsers do not expose host network interfaces. Report only loopback.
+int uv_interface_addresses(uv_interface_address_t * *addresses, int *count)
+{
+ if (!addresses || !count) {
+ return UV_EINVAL;
+ }
+ *addresses = (uv_interface_address_t *)malloc(sizeof(uv_interface_address_t));
+ if (!*addresses) {
+ return UV_ENOMEM;
+ }
+
+ uv_interface_address_t *addr = *addresses;
+ memset(addr, 0, sizeof(uv_interface_address_t));
+ addr->name = strdup("lo");
+ if (!addr->name) {
+ free(addr); return UV_ENOMEM;
+ }
+ addr->is_internal = 1;
+ uv_ip4_addr("127.0.0.1", 0, (struct sockaddr_in *)&addr->address);
+ uv_ip4_addr("255.0.0.0", 0, (struct sockaddr_in *)&addr->netmask);
+ *count = 1;
+ return 0;
+}
+
+#define UV_BROWSER_MAX_FD 256
+
+typedef struct {
+ _Atomic uint32_t generation; // woken on any fd state change
+ _Atomic int32_t readable[UV_BROWSER_MAX_FD];
+ _Atomic int32_t writable[UV_BROWSER_MAX_FD];
+} uv_browser_shared_state_t;
+
+static uv_browser_shared_state_t g_shared_state;
+
+EMSCRIPTEN_KEEPALIVE
+void *uv_browser_get_shared_state_ptr(void)
+{
+ return (void *)&g_shared_state;
+}
+
+EMSCRIPTEN_KEEPALIVE
+void uv_browser_set_readable(int fd, int32_t nbytes)
+{
+ if (fd < 0 || fd >= UV_BROWSER_MAX_FD) {
+ return;
+ }
+ atomic_store(&g_shared_state.readable[fd], nbytes);
+ atomic_fetch_add(&g_shared_state.generation, 1);
+ emscripten_futex_wake(&g_shared_state.generation, 1);
+}
+
+EMSCRIPTEN_KEEPALIVE
+void uv_browser_set_writable(int fd, int32_t nbytes)
+{
+ if (fd < 0 || fd >= UV_BROWSER_MAX_FD) {
+ return;
+ }
+ atomic_store(&g_shared_state.writable[fd], nbytes);
+ atomic_fetch_add(&g_shared_state.generation, 1);
+ emscripten_futex_wake(&g_shared_state.generation, 1);
+}
+
+static int uv_browser_scan_ready(uv_loop_t *loop, unsigned *out_idx, int *out_revents)
+{
+ unsigned i;
+ uv__io_t *w;
+ int revents;
+
+ for (i = 0; i < loop->nwatchers && i < UV_BROWSER_MAX_FD; i++) {
+ w = loop->watchers[i];
+ if (w == NULL || w->pevents == 0) {
+ continue;
+ }
+
+ revents = 0;
+ if ((w->pevents & POLLIN) && atomic_load(&g_shared_state.readable[i]) > 0) {
+ revents |= POLLIN;
+ }
+ if ((w->pevents & POLLOUT) && atomic_load(&g_shared_state.writable[i]) > 0) {
+ revents |= POLLOUT;
+ }
+
+ if (revents) {
+ *out_idx = i;
+ *out_revents = revents;
+ return 1;
+ }
+ }
+ return 0;
+}
+
+void uv__io_poll(uv_loop_t *loop, int timeout)
+{
+ double deadline;
+ double remaining;
+ uint32_t g0;
+ unsigned idx;
+ int revents;
+ int have_signal;
+ uv__io_t *w;
+
+ deadline = (timeout < 0) ? -1.0 : (emscripten_get_now() + (double)timeout);
+ have_signal = 0;
+
+ while (true) {
+ /* Any state change that lands between this load and the scan below is still caught by the scan
+ itself. Any change that lands after the scan will bump generation past g0, so the futex_wait call
+ falls through immediately instead of sleeping through it. This closes the narrow window where a notify
+ could otherwise land between "nothing is ready" and "waiting". */
+
+ g0 = atomic_load(&g_shared_state.generation);
+
+ if (uv_browser_scan_ready(loop, &idx, &revents)) {
+ have_signal = 1;
+ break;
+ }
+
+ if (deadline >= 0.0) {
+ remaining = deadline - emscripten_get_now();
+ if (remaining <= 0.0) {
+ break;
+ }
+ } else {
+ remaining = 60000.0;
+ }
+
+ int rc = emscripten_futex_wait(&g_shared_state.generation, g0, remaining);
+ }
+
+ uv_update_time(loop);
+
+ if (!have_signal) {
+ return;
+ }
+
+ for (idx = 0; idx < loop->nwatchers && idx < UV_BROWSER_MAX_FD; idx++) {
+ w = loop->watchers[idx];
+ if (w == NULL || w->pevents == 0) {
+ continue;
+ }
+
+ revents = 0;
+ if ((w->pevents & POLLIN) && atomic_load(&g_shared_state.readable[idx]) > 0) {
+ revents |= POLLIN;
+ }
+ if ((w->pevents & POLLOUT) && atomic_load(&g_shared_state.writable[idx]) > 0) {
+ revents |= POLLOUT;
+ }
+
+ revents &= w->pevents | POLLERR | POLLHUP;
+
+ if (revents == 0) {
+ continue;
+ }
+
+ uv__io_cb(loop, w, revents);
+ }
+}
+
+int uv__platform_loop_init(uv_loop_t *loop)
+{
+ if (!loop) {
+ return UV_EINVAL;
+ }
+ loop->backend_fd = -1;
+ return 0;
+}
+
+void uv__platform_loop_delete(uv_loop_t *loop)
+{
+ (void)loop;
+}
+
+void uv__platform_invalidate_fd(uv_loop_t *loop, int fd)
+{
+ if (fd < 0 || fd >= UV_BROWSER_MAX_FD) {
+ return;
+ }
+ atomic_store(&g_shared_state.readable[fd], 0);
+ atomic_store(&g_shared_state.writable[fd], 0);
+}
+
+int uv__io_check_fd(uv_loop_t *loop, int fd)
+{
+ (void)loop; (void)fd;
+ return 0;
+}
+
+int uv__io_fork(uv_loop_t *loop)
+{
+ (void)loop;
+ return 0;
+}
+
+// pthread shims
+
+int pthread_getname_np(pthread_t thread, char *name, size_t len)
+{
+ (void)thread;
+ if (!name || len < 1) {
+ return EINVAL;
+ }
+ strncpy(name, "nvim-main", len - 1);
+ name[len - 1] = '\0';
+ return 0;
+}
+
+int pthread_setname_np(pthread_t thread, const char *name)
+{
+ (void)thread; (void)name;
+ return 0;
+}
+
+int pthread_setschedparam(pthread_t thread, int policy, const struct sched_param *param)
+{
+ (void)thread; (void)policy; (void)param;
+ return 0;
+}
diff --git a/src/wasm_stubs.c b/src/wasm_stubs.c
deleted file mode 100644
index 94c121be96..0000000000
--- a/src/wasm_stubs.c
+++ /dev/null
@@ -1,181 +0,0 @@
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-uint64_t uv_get_free_memory(void)
-{
- return 8 * 1024 * 1024;
-}
-uint64_t uv_get_total_memory(void)
-{
- return 256 * 1024 * 1024;
-}
-uint64_t uv_get_available_memory(void)
-{
- return uv_get_free_memory();
-}
-uint64_t uv_get_constrained_memory(void)
-{
- return uv_get_total_memory();
-}
-
-// Report an idle virtual system
-void uv_loadavg(double avg[3])
-{
- avg[0] = 0.0; avg[1] = 0.0; avg[2] = 0.0;
-}
-
-int uv_uptime(double *uptime)
-{
- if (!uptime) {
- return UV_EINVAL;
- }
- *uptime = emscripten_get_now() / 1000.0;
- return 0;
-}
-
-int uv_resident_set_memory(size_t *rss)
-{
- if (!rss) {
- return UV_EINVAL;
- }
- *rss = uv_get_total_memory() / 2;
- return 0;
-}
-
-int uv_exepath(char *buffer, size_t *size)
-{
- if (!buffer || !size) {
- return UV_EINVAL;
- }
- const char *exepath = "/nvim.wasm";
- size_t len = strlen(exepath);
- if (*size <= len) {
- *size = len + 1;
- return UV_ENOBUFS;
- }
- memcpy(buffer, exepath, len);
- buffer[len] = '\0';
- *size = len;
- return 0;
-}
-
-int uv__io_fork(uv_loop_t *loop)
-{
- (void)loop;
- return 0;
-}
-
-int uv_cpu_info(uv_cpu_info_t * *cpu_infos, int *count)
-{
- if (!cpu_infos || !count) {
- return UV_EINVAL;
- }
- *cpu_infos = (uv_cpu_info_t *)malloc(sizeof(uv_cpu_info_t));
- if (!*cpu_infos) {
- return UV_ENOMEM;
- }
-
- uv_cpu_info_t *cpu = *cpu_infos;
- cpu->model = "WebAssembly Virtual CPU";
- cpu->speed = 0;
- cpu->cpu_times.user = 0; cpu->cpu_times.nice = 0; cpu->cpu_times.sys = 0;
- cpu->cpu_times.idle = 0; cpu->cpu_times.irq = 0;
- *count = 1;
- return 0;
-}
-
-int uv_interface_addresses(uv_interface_address_t * *addresses, int *count)
-{
- if (!addresses || !count) {
- return UV_EINVAL;
- }
- *addresses = (uv_interface_address_t *)malloc(sizeof(uv_interface_address_t));
- if (!*addresses) {
- return UV_ENOMEM;
- }
-
- uv_interface_address_t *addr = *addresses;
- memset(addr, 0, sizeof(uv_interface_address_t));
- addr->name = strdup("lo");
- if (!addr->name) {
- free(addr); return UV_ENOMEM;
- }
- addr->is_internal = 1;
- uv_ip4_addr("127.0.0.1", 0, (struct sockaddr_in *)&addr->address);
- uv_ip4_addr("255.0.0.0", 0, (struct sockaddr_in *)&addr->netmask);
- *count = 1;
- return 0;
-}
-
-void uv__platform_invalidate_fd(uv_loop_t *loop, int fd)
-{
- (void)loop; (void)fd;
-}
-int uv__io_check_fd(uv_loop_t *loop, void *w)
-{
- (void)loop; (void)w; return 0;
-}
-
-// Replaces the kernel poll() call
-void uv__io_poll(uv_loop_t *loop, int timeout)
-{
- if (timeout > 0) {
- emscripten_sleep(timeout);
- uv_update_time(loop);
- emscripten_sleep(10);
- uv_update_time(loop);
- } else if (timeout == 0) {
- emscripten_sleep(0);
- }
-}
-
-int uv__platform_loop_init(uv_loop_t *loop)
-{
- if (!loop) {
- return UV_EINVAL;
- }
- loop->backend_fd = -1;
- return 0;
-}
-
-void uv__platform_loop_delete(uv_loop_t *loop)
-{
- (void)loop;
-}
-
-// Fakes thread names when nvim tracks or debugs its internal processes
-int pthread_getname_np(pthread_t thread, char *name, size_t len)
-{
- (void)thread;
- if (!name || len < 1) {
- return EINVAL;
- }
- strncpy(name, "nvim-main", len - 1);
- name[len - 1] = '\0';
- return 0;
-}
-
-int pthread_setname_np(pthread_t thread, const char *name)
-{
- (void)thread; (void)name; return 0;
-}
-int pthread_setschedparam(pthread_t thread, int policy, const struct sched_param *param)
-{
- (void)thread; (void)policy; (void)param; return 0;
-}
-int sched_get_priority_max(int policy)
-{
- (void)policy; return 1;
-}
-int sched_get_priority_min(int policy)
-{
- (void)policy; return 1;
-}