Skip to content

Phaze + Wry

wry is the webview layer under Tauri: a thin Rust binding to the webview the operating system already ships — WKWebView on macOS and iOS, WebView2 on Windows, WebKitGTK on Linux. Phaze + Wry is a Phaze app embedded in that webview and served from the binary — the same components, the same Phaze Router, the same Phaze Transport as the web target, with no browser engine bundled, no JavaScript server, and no IPC layer to write.

This page is the reason the target exists — where the speed and the security come from, measured against the shape desktop apps take today: Electron carrying a Next.js server. The package surface (@madenowhere/phaze-native, the native() build plugin, the shell bindings) is the Phaze Native reference. Tauri and the SwiftUI WebView shell (/swift, the Mac App Store one) are the sibling shells; this page is the bare one.

The most capable pattern for a React desktop app today runs Next.js with React Server Components inside Electron’s main process and intercepts the window’s HTTP with protocol.handle, so pages, route handlers and server actions execute on the user’s machine with direct Electron access, while the renderer keeps using plain fetch. No open port, no IPC channels. It is a genuinely good idea — and it is worth stating precisely which parts of it Phaze keeps, because the parts it drops are where the cost lived.

Decision in the Electron + Next patternWhat it buysPhaze + Wry
Satisfy the web abstraction locally — the page keeps fetch, cookies and forms; the shell answers them in-processNo IPC surface to design, type or hardenKept. Phaze’s client half already speaks nothing else: an action is a same-origin fetch, a navigation fetches a document. The shell’s protocol handler answers both.
A real, privileged origin (http://localhost:3000, registered standard + secure + supportFetchAPI)Cookies, fetch, secure-context APIs behaveKept. wry’s custom protocol serves at a real origin — <scheme>://localhost on macOS, iOS and Linux, http://<scheme>.localhost on Windows and Android — which is a secure context by host, so navigator.gpu and crypto.subtle are available. A document loaded from a string has a null origin and gets neither.
Intercept, never listenNothing is reachable from other processes or from a website pointed at localhostKept. No port, ever.
Adapt at the request boundary, keep the framework untouchedThe framework does not know it is in a shellKept, and thinner. Electron hands over a WHATWG Request; Next wants Node’s IncomingMessage, so the pattern ships a ~120-line stream adapter. wry and Tauri hand over an http::Request<Vec<u8>> and take an http::Response — the boundary Phaze’s Rust runtime already speaks.
Same process, so the server half calls platform APIs directlyFilesystem, keychain, devices, with types, no serializationKept — in Rust. The transport’s Rust handlers run in the shell process. There is no JavaScript server to grant that access to.
Run the Next server in the main process (require(resolve.sync('next')), Next as a runtime dependency, asar: false)Every page is dynamic on the user’s machineDropped. Documents are generated at build and embedded. Live data is a transport action. No server, no Node.
Force every page dynamic (export const dynamic = 'force-dynamic')Otherwise the page was pre-rendered on the developer’s machineInverted. Rendering is the build. What must be live at run time is data, and data has its own wire.
Bridge the two cookie jars by hand (read session.cookies into the request, write Set-Cookie back; sameSite unimplementable)Sessions survive interceptionDeleted. The shell owns identity, so it attaches the session at the boundary. No cookie is minted in the view.

The pattern’s remaining cost is structural, not a bug: it puts the RSC deserializer inside the desktop process. Every entry in the React2Shell catalogue — the CVSS 10.0 deserialization RCE, the recurring deserialization DoS chain, source-code exposure — is a fault in the server that pattern runs as the user, with the user’s filesystem. Phaze has no deserializer to run: the wire carries data, input.parse’d, never reconstructed.

The speed scorecard is three questions — what do you see, how quickly, how quickly can you use it — and a native shell answers each of them better than the web target can, because the network is gone and the “server” is memory.

How quickly you see it — the document is bytes in the binary

Every route is generated at build with renderToStringAsync, so its signal.async loaders resolve into the HTML, and embedded in the executable. The shell’s protocol handler answers the document request by returning a slice of that memory. The webview parses complete HTML and paints before any Phaze JavaScript runs — the same “server generates, browser renders” split as the edge, with the edge replaced by include_dir!.

How quickly you can use it — adopt, don't rebuild

Hydration is the binding system’s first tick: the sub-3 KB runtime walks the DOM the webview already built and attaches behaviour. Nothing is constructed twice. A client-rendered shell pays JavaScript to build the tree and then again to wire it; Phaze pays only the second half, and the JavaScript that does it arrived with the document.

Data — one copy, no parse

A transport action over the protocol handler returns Cow<'static, [u8]> — a borrow of embedded bytes or of a Rust buffer. A response: Flat<T> or response: Binary action is therefore one copy across the process boundary into WebKit, after which the compiled accessor reads fields in place, with zero shipped reader. JSON on the same path pays JSON.parse for nothing. The Rust runtime already emits application/x-flatbuf for Flat<T>; the shell is simply a third producer behind the runtime-neutral wire. In a native shell, anything numeric or large should be a FlatBuffer.

Navigation — from memory

The Phaze Router’s only dependency on a server is that fetch(url) returns a Phaze document. The handler answers from embedded bytes, and the router’s in-tab cache honours the CDN-Cache-Control the handler sets, so a revisited route never reaches the handler at all. Layouts persist, module signals survive, the view subtree swaps.

No JavaScript runtime in the shell

The process is the operating system’s webview plus Rust. Binary size, memory and startup follow. This is not a trick of the target; it is what falls out of Architecture C — endpoints are canonically Rust, and the one piece that was TypeScript-only, rendering, moved to build time.

Phaze is secure by design because it is isomorphic: there is no serialization boundary for a deserializer to sit on, and the page gate is not a separable layer. A native shell keeps all of that and removes four more surfaces that desktop frameworks carry.

SurfaceElectron + Next / Tauri commandsPhaze + Wry
A listening socketThe Next-in-Electron pattern avoids it; most Electron apps and every “local server” app do notNone. The protocol handler is reachable only from that webview.
A credential in the pageThe session is a cookie in the renderer’s jar, bridged into every request; script can read anything not HttpOnlyNone. The shell owns identity — a hardware-held key, a credentials file, a sealed channel — and attaches it at the boundary. Script injection in the view finds nothing to exfiltrate. Cookie persistence on a custom scheme is therefore not load-bearing.
An IPC command surfaceElectron: a preload bridge exposing ipcRenderer channels. Tauri: every #[tauri::command] is callable from the page, which is why a capability ACL existsNone to design. The view’s only capability is fetching URLs the app declared, and each one carries the gates it carries in production — auth= / verify= / csrf= / rate_limit= on a Rust handler, require: / use: on a fence. One permission model, not two.
A deserializer running as the userThe RSC server runs in the main process; its deserialization sink runs with the user’s filesystemNo deserializer. The wire is data, validated by input.parse; handlers are compiled Rust.
A null-origin documentA page loaded from a string has no origin: no crypto.subtle, no WebGPU, no storageA real origin. The compiled sign: verify and encrypt: decrypt run on native Web Crypto, exactly as in a browser.
A reachable set that growsAnything on the port, anything on any channelAn allowlist. The handler serves the route table and refuses everything else. Cloud-side actions are forwarded by the shell, with the shell’s credentials, so the view never crosses an origin and CORS never exists.

Post-quantum and at-rest crypto stay where the security model puts them — on the Rust runtime, which in this target is the shell process itself.

build run time (inside the binary)
───── ────────────────────────────
src/views/** ──renderToStringAsync──▶ dist/app/<route>/index.html ─┐
src/app.phaze │ include_dir!
client entry ──native({ shell })──▶ dist/app/assets/*.js.gz ─┤
▼
protocol handler app://localhost
├─ GET /<route> → embedded document (+ __PHAZE_CF__ seeded from a Rust loader)
├─ POST /transport/* → phaze Rust router, in-process, identity attached
├─ GET /api/* → same router (direct fences, `Flat<T>` bytes)
└─ anything else → refused
│
webview: parse → paint → hydrate → navigate

Three things to notice. The shell generates nothing at run time — rendering happened at build, so the handler is a byte server plus a dispatcher. The shell can still seed: the seed/render split means text was rendered at build while the __PHAZE_CF__ value can be produced when the document is served, so hydration adopts live local state without a fetch. And the transport half is the same Rust handlers that serve on workerd and on the hyper backend — the shell is a third backend behind one handler API, not a port of the app.

The build side is one plugin, the peer of cloudflare():

vite.config.ts
import { defineConfig } from 'vite'
import native from '@madenowhere/phaze-native/vite'
export default defineConfig({
plugins: [native({ shell: 'wry', compress: 'gzip' })],
})

The build is a served directory: every route generated as a document that references the entry as a file, the entry itself — runtime and views in one compact chunk, since a shell has no cache for a split to serve — and one chunk per import() site in the app. compress writes the scripts and stylesheets as .gz (or .br) for the shell to decode; on the first consumer that is 164.5 kB of assets embedded as 49.2 kB, decoded in under a millisecond at first paint. Every option is specified in the reference.

Development and shipping are one command each, from phaze-cli: phaze native dev runs the app’s dev server and the shell as a dev build with the server’s URL baked in, and phaze native build --bundles app,dmg runs the app’s build, cargo build --release, and the signed, verified bundle — see phaze-cli › The native shell.

The shell side is wry’s protocol builder. The handler serves the embedded output, decoding by suffix, and dispatches the transport paths to the in-process router:

let webview = WebViewBuilder::new()
.with_custom_protocol("app".into(), |_id, req| serve(req)) // documents raw; assets decoded by suffix; /transport/*, /api/*
.with_url("app://localhost") // a real origin — never `with_html`
.build(&window)?;

with_html is the wrong door for this target: it hands the document a null origin, and a null origin is not a secure context, so navigator.gpu and crypto.subtle are withheld. Serve the same bytes over the protocol and the document is a secure context. publish: true exists for the one case the string door fits — a static card that needs none of that — and adds a self-contained publish.html beside each served document without removing anything.

Two things the handler must do that a web server would not. It decodes: WebKit does not decode a scheme-handler body, so a Content-Encoding header on compressed bytes only delivers garbage to the parser — the handler gunzips and answers raw. And it serves the document with the entry as a file: the lazy chunks import the entry by filename, so an inlined entry would load twice and render twice.

  • Streams do not ride the protocol handler. wry’s and Tauri’s responders take a complete body, not a stream, so transport: sse and transport: ws fences cannot be answered by the handler. The firehose already has its wire — MoQ over WebTransport to a relay, where the shell process publishes and the webview subscribes exactly as a browser does. A low-rate host-to-view push is a future wire word, not a forced stream.
  • TypeScript-bodied fences do not run in the shell. There is no JavaScript server. In a native app an action is a Rust fence in-process, or a cloud fence the shell forwards with its own credentials.
  • The window drag is the one gesture that rides wry’s message channel. AppKit must run a drag on the very mousedown being dispatched, so it cannot be a fetch. drag() from @madenowhere/phaze-native/wry posts the one word drag; the shell answers it with performWindowDragWithEvent:. Nothing else rides that channel — no data, no commands — so the “no command surface” row above stays true.
  • A translucent window is wry’s transparent feature, and that feature is a private key. It sets WebKit’s non-public drawsBackground by KVC — what Tauri’s macOSPrivateApi names — which is fine for direct distribution and not for the Mac App Store. A store build is the /swift shell, whose transparency is public API; the reference has the trace and the measurements.
  • The router’s internal-link check knows the scheme. It accepts any link on the document’s own origin and scheme, which on the web is the http(s) check it always was, and under the shell’s custom scheme — a real origin in WebKit, measured — makes same-origin links internal. See the reference.
  • ssr={false} still means what it means. A WebGPU surface cannot be generated at build; it mounts fresh in the webview. Everything else, including sensor-driven behaviour, is in the document.
  • Content-Encoding is not honoured over the protocol. The bytes a handler hands the webview are final. Compression in the binary is the shell’s to undo, which is what compress plus the handler’s decode are; the trade between gzip and brotli is measured in the reference.
  • Phaze Native — the package: native(), the runtime, the shell bindings, the handler contract.
  • Phaze is secure by design — the React surface catalogue this target inherits none of.
  • It’s all about speed — the scorecard the Performance section applies.
  • Phaze + Rust — the handlers the shell dispatches to, and the phaze-native crate, the hyper backend for the same handler API.
  • Flat Buffers & SSR — the seed/render split and the data-class tier map.