Skip to content

feat(native): mer native — WebView shell + window.mer.invoke bridge (P0–P3)#100

Open
pranavp311 wants to merge 11 commits into
justrach:release/v0.2.53from
pranavp311:feat/mer-native
Open

feat(native): mer native — WebView shell + window.mer.invoke bridge (P0–P3)#100
pranavp311 wants to merge 11 commits into
justrach:release/v0.2.53from
pranavp311:feat/mer-native

Conversation

@pranavp311

@pranavp311 pranavp311 commented Jun 19, 2026

Copy link
Copy Markdown

Closes #101

Summary

Implements mer native, targeting justrach/merjs:release/v0.2.53: ship a merjs app as a native desktop app — a Zig shell hosting the system WebView (WKWebView on macOS) over the merjs loopback server, with a window.mer.invoke() JS↔Zig bridge. No Electron, no Chromium, no Node.

This is the zero-native model. It is an unusually clean fit because merjs already owns the server half — src/server.zig already supports port=0 ephemeral binding + a ServerReady handshake (literally commented "supports port=0 for desktop/testing" at server.zig:110). The shell only adds the WebView + window + bridge + packaging layer. The server, router, dispatch, SSR, and watcher are untouched.

macOS only in v0.2.53. Linux (WebKitGTK) and Windows (WebView2) are planned.

Developer experience

mer add native      # scaffold mer.app.zon + native/main.zig, print build.zig snippet
mer native          # dev: native window against the hot-reloading server
mer native build    # prod: build the native shell binary (ReleaseSmall)
mer package         # bundle as <Display>.app (manifest-driven Info.plist)

mer native reuses the mer dev pipeline (codegen → serve) and attaches a WebView window once the server reports its bound port. UI edits hot-reload inside the window via the existing /_mer/events SSE channel — the shell does not rebuild on UI edits.

What shipped (this PR)

P0 — Spike verified + fixed

