Chapter 1 of 7

BeforetheFullLuaPort

wasmoon lua + wa-sqlite boot on the user browser and render a .fuwa dsl that compiles to native lua. not interactive yet, i need to fix some bugs.

code modeAug 11, 2026
fuwakotoluawasmbrowser-idedsl

First chapter in the series, it's our "what existed before the rewrite".

We have Wasmoon Lua + wa-sqlite booting in the user's browser, rendering a .fuwa DSL that compiles to native Lua. It's not interactive yet (doesn't have all the features from the full lua port that will be addressed in the next chapters). I'm still chasing bugs. But the shape is there.

This demo is the live "svelte" stripped down to be simpler.

Guest Mode

This screen is for the "guest mode" (unauthenticated person), this lesson is "mama fury", also this is "desktop mode", we have "overengineered" CTA buttons that I will probably remove cause I don't want to handle websockets, a "to be removed" comment section, and the code preview.

The Adopt Panel

Click "Code It" and you land on the owner/adopt panel.

Yeah there is light mode for the "tutorial" but it hurts my eyes so let's use dark mode, it renders the app.fuwa or "routes" page, and boots on the left side (hero section) our wasmoon, this has livereload you can play with it.

btw I am bad at teaching so for now the "content" of the lesson stickers is not very helpful, anyway let's continue.

The Shell Header

There is a code-shell-header with some actions: the timer (cause I don't want people wasting their lives on a cute app), the deploy and sync buttons, the "hint" that does nothing for now, and my nemesis "recharge" that would open "Stripe"...... let's not mention Stripe (..... so absurdly fast... wow.... no edge cases for Brazilian folks.... sure sure. =͟͟͞(꒪ᗜ꒪ ‧̣̥̇) ).. yeah there is a terminal cause I like terminals (the Lua rewrite implemented them and added proper observability, btw).

Single terminal. Boring.

"Multi terminal" someday.

Deploy and Sync

How do deploy and sync work? So the code is saved at localStorage normally, but if you want to switch devices tap sync, pay onigiris (we need to feed fuwa-chan), and sync with our servers, deploy creates a "shareable" link so you can flex with friends (free marketing hahaha ꉂ(˵˃ ᗜ ˂˵)).

This is the "page" created at "localhost/preview/breezy-otter".

/explore

Also once you "adopt" a code it creates a card/register on "/explore". Yeah the UI is weird for now, but the idea was masonry from Pinterest but with our "lessons" preview, for debugging reasons things are "the same size", see the small card with "breezy otter" and a weird circle? that's our deployed app everyone can see and "fork it" / adopt it (#note this feature was not ported to the lua rewrite yet).

The white first element is because I forgot to update its cover. The hashtags use Pixiv-style discovery tags. Makes browsing easier.

Mobile

Now let's talk about the mobile version.

One of the first "pain points" to be solved was "dev shenanigans" and ease of use, for mobile especially to allow "coding" on a bus, I need to rethink how I would even turn the desktop into something usable.

I agree it's weird, our /explore for mobile, it was better before when I showed different shapes of items, but currently it's weird cause I was debugging the lessons.

Now this is one possibility (TikTok-like for GuestPanel).

Once we click on "peek code", we open a drawer.

After typing "Code it" (see the keyboard? that's a bug).

And once at "code": the idea is the livereload frontend should be the hero, the "coding" is not, also yeah the custom keyboard is still weird, I need some time to fix and properly add developer ergonomics to it.

But in theory if you swipe on a phone, you can get a "scientific calculator" feeling, someday I might add some Neovim stuff.

Check the next chapters below that discuss the "full lua rewrite" of the core IDE.

Chapter 2 of 7

Chapter 2 of 7

The.fuwaCompiler

how a .fuwa file becomes Lua, every construct from module to ? sugar, and why the compiler knows nothing about HTTP.

code mode · compilerJul 25, 2026
fuwakotocompilerdsllua

So far in this series I've been talking about what .fuwa looks like. The DSL, the tags, the code mode, the mobile weirdness. But I never showed you how it actually works. This is the first of the nerdy deep-dive track. Welcome to my workshop. It's messy, there's Lua everywhere, and I still find bugs in the <include> cycle detector. Lets go.

The compiler lives in runtime/stdlib/compiler/. Eleven modules. All written in Lua. This is important because it means the compiler can run inside Wasmoon at runtime. Not just at build time. Your browser can compile .fuwa files on the fly.

The whole thing is open: github.com/Schywi/fuwa/tree/main/runtime/stdlib/compiler if you want to read the source directly (btw i need to refactor it later).

The Pipeline

The whole thing is a one-way street. Data flows in one direction and never looks back.

package_web.lua orchestrates everything. It takes a directory full of .fuwa files, compiles each one, passes non-.fuwa files straight through, and generates a main.lua via a bootstrap. Then modules.lua does the top-level dispatch. It looks at the first keyword in your file and goes: is this a module? A schema? Routes? Actions? A view fragment? That's your block kind.

app.fuwa  ──┐
pages/*.fuwa ─┤
models/*.fuwa ─┤
view.fuwa  ──┘
              │
              ▼  package_web.lua
         ┌─────────────────┐
         │   compiler/      │
         │   modules.lua    │  dispatch by block kind
         │   actions.lua    │  ? sugar expansion
         │   routes.lua     │  web.app() wiring
         │   schema.lua     │  model() generation
         │   view.lua       │  <include> + fragments
         │   bootstrap.lua  │  → main.lua
         └─────────────────┘
              │
              ▼
         compiled Lua bundle
         (main.lua + modules)

The ONE RULE: one block kind per file. You want routes AND a schema in the same file? Nope. Hard compile error.

The Constructs

Here's what you write and what it becomes. This is the part where I geek out.

module Name compiles to local M = {}; return M. That's it. Dead simple.

use ModuleName becomes local ModuleName = require("ModuleName"). This is the loose import, the "just bring it in" one. btw import Name "path" end is the explicit one: local Name = require("path.to.module"). Two imports because sometimes you want structure and sometimes you just want the thing.

Routes are where it gets fun:

routes do
  GET "/posts" Posts.index
  POST "/posts" Posts.create
end

This compiles to web.app({ web.GET("/posts", Posts.index), web.POST("/posts", Posts.create) }). The compiler wraps everything in a web.app call so the runtime knows how to wire up the HTTP handler. But here's the thing: the compiler itself knows nothing about HTTP.

The compiler boundary is sacred. The compiler doesn't know about workers, Wasmoon, dev servers, or HTTP. Data flows dev server → package_webcompiler.core, never back. The compiler is a pure transformation. It takes text, it gives you Lua. That's the whole contract. If I ever break this rule, future dev-san gets to slap present dev-san.

The ? Sugar (My Favorite Thing)

Ok so this is the interesting ? sugar.

x = some_risky_call()?

That one question mark expands into about twenty lines of Lua (again need to refactor it's ugly). It unwraps {ok, value} tuples. If it's ok, you get the value. If it's error, it calls fail with the error. It's Rust's ? operator but in a DSL for browser apps. You write one character and it handles all the error plumbing. Worth it ദ്ദി ˉ͈̀꒳ˉ͈́ )✧.

Everything Else (Rapid Fire)

action name(req) do ... endfunction M.name(req) ... end. Actions are just functions on the module table.

schema "table" do ... endschema.model(...) with fields, changes, and timestamps. The schema compiler is its own little monster with field type checking and auto-timestamps.

match expr do when X -> ... else -> ... end → an if/elseif chain. Nothing fancy, just sugar.

if cond -> response (the guard syntax) → if cond then return response end. Guards are the early-return pattern baked into the syntax.

render "name", key: valrender("name", {key = val}). The comma-instead-of-parens thing is a deliberate choice to make templates feel less like function calls.

redirect pathredirect(path). fail :kind, metafail(...). These are one-to-one mappings but they matter for readability.

<include src="views/layout.fuwa" /> → compile-time expansion. The compiler reads the file, inlines it, and checks for cycles. The cycle detection was a genuinely fun bug to find. I had recursive includes that worked fine until they didn't, and the error message was just "stack overflow" with no line number. Took me two hours to trace. Now there's a proper cycle detector with a clear error. You're welcome, future me.

Views and Fragments

Files under views/fragments/*.fuwa become named templates for HTMX partial swaps. view.lua in the compiler builds a fragment registry so the runtime knows what to render when an HTMX request comes in asking for a fragment. This is how we get SPA-like behavior without writing JavaScript.

Bootstrap

bootstrap.lua generates main.lua. That file creates handle_request(method, path, body) and the first-paint set_html() call. It's the glue between the compiled modules and the Wasmoon runtime. It's stable now. Probably.

Diagnostics

Errors and warnings are collected during the compile pass and reported with line numbers. The error messages are not great yet. Some of them say things like "expected block" when what I really mean is "you put a schema inside a routes block, don't do that." I'll fix them eventually.

Chapter 3 of 7

Chapter 3 of 7

TheTemplateEngine&Runtime

a pure-Lua SSR engine, three isolation boundaries, postMessage contracts, and why fetch() was a trap.

code mode · runtimeJul 25, 2026
fuwakotoluawasmiframehtmx

Alright, let's talk about the part of Fuwakoto nobody sees but I spent way too long on. The runtime. The template engine. The plumbing. (˶˃ ᵕ ˂˶)

So in the previous zines we had a DSL, a code editor, livereload. Cute. But how does a .fuwa file actually become HTML? How does clicking a button talk to SQLite? That's what this one is about.

The Template Engine

I wrote an HTML parser in Lua. This was not the plan. The plan was "just use something that exists." But Lua doesn't have one (or i din't want to add dependencies anyway), and I needed data binding and directives that compiled to native Lua, so here we are.

It's a recursive-descent parser. Reads HTML character by character, builds an AST of element nodes and text nodes. Handles void tags, self-closing tags, raw text tags like <script> and <style>. The kind of thing you write once, debug for two weeks, and then pray nobody touches again.

Data binding uses &variable for escaped output and &unsafe variable for raw injection. The unsafe variant is how you shove a DOCTYPE in there. Yes, it's deliberately scary looking (also & was stolen from golang).

Directives: f-if="path" and f-if="not path" for conditional rendering. f-for="item in list" pushes a nested environment so the loop body sees the right bindings. f-csrf injects CSRF tokens on forms. <include> is expanded at compile time, not runtime. That one decision saved me from a whole category of bugs.

Dev mode reports missing_binding errors with line numbers. It fails LOUDLY. I'd rather scare someone with a red box than have a silent blank page they can't debug.

Three Isolation Boundaries

Boundary Runs Talks via
Main thread (host) Orchestration, DOM shell, message routing -
Web Worker Wasmoon Lua VM + SQLite-WASM postMessage (WorkerRequest/WorkerEvent)
Sandboxed iframe (tenant) Rendered HTML + petite-vue/htmx/UnoCSS postMessage (TenantCommand/TenantEvent)

These boundaries never touch directly. Everything crosses via typed postMessage contracts. The tenant iframe only has allow-scripts allow-forms. It trusts nothing. If the rendered app goes rogue, it can't escape the sandbox (in theory).

The Web Worker runs the actual Lua VM and SQLite. The main thread just routes messages and manages the iframe shell. The iframe gets HTML strings and sends back interaction events. Three separate worlds, one typed message bus. The full architecture doc is at github.com/Schywi/fuwa/blob/main/docs/architecture.md with mermaid diagrams if you want the annotated version.

┌───────────────────────────────────────────────┐
│                MAIN THREAD                     │
│  orchestration · compiler · preview shell      │
│                                                │
│  ┌──────────────────────────────────────────┐ │
│  │            WEB WORKER                    │ │
│  │  Wasmoon Lua VM · SQLite-WASM           │ │
│  │                  │                       │ │
│  │       postMessage│ contracts             │ │
│  └──────────────────┼───────────────────────┘ │
│                     │                          │
│  ┌──────────────────┼───────────────────────┐ │
│  │   SANDBOXED IFRAME (tenant)              │ │
│  │   petite-vue · htmx · UnoCSS · GSAP      │ │
│  │   allow-scripts allow-forms ONLY          │ │
│  └──────────────────────────────────────────┘ │
└───────────────────────────────────────────────┘

Two Execution Modes

The runtime has two personalities.

script mode is first paint. The entry point runs top to bottom, calls set_html() to emit HTML. This is the SSR pass.

request mode is every interaction after that. handle_request() dispatches through routes. User clicks a button, we hit a route, the action runs, the DB responds, HTML comes back.

The Persistence Loop

Okay this is the most important flow in the whole system. Ready?

User clicks → htmx XHR → shimmed XMLHttpRequest → postMessage to host → LaunchTarget{kind:'request'} → Lua handle_request → route → action → __fuwa_db_op → SQLite-WASM → response → host → iframe → htmx swap + petite-vue re-scope.

That's the whole round trip. Every mutation, every page change, every data fetch follows this exact path.

User click (@click)
  │
  ▼
htmx XHR ──► shimmed XMLHttpRequest
                  │
                  ▼ postMessage (TenantEvent)
              Host (PhoneShell)
                  │
                  ▼ LaunchTarget{kind:'request'}
              Worker (Lua)
                  │ handle_request → route → action
                  ▼
              __fuwa_db_op → SQLite-WASM  ◄── committed row
                  │
                  ▼ render() HTML
              Host ──► postMessage (TenantCommand) ──► iframe
                                                         │
                                                         ▼ htmx swap
                                                     petite-vue re-scope

The fetch() Trap

Here's a bug that made me question my life choices. ꉂ(˵˃ ᗜ ˂˵)

Mutations MUST use XMLHttpRequest. Not fetch(). The bridge only shims XHR. If you call fetch() from inside the iframe, the request escapes the sandbox entirely. It goes to the void. The Lua action runs fine. The DB never gets the write. Everything looks correct but nothing actually persisted.

This was a real bug. All mutations silently failed because fetch() just... left. No error, no warning, just a confident request heading off to nowhere.

Anyway now : XHR only. fetch() is a trap (for fuwa at least).

Where the UI Libraries Actually Live

petite-vue, htmx, UnoCSS, GSAP are not bundled into the host. They're injected into the iframe's srcdoc. petite-vue from unpkg, htmx from a local /testpanel/tenant/htmx.min.js, UnoCSS at runtime, GSAP on window.gsap, all of then are vendored at the repo level.

The host only renders HTML strings and shuttles messages. The reactive layer belongs entirely to the DSL-authored app running inside the sandbox. This separation is why the architecture works: the host doesn't need to know about Vue reactivity or CSS utilities. It just moves bytes between boundaries.

One thing that took me a while: petite-vue on stable parents. v-scope goes on ancestor elements that htmx doesn't swap. That way stateful widgets survive DOM updates. If you put v-scope on the same element htmx replaces, the state evaporates. Took a while to figure that out.

Chapter 4 of 7

Chapter 4 of 7

TheSelf-HostingShell

the IDE is itself a .fuwa app, no god wrapper, CodeMirror and xterm.js orchestrated from Lua, and why the host is just another payload.

code mode · shellJul 25, 2026
fuwakotoidecodemirrorxtermself-hosting

So in the previous chapters I showed you the code mode, the editor, the terminal, the deploy to /preview/breezy-otter. But I never explained what the shell is. Like, architecturally. What's holding all of this together?

Turns out: nothing special. The shell is just another .fuwa app.

shell/app.fuwa is a regular .fuwa file. It has routes like any other app:

module Shell
import Home "pages/home"
routes do
  GET "/" Home.index
  GET "/inspect/:payload_id" Home.inspect
  POST "/switch/:payload_id" Home.switch
end

That's it. shell/pages/home.fuwa has the action handlers. The difference is that this particular .fuwa app runs in a privileged worker. It has access to host capabilities that regular payloads can't touch. Things like host.mount_payload and host.switch_payload. The compiler has no idea "host" exists. It's not a special compilation target, there is no #ifdef HOST_MODE nonsense. Capabilities are enforced at runtime by what modules are available to your worker. If you're the shell worker, you get host.*. If you're a tenant payload, you don't.

This is the "no god wrapper" rule. The IDE chrome is not a separate application wrapping your code. It is your code, just with extra keys to the building. If the shell needs a feature, it goes into the runtime or stdlib first. Only when it genuinely needs privileged access does it become a host capability.

The JS glue

shell/views/layout.fuwa It loads everything: htmx for the HTML morphing, petite-vue for the reactive bits, CodeMirror 6 via importmap, xterm.js 6.0 for the terminal, and all the shell hooks.

The hooks live in shell/hooks/ and they are deliberately dumb. editor.js, terminal.js, workspace.js, runtime-session.js, preview-browser.js, preview.js, observability.js. Each one receives commands from Lua-land, executes them, reports back. No framework.

CodeMirror 6 from Lua is... an experience (˶˃ ᵕ ˂˶). The importmap approach works. You declare the CM modules in an import map, the layout loads them, and the Lua side can call into them through the hook bridge.

xterm.js is simpler. Terminals are terminals. You write bytes, they appear. The hook is thin: Lua spawns a pseudo-terminal in the worker, pipes stdout/stderr to the xterm instance, done.

The shell source is at github.com/Schywi/fuwa/tree/main/shell if you want to see it.

The payload boundary

Here's where it gets interesting. The shell app can mount other payloads as sandboxed tenants. When you open a project, host.mount_payload(id) loads that payload's compiled bundle into a separate worker. Each payload gets its own isolated Lua VM, its own SQLite instance via wa-sqlite, its own routes. The shell's routes serve the IDE chrome. The editor panels, the terminal, the header. The payload's routes serve the tenant app. Whatever the user built, running in the preview iframe.

host.switch_payload(id) hot-swaps which app is running. The preview iframe reloads, the editor panels update to show the new payload's source files, and the terminal context switches. It's not seamless. There's a flicker. But it's fast enough that it feels like switching tabs more than booting a new environment (unless you deploy it to digital ocean.... the VPS has weird edge cases with lua wasmoon and cache).

The payload source lives in payloads/fuwa-gomen/ for the active project. Each one is a self-contained .fuwa project with its own app.fuwa, its own pages, its own views. They don't know the shell exists. They don't need to, the "preview"/marketing of our "deploy" feature also live there.

The workspace switching flow

The whole thing works like this:

  1. Shell renders a grid of payload cards. Think /explore but for your local projects
  2. You click a payload, it fires POST /switch/:id to the shell action
  3. The shell calls host.switch_payload(id), the worker loads the new bundle
  4. Preview iframe reloads with the new tenant app
  5. Editor panels update, file list refreshes, terminal reconnects

It's five steps and most of them are invisible. The user just sees "I clicked a project and now I'm editing it." That's the goal.

  ┌──────────────┐     POST /switch/:id     ┌──────────────┐
  │  Shell UI     │ ──────────────────────►  │  Shell Action │
  │  payload grid │                          │  Home.switch  │
  └──────────────┘                          └──────┬───────┘
                                                   │
                                          host.switch_payload(id)
                                                   │
                    ┌──────────────────────────────┘
                    ▼
  ┌─────────────────────────────────────┐
  │        Worker (new bundle)           │
  │  compile payload → Lua              │
  │  boot Wasmoon + SQLite              │
  └────────────────┬────────────────────┘
                   │
                   ▼ set_html()
  ┌─────────────────────────────────────┐
  │     Preview iframe (new app)         │
  │  htmx · petite-vue · UnoCSS         │
  └────────────────┬────────────────────┘
                   │
                   ▼
  ┌─────────────────────────────────────┐
  │     Shell UI updates                │
  │  editor · file list · terminal      │
  └─────────────────────────────────────┘

What's still broken

Oh, plenty (˶˃ ᵕ ˂˶).

The editor needs a proper file tree. Right now it's just a flat file list and navigating a project with more than five files is... let's say "a experience." The mobile gesture layer from the Svelte version hasn't been ported yet. The custom keyboard ... only at v2.

But here's the thing: making the shell a .fuwa app means the compiler dogfoods itself. Every time the compiler breaks, the shell breaks.

Chapter 5 of 7

Chapter 5 of 7

TheDevServer&TheEdge

a native Lua HTTP server speaking stdin/stdout, OpenResty as the edge proxy with W3C traceparent headers, and Docker Compose for dev and prod shapes.

infra · dev serverJul 25, 2026
fuwakotodev-serveropenrestydockerlua

Alright so we've been talking about language mode, code mode, the DSL, social media experiments. But none of that runs on vibes alone. It needs a server. And the server needs a shape. So this is the infra track (˶˃ ᵕ ˂˶). First of a few.

Let me walk you through what's actually running when you hit fuwa.lmirand.com.

The core dev server is a single Lua file. runtime/fuwa-dev.lua. It speaks HTTP over stdin/stdout, CGI-style. Just lua5.4. It serves static assets from shell/, vendor/, payloads/, compiles .fuwa payloads on the fly, and injects reload SSE scripts into rendered pages. Draft and preview overlays with edits stored under .fuwa-dev/drafts/. The route table: /payload/<id>/..., /preview/<id>/..., /shell/..., /vendor/..., /runtime/tenant.html, /runtime/<id>/bundle.json, /draft/<id>, /__dev/reload.

So on every single request, the server saves package.loaded and package.preload, loads main.lua, calls handle_request(), then restores the original state. Full module isolation per request. No cross-request state leakage.

Out front we have OpenResty. Nginx + LuaJIT. The handler at runtime/openresty/handler.lua calls fuwa_dev.route_request() , the dev server runs inside nginx. Observability dashboards under /dash/signoz/, /dash/vmetrics/, /dash/clickhouse/, /dash/vector/. JSON-structured access logs: request_id, traceparent, method, URI, status, timing.

runtime/openresty/tracing/http.lua generates request IDs (uuid4) and W3C traceparent headers at the edge. Every request gets tagged before it touches the app. The entire chain is traceable from the moment it hits the proxy.

One thing I learned the hard way: variable-based proxy_pass. If nginx resolves upstreams at config-parse time and the downstream service isn't ready yet, you get a dead proxy on startup. Using set $backend "fuwa:8080" and proxy_pass http://$backend defers DNS resolution to request time.

The repo is at github.com/Schywi/fuwa. The OpenResty configs live under infra/openresty/.

Docker Compose wires it all together. Dev shape: docker compose -f infra/docker-compose/dev.yml up -d Dev shape: ./dev.sh → Tilt (infra/Tiltfile) → docker compose brings up the app, OpenResty, SigNoz, telemetry. Prod shape: same idea but with the production OpenResty config and SigNoz.

CI is now six stages. Gatekeeper first (gitleaks, merge-conflict markers, whitespace diff). Then compile-check + Docker build run in parallel. After compile-check: luacheck, unit, smoke, and acceptance tests all run in parallel. Integration runs after unit passes. Then comes the heavy stuff: terraform init/validate/apply → Ansible bootstrap → Terratest (Go test suite) → terraform destroy.

k6 load testing runs five scenarios (infra/k6/fuwa-stress.js): ramp, spike, soak, edge cases, and stress. P95 must stay under 500ms, error rate under 5% and continuous load via infra/docker-compose/load.yml , OTLP trace generator + curl loop hitting OpenResty.

Chapter 6 of 7

Chapter 6 of 7

TheObservabilityPipeline

structured tracing in pure Lua, the two-path trace pipeline (server and browser), Vector routing, OTLP conversion, and why we ditched Uptrace for SigNoz.

infra · observabilityJul 25, 2026
fuwakotoobservabilityopentelemetrysignoztracing

The "server" is a Web Worker running Wasmoon Lua plus SQLite-WASM. Traditional APM tools assume there's a process you can inject an agent into. We don't have that. We have a sandboxed iframe and a postMessage bridge.

So I did the only thing that made sense: build our own trace pipeline.

The span system (runtime/trace.lua)

It captures the same mental model: spans form a tree, each span has timing and attributes, and leaf spans can emit structured log events.

The API is three functions (source):

  • trace.span(name, attrs, fn) creates a span, times the function, records duration
  • span:log(message, fields) attaches a structured log event to the current span
  • span:set(key, value) sets attributes

Spans nest. Call trace.span inside another trace.span and they form parent/child relationships automatically. A scoped context tracks the current span, so span:log() knows where to attach without you threading context through every function call.

Internally it emits events: span_start, span_end, log, set_attr. Each event carries trace_id, span_id, name, attrs, and eventually duration_ms. Easy to inspect with cat if you need to.

The two-path architecture

Here's where it gets interesting. Traces can originate from two completely different places, and they take two completely different paths to the same destination.

Path one: the dev server. When fuwa-dev.lua handles a request, trace events get written as __VECTOR__ prefixed JSON lines to stderr. So in dev, you get live trace streaming in the shell's observability tab without any network hops.

Path two: the browser. When the Wasmoon worker generates traces, they get JSON-encoded and sent via postMessage({type:'trace'}) to the main thread. runtime-session.js picks them up, calls appendEvents(), and POSTs them to the ring buffer endpoint on the dev server.

Both paths use identical event schemas. The browser console panel (shell/hooks/observability.js) renders them the same way regardless of origin.

The bridge to production observability

Okay so local trace streaming in the shell is cute, but what about when you want actual dashboards? Retention? Metrics derived from spans?

Enter runtime/host/vector_bridge.lua. The build_payload() converts request events to JSON. forward_event() POSTs that payload to wherever FUWA_VECTOR_URL points.

Vector (infra/docker-compose/vector.toml) receives traces natively via its OTLP source on port 4318. Vector speaks OTLP directly.

Vector fans out to two destinations: VictoriaMetrics for metrics with 14-day retention, and SigNoz ingester for traces. One unified pipeline. OpenResty sends OTLP/JSON via HTTP → Vector (:4318) → traces to SigNoz → ClickHouse. Metrics go to VictoriaMetrics. Logs also route through SigNoz → ClickHouse.

The data is real now. No synthetic seed data. Every trace on the dashboard came from an actual request. The dashboards aren't empty because the app is alive.

From there it's SigNoz doing the heavy lifting: ClickHouse stores the traces and logs, and the dashboard lives at /dash/signoz/.

The stack, end to end

  ┌─────────────────────────────────────────────┐
  │             SERVER PATH                      │
  │  OpenResty → fuwa-dev.lua (in-process)       │
  │       │ trace events                          │
  │       ├──► shm ring buffer → SSE             │
  │       │    observability.js (dev panel)       │
  │       │                                      │
  │       └──► router.lua                        │
  │            traces → Vector :4318 (OTLP)       │
  │            metrics → Vector :8687 (HTTP)      │
  └─────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────┐
  │            BROWSER PATH                       │
  │  Wasmoon Worker                               │
  │       │ JSON encode                            │
  │       ▼ postMessage({type:'trace'})           │
  │  runtime-session.js → appendEvents()          │
  │       │ POST                                   │
  │       └────── (same OTLP endpoint) ─────────┘ │
  └─────────────────────────────────────────────┘

                  ▼ (production path)
  ┌─────────────────────────────────────────────┐
  │  Vector router                                │
  │  (:4317 gRPC, :4318 HTTP, :8687 metrics)      │
  │       │ log-to-metric transforms              │
  │       ├──► VictoriaMetrics (metrics, 14d)     │
  │       │                                       │
  │       ▼                                       │
  │  SigNoz ingester (OTLP native, no bridge)     │
  │       │                                       │
  │       ▼                                       │
  │  ClickHouse (traces + logs)                   │
  │       │ (1GB mem_limit, Keeper coordination)  │
  │       ▼                                       │
  │  SigNoz dashboard (/dash/signoz/)             │
  │       │ (auto-seeded dashboards)              │
  └─────────────────────────────────────────────┘
Chapter 7 of 7

Chapter 7 of 7

Deploy,Preview,Observe

the deploy pipeline, public preview URLs with random-word slugs, tenant-isolated SQLite, live container logs streamed to the IDE.

code mode · deployAug 7, 2026
fuwakotodeploypreviewsqliteobservabilitymermaid

The compiler works. The runtime works. The observability pipeline works. Terraform provisions a droplet, Ansible bootstraps it, k6 hammers it, CI tears it all down.

What happens when a user clicks a button and their .fuwa app goes live?

This is the deploy track. The part where the IDE starts being an infrastructure control plane (˶˃ ᵕ ˂˶).

The Deploy Button

POST /__dev/deploy. You send a JSON blob with your .fuwa source files. What comes back is a URL like /p/cosmic-reef-phoenix/. Three random words from a pool of sixty. The slug is stable , a deploy_slug cookie ties you to that URL for 30 days. Deploy again, same slug.

Everything lives in SQLite. .fuwa-dev/deployments.sqlite. The store saves four things: the slug, the original .fuwa source, the compiled Lua, and a timestamp. The compiler runs inside OpenResty , same package_web.build() that powers the dev server, now generating production bundles.

POST /__dev/deploy
  │  {entry, files}
  ▼
package_web.build()
  │  .fuwa → Lua modules
  ▼
store.save()
  │  SQLite upsert
  ▼
{ok, slug, url}
  │  /p/cosmic-reef-phoenix/
  ▼
live. no build server, no queue, no workers.

Public Preview: Tenant Isolation

GET /p/cosmic-reef-phoenix/ renders the app in a public shell iframe.

The preview handler (runtime/openresty/deploy/preview_handler.lua) does two things that matter. First: it disables the host module. package.loaded["host"] and package.preload["host"] are set to nil before running user code. You get what you deployed, nothing more.

Second: tenant isolation. The SQLite DB bridge (runtime/openresty/preview/db_bridge.lua) passes a tenant_key = "preview:cosmic-reef-phoenix" to every query. Two different /p/ slugs share the same SQLite file but can't see each other's data , the tenant_documents table has tenant_key as part of the composite primary key. One database, many worlds.

Container Logs, Live

The IDE has a tmux panel that shows live logs from seven containers: OpenResty, SigNoz, the ingester, ClickHouse, Keeper, Vector, and VictoriaMetrics. Seven streams with multiplexing to one SSE endpoint.

GET /__dev/containers/live?name=openresty&name=clickhouse. Every half-second it runs docker logs --tail 100 <name> 2>&1 via io.popen, tracks per-container line offsets, and pushes new lines as SSE events. errors_only=1 filters to Error|Warn|Fail|Fatal|Panic|Exception|Traceback.

Next

See more projects