Skip to content

5b. phaze-native

1. phaze-tsplugin ← editor (TS Language Service)
2. phaze-compile ← build-time AST rewriting
3. phaze-vite ← island HMR + chunking helpers
4. phaze-astro ← Astro integration (island model)
5. phaze-cloudflare ← native Cloudflare Workers adapter (whole-page)
5b. phaze-native ← native-shell adapter (whole-page; wry / Tauri / SwiftUI WebView) ← you are here

@madenowhere/phaze-native is the native-shell adapter — the third occupant of the host-adapter slot, beside phaze-astro (#4) and phaze-cloudflare (#5). You install it instead of them when the app is shown in a webview by a native shell — bare wry, Tauri, or the SwiftUI WebView of macOS 26 — its views served by the app’s own worker, the ones marked artifact: true shipped in the executable and served by the shell itself. Like #5 it is whole-page: the document arrives with the view already rendered and the webview hydrates it as one reactive tree. And like every adapter it is build-time only: nothing here grows the sub-3 KB phaze runtime chunk.

The surface — the options, the emitted output, the origin contract, the shell’s duties, transport in a shell — is the Phaze Native reference, and the reasoning is Phaze + Wry. This page is the tooling view: what the package is, how it plugs into Vite, and the phaze-cli commands that drive the shell’s dev loop, build and bundle.

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

native() returns cloudflare()’s plugins — phaze-compile with the same Oxc JSX configuration, .phaze resolution and PUBLIC_* env prefix the web target uses, and the web target’s own plugin — followed by one plugin of its own for the shell’s side. You don’t add phazeVite(), for the reason #5 doesn’t: whole-page hydration has no per-island boundary for its replace() HMR. Unlike #5 the plugin applies no phazeChunks() of its own unless you ask for it with chunks: the chunk layout is the consumer’s, declared in the config’s own codeSplitting.groups exactly as a web consumer declares it — the runtime claimed by a priority group, each library named into its own chunk — so every library’s cost stays a readable line and the runtime stays measurable as its own file.

The plugin composes #5 over src/views (a native app’s routes are views — ---view, a page without the device variants — and a src/pages/ is refused by name) and owns only the shell’s side: the client’s JavaScript target, the api dev proxy, the artifact list, the shipped dist/app, compress, and the download sizes. Discovery, the entries, the app-config conventions, the virtual modules, the worker, the prerender pass and the dev server are #5’s, composed.

native() returns cloudflare()’s plugins over the views root, plus one of its own for the shell’s side. So a native app’s build is vite build --app, the web target’s, whole:

  1. #5’s two environments. The client bundle to dist/client/assets/*, and the app’s worker to dist/server/index.js. native() hands cloudflare() the views root with the basenames index / view / modal, the app’s registries, content, env and auth files, router, and the artifact list as the worker’s prerender set.
  2. #5’s prerender pass, through the built worker in Miniflare with the wrangler.jsonc bindings: every artifact: true view and every prerender: true view is rendered to dist/client/<route>/index.html, the guard before the stream included — a restricted view is not an artifact: the guard returns 401, the pass skips the response, and the view ships no document.
  3. The shipped directory, assembled by native() inside the build’s afterPrerender: dist/app holds the artifact documents, a copy of assets/ and phaze-env.json — the public env the shell reads with PhazeEnv — and nothing the worker keeps for itself — a served view is never in the app. With compress, each shipped script and stylesheet becomes <name>.gz or <name>.br for the shell to decode; with artifacts, the build ends with the sizes a person downloads.

Nothing of the web target’s build is re-implemented here, and nothing that runs in the webview is either: the boot, the router, go and the transport client are phaze-cloudflare’s. What native() owns is the shell’s side alone — the client’s JavaScript target for the shell’s engine, the api dev proxy, the refusal of the web’s fences and route root under the views, the artifact list, dist/app, compress, the download sizes, the native config key. A native app installs this package and names only it: the app-config entries /auth, /env, /transport, /actions and /content re-export the web target’s helpers under this package’s name, the compiler knows this package’s names for go and the /actions surface as it knows the web target’s, and phaze-cloudflare is the dependency native() composes, never one the app imports.

With cloudflare set, the worker is the app’s to deploy — wrangler.jsonc beside the config, wrangler deploy — and a view without artifact: true is served by it. Without it, every view must be an artifact; the worker is still built and rendered through, never deployed, and the app needs only a wrangler.jsonc naming it.

vite in serve mode is #5’s dev: the app’s worker runs in Miniflare’s module runner with the bindings its wrangler.jsonc names, so every view — artifact or not — is rendered per request with the guard before the stream, and the client is served by the dev server with HMR. The document’s origin is then the dev server’s http://localhost, which the router accepts as it always has. A webview pointed at it, or a browser, receives the same document the worker will serve, and for an artifact the same document the shell will.

Pointing the shell at it is a build, not a switch. The model is Tauri’s, read from its source: the embedded app is a cargo feature of the shell crate, embed, on by default. A build without it is a dev build — include_dir! is skipped, so the app’s build output need not exist, and the webview loads the dev server URL that the crate’s build.rs bakes in from PHAZE_NATIVE_DEV_URL. Baked at compile time, so a release binary has no dev URL to be pointed at, and a dev build without one does not compile. A bare cargo run still embeds, as it always did.

phaze native dev is the one command that runs it. It starts the app’s dev server in its own process group, refuses to start when the dev URL is already answered by something else (another dev server would satisfy the wait and put the wrong app in the window), waits for the port, then runs cargo run --no-default-features with the URL set, rebuilds and relaunches the shell when its sources change, and stops both on exit or Ctrl-C. In the app’s package.json, named for the shell it runs — a bare dev would be ambiguous the day a second shell arrives, so the manifest’s dev-command and build-command name Vite directly instead of a script:

"wry:dev": "phaze native dev",
"wry:build": "phaze native build --bundles app,dmg"

Two things the app’s Vite config owns: its port, bound with strictPort so a taken port fails out loud instead of moving, and the matching dev-url in the shell crate’s [package.metadata.native]. Another dev server on the same machine, the web target’s on 5173, is exactly why.

What it buys: while iterating on views, styles or a scene there is no build, no embed, no bundle and no signing. An edit swaps in place in the webview — the dev entry is the HMR boundary, as on the web target: an edit under src/app.phaze re-renders the root, a view edit swaps the view — and the binary’s staleness stops mattering because the view comes from the dev server. List the app’s stylesheet in devStylesheets so a first load paints styled: Vite injects imported CSS from JavaScript. What it does not replace: dist/app is what ships, so phaze native build remains the proof before a commit, and a signed bundle remains the only shape for anything the OS keychain gates. It is the web target’s own split: vite to iterate, the real host to prove.

The SwiftUI shell. No cargo, so no feature: the shell reads PHAZE_NATIVE_DEV_URL from its environment at launch and loads that instead of <scheme>://localhost/. The same phaze native dev runs it, from a native.toml beside its Package.swift carrying the same table — Vite on the shell’s strict port with the shell’s config (src-swift/vite.config.ts, the app’s config with native: { shell: 'swift' }), the port refusal and the wait, swift build, the product launched with the variable set, and the rebuild-and-relaunch when Sources/ changes; phaze native build --run writes dist/app through the same config, builds the release product and launches it, which is the built-app test. Transport under the dev server is the plugin’s api: the view’s relative /transport/* calls are proxied to the API’s dev server, the way cloudflare({ rustApi }) proxies a web app’s Rust half, and the built app forwards them from the shell.

A wry shell ships as a signed .app — a Swift shell too, its binary handed to bundle --binary (design, not shipped) — and that configuration has two halves in one file: [package.metadata.native] in the shell crate’s Cargo.toml, cargo’s per-tool table, the one cargo-bundle uses. The app’s identity, product, bundle-id, team, the Info.plist and entitlements files; and the signing pairs, an identity and the provisioning profile it was issued with, one for development and one for Developer ID. Neither value is a secret, the identity is a certificate’s name and the private key stays in the keychain, and another Mac overrides them with APPLE_SIGNING_IDENTITY and PHAZE_NATIVE_PROFILE. The full table is on the phaze-cli page.

phaze native build runs the app’s build, cargo build --release, then the bundle: the .app under target/release/bundle/macos/, the plist stamped with the bundle id, executable and version from the manifest, a refusal to sign unless the entitlements claim exactly <team>.<bundle-id>, codesign with the hardened runtime, codesign --verify --strict, and with --bundles app,dmg a signed <product>_<version>_<arch>.dmg. With Tauri’s APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID, or APPLE_API_KEY / APPLE_API_ISSUER, set, the app is notarized and stapled before the dmg is made. phaze native bundle does the last part alone for a binary already built.

The shell’s build.rs carries two lines of its own: it tracks the app’s build directory when embedding, so a rebuilt app recompiles the shell without a touch, and it fails the build when a .br asset meets a binary without the brotli decoder, instead of a 500 at runtime.

sizeReport() reads the plugin’s api — shell, compress, artifacts, dir (the shipped dist/app) and ready (set once that directory is assembled) — and switches columns: served, what the webview parses, against embedded, what the binary carries, both read from the shipped directory rather than the in-memory bundle, in a post-ordered closeBundle of the environment that completes the build. The generated HTML joins the tree as app, one row per route, and the totals are first paint per delivery and what the binary embeds in all, then the download. A shell has no wire, so the web columns would answer the wrong question.

  • A library’s conditional chunks. An import() behind a runtime condition the app never turns on is a chunk a shell would embed for nothing. Detecting that from the app’s source and stripping the site is the library’s shaker’s job, the way a WGSL feature is stripped; native() has no declaration for it, because the source already says which conditions are on.
  • The shell’s duties. Serving the artifact views and decoding the assets by suffix, a loud 404, the exact-origin CORS header, the navigation allowlist, the forwards with the shell’s credentials — transport to the API, a served view to the app’s worker. They are Rust or Swift, in the shell — the Swift ones ship in the Phaze package at phaze/swift, whose PhazeRouter serves dist/app and forwards the rest; the reference lists them.
  • The shell’s dev loop, build and bundle. Those are phaze-cli’s native commands, not the Vite plugin: the plugin ends at dist/app, and a native app’s top-level build is cargo or swift build.
  • Compression policy per shell. compress applies to wry. Under shell: 'tauri' it is ignored with a warning, because Tauri’s codegen compresses frontendDist itself. The SwiftUI shell decodes in its URLSchemeHandler (design).
  • A Swift shell’s build. phaze native build drives cargo; a SwiftUI-shell binary is handed to phaze native bundle --binary for the signed .app (design, not shipped).

Same reasoning as the rest of the stack: different target, its own side. A native app has a shell, a shipped directory and a client engine a Workers app never has, and a Workers app installs no webview shell. Everything else a native app needs is the web target’s — the worker that serves its views, the render, the prerender pass, the dev server, the router, the boot, the transport client, route discovery — and phaze-native composes phaze-cloudflare for all of it rather than copying any of it, so there is one worker, one render and one router, never two. The package boundary is the address: a native app names @madenowhere/phaze-native and nothing else, and what is the web target’s reaches it through that name.

  • Phaze Native — the reference: every option, the emitted output, the origin contract, the shell’s duties, transport in a shell, /wry.
  • phaze-cli › The native shell — phaze native dev, build, bundle, info, and the [package.metadata.native] table they read.
  • Phaze + Wry — why a Phaze app in a native shell is faster and safer than a bundled browser carrying a JavaScript server.