The issue framed P0 ("minimal Zig + WKWebView loading the dev server via ServerReady") as work to be done. It already existed in examples/desktop/main.zig (from #50/#53). Verification found real Zig 0.16 API drift — the spike called ctx.ready.event.set()/wait() on the raw std.atomic.Value(bool), which has no such methods in 0.16; ServerReady already wraps them correctly. Fixed (bcca0aa). zig build desktop works again.

P1 — Manifest-driven shell + CLI

New src/native/ module (re-exported as mer.native):

  • shell.zig — server-on-port=0 + ServerReady handshake + platform openWindow. Also fixed a latent bug: the desktop spike never called runtime.init(), so runtime.io was undefined at runtime.
  • macos.zig — WKWebView + NSWindow via extern ObjC primitives (the proven [Research] Zig 0.15 @cImport compatibility with AppKit/WebKit ObjC headers #50 pattern: no @cImport).
  • manifest.zig — comptime parse of mer.app.zon (imported as a build module).
  • main.zig — native binary entry; --dev/--no-dev flags.
  • build.zig: native (run/dev), native-build (prod), package (.app bundle) steps.
  • cli.zig: mer native, mer native build, mer package, mer add native.
  • mer.app.zon (framework's own manifest) + examples/starter/ templates.

P2 — window.mer.invoke JS↔Zig bridge

  • bridge.zig — comptime command registry (mirrors src/dispatch.zig route tables); 64 KB payload limit, permission gate (cmd.perm ∩ manifest.perms), deny-by-default; returns window.mer._resolve(id,ok,json) JS. 7 unit tests, all passing, wired into zig build test.
  • macos.zig — dynamically allocates a MerInvokeHandler ObjC class (objc_allocateClassPair+class_addMethod) conforming to WKScriptMessageHandler; the IMP pulls the message body, calls bridge.dispatch, evaluates the result JS via evaluateJavaScript:. Injects the window.mer shim at document start via WKUserScript.
  • Reference commands: mer.ping, mer.echo (always allowed), dialog.openFile/clipboard.write (permission-gated stubs).

P3 — Manifest-driven .app packaging

mer package reads mer.app.zon comptime in build.zig and drives Info.plist (CFBundleIdentifierid, CFBundleNamedisplay_name, CFBundleVersionversion); the bundle is named <display_name>.app. Verified: open zig-out/MerJS.app launches the native window.

Decisions (issue's open questions, resolved)

  1. Vendor a thin abstraction, do NOT depend on zero-native — zero-native drags in CEF/C++ host glue (cef_host.cpp, gtk_host.c, webview2_host.cpp) and a richer/divergent manifest schema; merjs already proved the pure-Zig ObjC path itself. zero-native's bridge/app_manifest/tooling were studied as design references only.
  2. System WebView only for v0.2.53; web_engine = "chromium"/CEF is parsed but rejected with a clear error.
  3. Per-command permissions in the manifest (mirrors dispatch.zig per-route shape), deny-by-default.
  4. server.mode switchembedded (in-process loopback, the proven path) shipped; static/mer://app is stretch.

What was deferred (from the original phased plan)

The issue's plan had phases P0–P7. This PR ships P0–P3 on macOS. The following are intentionally deferred and documented in plans/mer-native.md:

Phase Status Why deferred
P4 — Linux WebView (WebKitGTK) deferred Backend behind the same Shell interface is sketched; WebKitGTK linking + a Linux test environment are out of scope for this PR. macOS-first matches the existing examples/desktop precedent.
P5 — Prod server embed / static export deferred (stretch) The embedded loopback server works in prod today. The server.mode = "static" path (prerendered dist/ served over a mer://app custom scheme, no live SSR) is a separate effort and only needed for apps that don't want a live server. The manifest field is stubbed.
P6 — Windows (WebView2) deferred New platform backend; no Windows environment to validate. shell.zig's switch (builtin.os.tag) returns error.UnsupportedPlatform for non-macOS.
P7 — Mobile (iOS/Android via C ABI libmer-native.a) deferred Largest remaining piece; requires a C ABI surface + Xcode/Android-consumable artifacts. Explicitly out of scope for v0.2.53.
CEF / bundled Chromium deferred web_engine = "chromium" is parsed but rejected. CEF is a large, separate effort (C++ toolchain, ~hundreds of MB) and the issue itself flagged it as a later concern.
Real dialog / clipboard platform wiring deferred dialog.openFile and clipboard.write are registered, permission-gated, and round-trip through the bridge, but return HandlerError — the NSOpenPanel/NSPasteboard calls land after v0.2.53. The bridge plumbing (the hard part) is done.
Code signing / notarization deferred mer package produces a locally-runnable .app (matches the existing examples/desktop/README.md "Status" caveats). Not App Store distributable yet.
Dynamic origin extraction from WKScriptMessage deferred (hardening) v0.2.53 trusts loopback by construction (the shell only loads http://127.0.0.1:<port>); the size + permission guards are the security boundary. Per-frame origin checking is a planned hardening step.

Verification

zig build test         ✅  (incl. 7 bridge dispatch tests)
zig build native-build ✅  (6 MB binary; smoke-tested: server binds ephemeral port, window opens)
zig build package      ✅  (MerJS.app; `open` launches it)
zig build cli          ✅
zig build desktop      ✅  (no regression)

Files

src/native/               (new) shell.zig, macos.zig, bridge.zig, manifest.zig, main.zig, mer.zig, commands.zig
build.zig                 (edit) native / native-build / package steps; bridge tests in `test`
cli.zig                   (edit) mer native / native build / package / add native
mer.app.zon               (new) framework's own manifest
examples/starter/         (new) mer.app.zon + native/main.zig templates for `mer add native`
examples/desktop/main.zig (fix) Zig 0.16 drift
docs/native.md            (new) user docs
plans/mer-native.md       (new) full design + phased roadmap (P0–P3 marked shipped)

+1466 / −5 across 16 files. Server/router/dispatch/watcher untouched.

Closes the mer native epic for v0.2.53 (P0–P3). P4–P7 tracked in plans/mer-native.md.

… (Zig 0.16 drift)

P0 verification: the justrach#50/justrach#53 desktop spike called ctx.ready.event.set()/wait()
on the inner std.atomic.Value(bool), which has no such methods in Zig 0.16.
ServerReady already wraps them correctly (src/server.zig:47,54). Call those.
zig build desktop now succeeds on release/v0.2.6.
src/native/ module (re-exported as mer.native):
- shell.zig: server-on-port-0 + ServerReady handshake + platform openWindow.
  Fixes the desktop spike's missing runtime.init() (runtime.io was undefined).
- macos.zig: WKWebView + NSWindow via extern ObjC primitives (proven justrach#50 pattern).
- manifest.zig: comptime parse of mer.app.zon (@import module wired in build.zig).
- main.zig: native binary entry; --dev/--no-dev flags drive hot reload.
- bridge.zig / commands.zig: P2 stubs (compile, no-op surface).

build.zig: native (run/dev), native-build (prod), package (.app bundle) steps.
mer.app.zon: framework's own manifest.
examples/starter/{mer.app.zon,native/main.zig}: scaffold templates.

cli.zig: mer native / mer native build / mer package / mer add native.
- mer add native scaffolds manifest + native/main.zig + prints build.zig snippet.

Verified: zig build native-build produces mernative (6 MB); running it binds
an ephemeral loopback port and opens the WKWebView. zig build test passes.
bridge.zig — platform-agnostic dispatch:
- comptime command registry (mirrors src/dispatch.zig route-table shape)
- guards: 64KB payload limit, permission check (cmd.perm ∩ manifest.perms),
  deny-by-default unknown commands
- returns 'window.mer._resolve(id,ok,json);' JS for the IMP to evaluate
- reference commands: mer.ping, mer.echo (always allowed), dialog.openFile +
  clipboard.write (permission-gated stubs; NSOpenPanel/NSPasteboard post-v0.2.6)
- 7 standalone unit tests (size/perm/deny/parse/resolve) — all pass

macos.zig — WKScriptMessageHandler ObjC glue:
- dynamically allocates MerInvokeHandler class (objc_allocateClassPair +
  class_addMethod + objc_registerClassPair) conforming to WKScriptMessageHandler
- IMP pulls message body, calls bridge.dispatch, evaluates result JS via
  evaluateJavaScript:completionHandler:
- injects window.mer shim (invoke/_resolve) at document start via WKUserScript
- registers 'merInvoke' message handler on the webview's userContentController

shell.zig builds a long-lived bridge.Ctx (manifest permissions) for the IMP.
build.zig runs src/native/bridge.zig tests in 'zig build test'.
build.zig: the 'package' step reads mer.app.zon comptime (@import) and drives
Info.plist — CFBundleIdentifier<-id, CFBundleName<-display_name,
CFBundleVersion<-version; the bundle is named <display_name>.app. The binary
(CFBundleExecutable=mernative) is installed into Contents/MacOS/.

Verified: zig build package emits MerJS.app; 'open MerJS.app' launches the
native window (server binds ephemeral loopback, WebView loads it).

docs/native.md: full user docs — quick start, manifest schema, bridge contract,
built-in commands, adding commands, security model, dev/prod, v0.2.6 limits.
@justrach

Copy link
Copy Markdown
Owner

🤝 Review + live-test handoff (for the next agent · cc @pranavp311)

Picked this up, reviewed it, and tested mer native end-to-end on Zig 0.16.0 / macOS. TL;DR: architecture is solid and the JS↔Zig bridge genuinely works — zig build test/native-build/package are all green, and I drove every bridge path live. There are 3 small things to fix before merge (all one-liners), plus a few nits.

✅ Verified working

  • zig build test (incl. the 7 bridge.zig tests), zig build native-build (5.8 MB binary), zig build package (→ MerJS.app) — all pass on 0.16.0.
  • Server-on-port=0 + ServerReady handshake binds an ephemeral port; cold start (launch→first byte) ≈ 55 ms.
  • SSR over loopback returns HTTP 200; served HTML is byte-1:1 with the page source.
  • Bridge proven live (the part the unit tests can't cover): built a demo page that fires window.mer.invoke() for all 5 outcomes on load. Captured all five arriving at Zig dispatch() with correct cmd/args/id — mer.ping, mer.echo (structured args round-tripped JS→NSString→UTF8→Zig), dialog.openFileHandlerError stub, clipboard.writePermissionDenied (after dropping clipboard from the manifest — the gate really blocks), and unknown→UnknownCommand. The whole ObjC chain (shim → postMessage → dynamically-allocated MerInvokeHandler → IMP → dispatchevaluateJavaScript) is exercised.

🔧 Issues to fix before merge

  1. Memory leak in dispatch() error pathssrc/native/bridge.zig (~L130 & L134, the two try quoteStr(...) args to resolveStr). quoteStr allocates, resolveStr copies it into a new allocation, and the quoteStr result is never freed. Leaks on every UnknownCommand and HandlerError — i.e. every dialog.openFile / clipboard.write call. The 7 tests miss it because they use an arena; reproduced under a leak-detecting allocator → "1 tests leaked memory." Fix: free the quoteStr result after resolveStr, or don't double-allocate.
  2. CLI prints the wrong .app pathcli.zig cmdPackage prints open zig-out/MerNative.app, but the bundle is named from display_nameMerJS.app. docs/native.md is already correct; only the print is stale, so the copy-paste hint 404s.
  3. mer add native scaffold snippet won't compile — the printed build.zig snippet calls addRoutesModule(b, native_mod, mer_mod), which is undefined in a user project (in-framework it's helpers.addRoutesModule(...) with a 6-arg signature). Pasting the snippet as instructed fails with "use of undeclared identifier".

📝 Nits (non-blocking)

  • Watcher path hardcoded to "app" in shell.zig — correct for a scaffolded project, but for the framework's own zig build native the routes live in examples/site/app, so hot-reload watches a nonexistent ./app. No crash.
  • WindowConfig defaults are dead — fromZon reads win.label/title/width/height directly, so a .zon window omitting any field is a comptime error instead of falling back to the struct default. Use @hasField like the server/permissions blocks.
  • Shutdown races — detached server/watcher threads touch runtime.io / the stack watcher after run() returns and defers fire on window close. Benign (process exits), inherited from the desktop spike, but worth a comment.

ℹ️ Not a code issue

Couldn't grab a screenshot of the native window from this environment — macOS screen-capture needs the host app's Accessibility/Screen-Recording grants, unrelated to the PR. Rendering is 1:1 regardless (WKWebView = Safari's WebKit; served SSR matches source byte-for-byte). For sanity I also stood up a real Next.js 15 app rendering the same UI: merjs native 5.8 MB / ~55 ms cold start / zero runtime vs Next.js ~322 MB on disk (node_modules + .next) / 773 ms Ready in / needs Node + a browser.

Handoff state: the 3 fixes are all one-liners; nothing pushed, all testing was in a throwaway worktree. Next agent: knock out 1–3, re-run zig build test + zig build package, and confirm the dialog/clipboard handlers are still expected to be post-v0.2.6 stubs (they are, per the PR description). 🚀

@pranavp311

Copy link
Copy Markdown
Author

Raised owner review/merge tracker: #101.

Latest PR branch push includes the blocker fixes from review:

  • complete native scaffold/build/package steps
  • bridge allowed-origin enforcement and safer Promise rejection paths
  • macOS app termination on last window close
  • v0.2.53 docs/manifest cleanup
  • release/** PR CI trigger

Local verification passed:

  • zig build test
  • zig build cli
  • zig build desktop
  • zig build native-build -Doptimize=ReleaseSmall
  • zig build package -Doptimize=ReleaseSmall
  • git diff --check

@justrach please review and merge PR #100 into release/v0.2.53 if the changes look good.

@pranavp311

Copy link
Copy Markdown
Author

Follow-up pushed for the non-blocking owner review nits: 55eb3bd.

Addressed:

  • Native watcher path is now manifest-driven via server.watch_dir, with the framework manifest watching examples/site/app and scaffolded apps defaulting to app.
  • WindowConfig defaults now apply when a window omits label/title/width/height; added manifest tests for this.
  • Added a shell lifecycle comment documenting the current detached server/watcher behavior and the future cooperative shutdown point.

Verification passed locally:

  • zig build test
  • zig build cli
  • zig build desktop
  • zig build native-build -Doptimize=ReleaseSmall
  • zig build package -Doptimize=ReleaseSmall
  • git diff --check

The only local unstaged file left is the pre-existing codedb.snapshot change, not included in the PR commits.

Two small framework additions needed by native desktop apps that host a
built SPA and need a long-lived SSE/push channel:

1. Config.raw_handler — a hook checked before routing/static. Apps register
   a callback that receives the live std.http.Server.Request and can call
   respondStreaming to hold the connection open (SSE, websockets). Normal
   API routes return a single mer.Response and cannot do this. Returns true
   if handled; false falls through to the normal dispatch path.

2. Config.static_dir — serve a directory other than public/ (e.g. a Vite
   dist/ build). When set, '/' serves index.html and unknown paths fall
   back to index.html (SPA history-fallback), since a built client-side
   app owns its own routing. tryServe now takes a ServeOpts{dir,spa}.

Both are opt-in (null/default preserves existing behaviour). Server, ConnCtx,
and serveRequest thread the new fields through. mer.RawHandler re-exported.

Background: the codegraff-zig GUI pivot from Tauri/Rust to a merjs/Zig
backend needs these to (a) push graff session-updated events over SSE and
(b) serve the existing Vite/React dist/ from the embedded native server.
Apps that host a built SPA and need a push channel (SSE) can now:
- set server.static_dir in mer.app.zon (e.g. "dist") to serve a built
  client app with SPA history fallback instead of public/
- pass a RunOpts{ .raw_handler = ... } to Shell.run to register a
  raw-request handler (e.g. GET /events SSE) checked before routing

Threaded static_dir + raw_handler through ServerCtx into the Server Config.
Existing callers pass .{} (no raw handler) — no behaviour change.
When static_dir is set (SPA mode), static.tryServe's history fallback was
serving index.html for /api/* paths, shadowing backend routes. Now the
router is consulted first: if a route matches the path, skip static/SPA
and dispatch the route. Non-route paths still get static + SPA fallback.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants