feat(native): mer native — WebView shell + window.mer.invoke bridge (P0–P3)#100
feat(native): mer native — WebView shell + window.mer.invoke bridge (P0–P3)#100pranavp311 wants to merge 11 commits into
Conversation
… (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.
🤝 Review + live-test handoff (for the next agent · cc @pranavp311)Picked this up, reviewed it, and tested ✅ Verified working
🔧 Issues to fix before merge
📝 Nits (non-blocking)
ℹ️ Not a code issueCouldn'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 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 |
|
Raised owner review/merge tracker: #101. Latest PR branch push includes the blocker fixes from review:
Local verification passed:
@justrach please review and merge PR #100 into release/v0.2.53 if the changes look good. |
|
Follow-up pushed for the non-blocking owner review nits: 55eb3bd. Addressed:
Verification passed locally:
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.
Closes #101
Summary
Implements
mer native, targetingjustrach/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 awindow.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.zigalready supportsport=0ephemeral binding + aServerReadyhandshake (literally commented "supports port=0 for desktop/testing" atserver.zig:110). The shell only adds the WebView + window + bridge + packaging layer. The server, router, dispatch, SSR, and watcher are untouched.Developer experience
mer nativereuses themer devpipeline (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/eventsSSE 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 inexamples/desktop/main.zig(from #50/#53). Verification found real Zig 0.16 API drift — the spike calledctx.ready.event.set()/wait()on the rawstd.atomic.Value(bool), which has no such methods in 0.16;ServerReadyalready wraps them correctly. Fixed (bcca0aa).zig build desktopworks again.P1 — Manifest-driven shell + CLI
New
src/native/module (re-exported asmer.native):shell.zig— server-on-port=0+ServerReadyhandshake + platformopenWindow. Also fixed a latent bug: the desktop spike never calledruntime.init(), soruntime.iowas 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 ofmer.app.zon(imported as a build module).main.zig— native binary entry;--dev/--no-devflags.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.invokeJS↔Zig bridgebridge.zig— comptime command registry (mirrorssrc/dispatch.zigroute tables); 64 KB payload limit, permission gate (cmd.perm ∩ manifest.perms), deny-by-default; returnswindow.mer._resolve(id,ok,json)JS. 7 unit tests, all passing, wired intozig build test.macos.zig— dynamically allocates aMerInvokeHandlerObjC class (objc_allocateClassPair+class_addMethod) conforming toWKScriptMessageHandler; the IMP pulls the message body, callsbridge.dispatch, evaluates the result JS viaevaluateJavaScript:. Injects thewindow.mershim at document start viaWKUserScript.mer.ping,mer.echo(always allowed),dialog.openFile/clipboard.write(permission-gated stubs).P3 — Manifest-driven
.apppackagingmer packagereadsmer.app.zoncomptime inbuild.zigand drivesInfo.plist(CFBundleIdentifier←id,CFBundleName←display_name,CFBundleVersion←version); the bundle is named<display_name>.app. Verified:open zig-out/MerJS.applaunches the native window.Decisions (issue's open questions, resolved)
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'sbridge/app_manifest/toolingwere studied as design references only.web_engine = "chromium"/CEF is parsed but rejected with a clear error.dispatch.zigper-route shape), deny-by-default.server.modeswitch —embedded(in-process loopback, the proven path) shipped;static/mer://appis 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:Shellinterface is sketched; WebKitGTK linking + a Linux test environment are out of scope for this PR. macOS-first matches the existingexamples/desktopprecedent.embeddedloopback server works in prod today. Theserver.mode = "static"path (prerendereddist/served over amer://appcustom 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.shell.zig'sswitch (builtin.os.tag)returnserror.UnsupportedPlatformfor non-macOS.libmer-native.a)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.dialog/clipboardplatform wiringdialog.openFileandclipboard.writeare registered, permission-gated, and round-trip through the bridge, but returnHandlerError— the NSOpenPanel/NSPasteboard calls land after v0.2.53. The bridge plumbing (the hard part) is done.mer packageproduces a locally-runnable.app(matches the existingexamples/desktop/README.md"Status" caveats). Not App Store distributable yet.http://127.0.0.1:<port>); the size + permission guards are the security boundary. Per-frame origin checking is a planned hardening step.Verification
Files
+1466 / −5 across 16 files. Server/router/dispatch/watcher untouched.
Closes the
mer nativeepic for v0.2.53 (P0–P3). P4–P7 tracked inplans/mer-native.md.