Phaze Native
Phaze Native is a target, the way Cloudflare Workers is a target: it decides what the build emits, what runs in the document, and who serves it. A native app’s routes are views — src/views/, the ---view fence — a page without the device variants, because the app is the device; everything else a page declares, a view declares. The document is shown by a native shell — wry bare, Tauri on top of it, or the SwiftUI WebView of macOS 26, the Mac App Store shell — and a view marked artifact: true is rendered at build and served by that shell from bytes in the app itself, over the shell’s own protocol, instead of requested from the server. Components, the router, and transport are unchanged; what changes is where a document comes from, and that dispatch happens in-process, in Rust.
The reasoning — why this is faster and safer than a bundled browser carrying a JavaScript server — is Phaze + Wry. This page is the surface.
The package
Section titled “The package”@madenowhere/phaze-native RUNTIME — the boot, the router, `go`, the transport client (what runs in the webview)@madenowhere/phaze-native/wry RUNTIME — the bare-wry shell binding@madenowhere/phaze-native/tauri RUNTIME — the Tauri shell binding@madenowhere/phaze-native/swift RUNTIME — the SwiftUI WebView shell (macOS 26 — the Mac App Store shell); its shell side is the Swift package below@madenowhere/phaze-native/auth APP — `Auth()`; likewise /env `Env()`, /transport, /actions `useAction` · `ActionError`, /content `newCollection()`@madenowhere/phaze-native/vite BUILD — native({ … }); the only build exportphaze/swift — the Swift package `Phaze` SHELL — `PhazeRouter(dist:scheme:api:views:)`: the scheme handler, the drag, the forwards to the API and to the app's workerThe split mirrors @madenowhere/phaze-cloudflare exactly: /vite is how to build it and the package proper is what runs. native() emits the client entry that imports the router from the runtime; the router lives in the package, not in the plugin. An app names one package: its src/auth.ts, src/env.ts, src/transport.ts and src/content.config.ts import from the app-config entries, which re-export the web target’s helpers under this package’s name, and the compiler knows this name for go and the /actions surface as it knows the web target’s — so a component, a fence or a config moves between the two targets by its import’s package name and nothing else.
native rather than desktop because Tauri 2 targets desktop and mobile, and because it parallels cloudflare: a native target beside a cloud target. The subpath for the bare webview is /wry, not /tao — tao is the windowing and event loop, wry is the webview, and Phaze binds to the webview.
native() — the build plugin
Section titled “native() — the build plugin”import { defineConfig } from 'vite'import native from '@madenowhere/phaze-native/vite'
export default defineConfig({ plugins: [native({ shell: 'wry', compress: 'gzip' })],})native() occupies the slot cloudflare() occupies: the target’s Vite build plugin. It composes cloudflare() over the views root — one compiler, one client bundle, one worker, one render — and adds the shell’s side around it. The build is the web target’s, vite build --app: the client bundle to dist/client, the app’s worker to dist/server, then the prerender pass through that worker in Miniflare, which writes the artifact: true views’ documents beside the prerender: true ones. What native() adds is what the shell needs and the web has no use for: the client’s JavaScript target set to the shell’s engine, the refusal of the web’s fences under the views root, the artifact list handed to the worker build as its prerender set, the assembly of the shipped directory dist/app — the artifact documents and a copy of the assets, nothing the worker keeps for itself — compress, and the download sizes. An app installs this package and names only it; phaze-cloudflare is the dependency native() composes, never one the app imports.
The client bundle’s layout is the consumer’s, exactly as on the web target. The plugin adds no codeSplitting of its own, so the consumer’s build.rolldownOptions.output.codeSplitting merges through: the phaze runtime claimed by a priority group through phazeChunks(), each library named into its own chunk, the app’s components into theirs. That is what keeps every library’s cost a readable line in the size report and the sub-3 KB runtime measurable as its own file, in a shell as much as on a CDN. The client entry is the web target’s own: each view its own chunk, entered by import() and preloaded as on the web, since the shipped assets/ carries every chunk the worker’s does.
| Option | Type | What it does | Status |
|---|---|---|---|
shell | 'wry' | 'tauri' | 'swift' | Which shell serves the output, and therefore which origin contract the build assumes (below). Sets the JavaScript target to the shell’s engine — WebKit for wry and the SwiftUI shell on Apple platforms, WebView2’s Chromium for Tauri on Windows — unless build.target is set, and decides whether compress applies. Required — here, or under the config’s native key (below). The shell’s side is the /wry · /tauri · /swift section below. | shipped |
compress | 'gzip' | 'brotli' | wry today; the SwiftUI shell once its handler decodes (design). After the documents are written, each assets/*.js and *.css is replaced by <name>.gz or <name>.br — the static-precompression convention, so the directory says which decoder the shell needs. The shell decodes before responding and never sets Content-Encoding: WebKit does not decode a scheme-handler body (measured — a br body reached the parser as raw bytes). Documents stay raw. Ignored under shell: 'tauri' with a warning, because Tauri’s codegen compresses frontendDist itself and decompresses on read. | shipped |
chunks | PhazeChunkOptions | false | Convenience only: phazeChunks() rules applied by the plugin as codeSplitting.groups. Unset, the plugin adds none and the consumer’s own codeSplitting is the layout, which is how the web consumers do it too. | shipped |
views · outDir | string | The views root, default src/views: the web target’s convention whole, with the basenames index.phaze · view.phaze · modal.phaze — (group)/, a @name/ slot composed over the main its URL matches, a modal.phaze slot with a document of its own, a +name/ folder or a view’s restricted, status views riding the client entry. Every one of them opens with ---view, the modal included (its basename makes it a modal). A src/pages/ directory, or a ---page / ---modal fence under the root, is an error naming the move: a native app’s routes are views. Output root, default dist. | shipped |
artifact: true (a view’s label) | in the ---view fence | The view is rendered at build — through the app’s worker, as a prerender: true page is — and shipped in the app, served by the shell from dist/app/<route>/index.html instead of requested from the server: the per-view choice of how many requests reach the server. A restricted view is not an artifact: at build the guard returns 401, the prerender pass skips the response, and the view ships no document. Without the label the view is the server’s, with everything a page has: the loader per request, the pre-stream guard, prerender, revalidate, headers, speculate. artifact with prerender: true or revalidate is a compile error — a baked file is in one place and has no response — the same reason the web target refuses prerender with revalidate. A dynamic segment cannot be an artifact: it has no request to name its values. | shipped |
cloudflare | NativeWorkerOptions | The app’s worker, under cloudflare()’s own option names — cache, headers, speculationRules, sitemap, site, isomorphicAuth, login, prerender, experimental, rustApi, middleware, fingerprint. Present, even as {}, the app has a worker to deploy: dist/server/index.js beside wrangler.jsonc, and a view without artifact: true is served by it. Absent, the app is artifacts only — a served view is a build error naming the choice — and the worker the build still renders through is never deployed. isomorphicAuth defaults to true here: an app has no redirect surface, so a signed-out request to a served view renders 401.phaze in place. | shipped |
artifacts | Record<string, string> | The shell’s artifacts, label → path relative to the Vite root — the binary, the .app, the .dmg — so the build ends with the sizes a person downloads, not only what it embeds. A directory is measured as the sum of its files. Vite runs before the shell is rebuilt, so each row carries the artifact’s build time, and one older than this build says so. Rendered as the size report’s last lines when a report is in the config, else printed by the plugin. | shipped |
devStylesheets | string[] | Dev server only. Root-relative stylesheet URLs linked as blocking <link rel="stylesheet"> in each rendered document — the option cloudflare() has. Vite injects imported CSS from JavaScript, so without it the document paints unstyled until the entry runs. No effect on builds, whose documents link the manifest’s CSS. | shipped |
router | boolean | Client-side routing between the documents — the web target’s router, started after the boot, so an internal link, a go() or the shell’s own navigation swaps the view inside the persistent app instead of loading a new document. Off by default, as cloudflare({ router }) is: a single-view shell ships none of it. | shipped |
actions | string | string[] | The transport registry — a transport.phaze or actions module — as cloudflare({ actions }) takes it. The compiler reads it so each actions.X() lowers to the call its fence declares, and phaze:transport is the client the web target’s pages use; the fences run wherever that registry’s dispatcher runs. May be another app’s, or a list: this app’s own file first, then the shared registry the same worker dispatches beside it, the way the dash names its web app’s by path. A group declared in two files is a build error. | shipped |
content | string | false | The content-collections config, as cloudflare({ content }) takes it — src/content.config.ts by convention, false opts out. phaze:content serves the render its collections, with patterns resolved against the project root that config sits in, so it too may be another app’s; the browser build gets the throwing stub. | shipped |
env | string | false | The env schema, as cloudflare({ env }) takes it — src/env.ts by convention. Its public bucket is inlined into the client from the app’s .env files at build, exactly as on the web target, and env.dev / env.prod are the build mode; its private bucket is the app’s worker’s, read by a view’s loader and by the fences the worker dispatches, and the throwing stub in the client. A document shipped in the app carries only what the client bundle carries. The shell reads the same public values: the build writes them into the shipped directory as phaze-env.json, named as env.public names them, and the Swift package’s PhazeEnv(dist:) reads them there as env.public.X. Nothing private is ever in it. false opts out. | shipped |
auth | string | false | The auth config, as cloudflare({ auth }) takes it — src/auth.ts by convention, false opts out. What this target reads from it is the Auth({ fingerprint }) declaration, handed to the compiler as on the web: undeclared, every fingerprint() compiles to a resolved null and no measurement chunk exists. A native app’s device signal is the shell’s, attached at the boundary, not the view’s. | shipped |
api | string | The origin whose worker dispatches the actions registry — that app’s dev server, 'http://localhost:5173'. Dev server only: Vite proxies the dispatcher’s namespace, /transport/*, and each direct fence’s path there, the shape cloudflare({ rustApi }) gives the Rust half of a web app, so the view’s calls stay relative and the API sees this app’s own origin. No effect on builds: a shipped document’s shell forwards to its origin. | shipped |
staticSubtreeHoist · appPhazePath · distributedGlobals | as in cloudflare() | Compile-level options that do not depend on the target; threaded to phazeCompile() unchanged. | shipped |
One app, several shells — the native config key. The options that belong to a shell rather than to the app — shell, compress, artifacts, the three the size report reads — can sit under a top-level native key of the Vite config instead of in native()’s argument, the way Vitest reads test. An app served by more than one shell keeps one config and gives each shell a config that extends it — src-tauri/vite.config.ts is mergeConfig(app, { native: { shell: 'tauri' } }), src-swift/vite.config.ts the same with 'swift' — and each shell’s launcher runs Vite with its own. A value there wins over the same option passed to native(); no shell anywhere and the build refuses.
compress, measured. On the first consumer’s ten served assets, 164.5 kB raw:
| gzip, level 9 | brotli, default quality | |
|---|---|---|
| embedded bytes | 50,332 (30%) | 44,654 (27%) |
| decoder the shell links | a DEFLATE decoder it usually has already (flate2) | brotli-decompressor, +165 kB of binary — its 122,784-byte static dictionary plus code |
| entry decode, 80.7 kB out | 218 µs | 380 µs |
Brotli packs about eleven percent tighter, so its decoder repays itself only once the embedded assets pass roughly 1.5 MB gzip’d. Below that, gzip. Node’s brotli at its default quality is the same call the size report measures with, so the report’s br: column is what would land on disk.
What the plugin does not emit. Nothing about a library’s conditional chunks: an import() behind a runtime condition the app never turns on is the library shaker’s job — a transform-stage region strip driven by the app’s source, the way a WGSL feature is — so the bundler never emits the chunk and the binary never embeds it. native() has no declaration for it, because the source already says which conditions are on. And no render of its own: an artifact is the web target’s prerender of that view, run through the app’s built worker, so its loader and head run once, at build, with the worker’s bindings, and what they read is pinned to the build. Live values belong to transport actions.
The emitted output
Section titled “The emitted output”dist/├── client/ ← the worker's static assets: every prerendered view + the bundle (cloudflare()'s)├── server/index.js ← the app's worker (cloudflare()'s) — deployed with `cloudflare`, else only rendered through└── app/ ← what the shell serves: the artifact: true views and a copy of the assets ├── index.html ← GET / — the entry referenced as a file ├── phaze-env.json ← the public env, for the shell: env.public's values from .env / .env.production └── assets/ ├── phaze-router-<hash>.js.gz ← the entry: runtime + views + layouts (.gz / .br with compress) ├── phaze-router-<hash>.css.gz └── <engine>-<hash>.js.gz ← one per import() site in the appA served view — news/view.phaze with prerender: true, say — is in dist/client for the worker and not in dist/app: the shell requests it from the worker, so the app never carries a copy that goes stale.
Each document carries the same three things a web document carries: #__phaze_root__, the inline window.__PHAZE_CF__ payload, and the <title>. That is what the router extracts on a navigation and what startClient adopts on boot, so the router and the boot path work on a native document unchanged.
The document references the entry as a file, never inlined, and this is not a style choice. Every lazy chunk imports the entry by filename — an engine chunk’s import of the runtime resolves to ./phaze-router-<hash>.js — so a document that inlined the entry and also served the directory would evaluate the entry twice, as two module instances, and render twice. Measured as the card drawn twice.
The origin contract
Section titled “The origin contract”A native document has an origin, and the origin is the shell’s. This is the one place the target reaches into the runtime.
| Shell | Origin on macOS / iOS / Linux | Origin on Windows / Android | Source |
|---|---|---|---|
wry, with_custom_protocol("app", …) + with_url("app://localhost") | app://localhost | http://app.localhost (https by opt-in) | wry’s with_custom_protocol docs |
Tauri, frontendDist served by its protocol | tauri://localhost | http://tauri.localhost (https by opt-in) | tauri::manager::webview |
SwiftUI WebView, WebPage.Configuration.urlSchemeHandlers → <scheme>://localhost | nk://localhost — a secure context, measured 2026-09-12 (WebGPU available) | n/a — macOS and iOS only | the macOS 26 SDK’s WebKit Swift interface |
wry, with_html(string) — a document loaded as a string | null | null | wry’s with_html docs |
Two consequences follow.
A string-loaded document is not a secure context. With a null origin the document gets no navigator.gpu, no crypto.subtle, no storage, and no relative URLs. The compiled sign: verifier and encrypt: decryptor are crypto.subtle calls, so a with_html document cannot open a signed or sealed response at all. Anything that needs a secure context needs the protocol form. A string-loaded document still hydrates: the payload carries the route’s pathname, and the boot reads it before location, which under a null origin says nothing useful.
The router accepts the scheme. The web router’s internal-link check used to accept only http: and https:; on Apple platforms the shell’s scheme is the custom one, so every click would have fallen through to a full document load and lost layout persistence. Measured in WebKit, a document served at nk://localhost has that as its real origin, a relative link resolves to the same origin, and a link on another scheme reads as null. So the check is now same origin, same scheme, and a real origin — on the web exactly the http(s) check it always was, and under a shell the same-origin links are internal. It is one check in the web router, not a native copy of it. Everything else in the navigation lifecycle — the fetch, the #__phaze_root__ extraction, the signal-driven swap, the in-tab cache — is the same code path answering a request the shell serves.
The runtime
Section titled “The runtime”@madenowhere/phaze-native is what runs inside the webview, and it is not a second implementation. The package root re-exports the web target’s own client: the boot, startClient and currentRoute, because a native document carries the same root, payload and title a Worker’s does, so the same code adopts it; the router, with router: true; go, which the compiler strips at every call site under this package’s name as it does under the web target’s; and the transport client behind phaze:transport. Those are @madenowhere/phaze-cloudflare’s runtime entries — /client, /router, /action-client, /runtime — together with its route discovery, /routes. The router needs no native copy: its one scheme check is widened in place (above).
Everything build-side is the web target’s, composed: the worker, the render of a view inside its layout with its @name/ slots and a modal.phaze as a document of its own, the src/env.ts · src/auth.ts · src/content.config.ts conventions and the phaze:env, phaze:content and phaze:transport modules they feed, the prerender pass, the dev server. Nothing of it is duplicated in this package; what this package owns is the shell’s side. So a native app installs this package and names only it — phaze-cloudflare is the dependency native() composes, never one the app imports — and every mechanism a page has on the web is the same code on a view.
Restricted views
Section titled “Restricted views”A restricted view, declared with restricted in its ---view fence or by a +name/ folder, is guarded exactly as a web page is: the app’s worker runs the pre-stream guard on each request and renders 401.phaze in place when signed out, 403.phaze on a missing role, and 404 under notFound (Router → Authorization). native() sets isomorphicAuth: true, so no signed redirect is issued. After a sign-in the 401 view requests its URL again with go.replace(location.pathname), and the guard returns the view, as on the web. A restricted view is not an artifact: at build the guard returns 401, the prerender pass skips a non-OK response with its warning, and the view ships no document.
The shell subpaths carry only what a view cannot get from the document or a fetch:
/wry— the bare shell. One export,drag(options?): it makes a region a window-drag handle. WKWebView swallows the mouse, and AppKit only drags when a click reaches a view that does not handle it, so on a primary-button press inside the region the view posts the one worddragoverwindow.ipc.postMessage— the frozen one-method object wry installs before any script in the view — and the shell’s IPC handler runs the real drag with the event being dispatched (performWindowDragWithEvent:), screen-edge behaviour included. The region defaults to[data-tauri-drag-region], matched withclosestso a region’s descendants drag too, which is the attribute Tauri’s shell honours by itself, so markup moves between shells; anything under[data-no-drag]opts out. That word is a gesture, not data: nothing else rides the message channel, there is no general post, and the security table’s “no command surface” holds. Nothing arrives from an initialization script either — the document carries its payload and the origin islocation’s — so there is no such contract to state./tauri— deferred. Tauri serves a real origin and runs its own drag regions, so a Tauri document needs nothing from the runtime today; what the subpath would carry is the bridge to Tauri’s host→view channel for the push case below, which is not part of this surface yet./swift— the SwiftUIWebViewshell of macOS 26, the one a Mac App Store build uses (the aside below says why). Its shell side is the Swift packagePhaze(phaze/swift):PhazeRouter(dist:scheme:api:views:)builds theWebPageaWebViewshows — the scheme handler servingdist/app, the forwards to the API and to the app’s worker, and the drag. The drag is the same gesture as/wry’s, the one word posted overwindow.webkit.messageHandlers.drag—WebPage.Configuration.userContentControlleris the publicWKUserContentController— and answered with the publicperformWindowDragWithEvent:(performDrag(with:)) and the event being dispatched; the router injects the view side as aWKUserScript, so a view imports nothing — the script and its handler live in the client content world, so the view’s own scripts can neither post the word nor alter the listener, and a message from any frame but the main one is ignored — and the same region attribute holds, so markup moves between the three shells.phaze.pathis the route as observable state: it follows every navigation the view’s router makes, and setting it navigates that router, so aListselection bound to it both shows and drives the view. The view navigates only within its own origin and the origins the app names inallow(its dev server): a link anywhere else opens in the user’s browser, any other navigation there is refused — the allowlist the wry shell keeps with its navigation handler, since the view is the app and not a browser. Nothing else rides the message channel, for the reason/wrygives.
The shell’s duties
Section titled “The shell’s duties”The shell registers one protocol handler. wry gives it an http::Request<Vec<u8>> and takes an http::Response<Cow<'static, [u8]>>; Tauri’s register_uri_scheme_protocol is the same shape; the SwiftUI shell’s URLSchemeHandler takes a URLRequest and replies with a response event then data events, the same shape in Swift. The handler has five jobs, and no more:
| Request | Handler | Notes |
|---|---|---|
GET /<route> of an artifact view | serve the document from the app | dist/app/<route>/index.html — include_dir! bytes in a Rust shell, the bundle resource in the Swift one — raw; set CDN-Cache-Control so the router’s in-tab cache serves revisits from memory |
GET /assets/* | serve the chunk from the app, decoded | look the path up raw, then <path>.gz, then <path>.br; decode by suffix and respond with the bytes and a content type, never a Content-Encoding. The whole decode for a first paint is under a millisecond. A .br in a binary without a brotli decoder should fail loudly, not serve the compressed bytes |
POST /transport/action/*, GET /transport/stream/*, GET /api/* | dispatch in-process, or forward the request to the API | in-process: the same #[get] / #[post] handlers that serve on workerd and hyper — a third backend behind one handler API (design). Forwarded: the SwiftUI shell’s APIForward sends the request to the API origin with the shell’s own cookies and serves the API’s response to the webview as its bytes arrive (shipped) |
GET /<route> of a served view, or an asset the app does not carry | forward the request to the app’s worker | the SwiftUI shell’s PhazeRouter(views:), the same forward pointed at the worker’s origin: the request goes out with the shell’s session cookies, so the worker’s guard runs on the app’s session, and the worker’s response is served to the webview (shipped). With no worker origin configured, a 404 |
| anything else | refuse | the route table is the allowlist |
Identity is attached at the boundary. The shell owns whatever proves who the user is — a hardware-held key, a credentials file, the cookies a cloud origin set — and attaches it to the request before dispatch or forward. No cookie is minted in the view, so nothing in the view can be read out, and cookie behaviour on a custom scheme is not load-bearing. Cloud-side actions the app declares are forwarded by the shell with the shell’s credentials; the view never crosses an origin.
The SwiftUI shell does this today. PhazeRouter(dist:scheme:api:views:) forwards /transport/* and /api/* through its APIForward, whose URLSession keeps the session cookie the API sets on sign-in — the process’s own store, sandboxed, expiry and Secure honoured by the system — and sends it on every later forward; Set-Cookie and Content-Encoding are dropped from what reaches the view, and the exact view origin goes on as Access-Control-Allow-Origin. So the app’s @global session resolves to the same user in the shell as in a browser, and every fence works unchanged, sign: and encrypt: included, since the view’s compiled arrows never learn the call left the process. Measured on the dash against its API’s dev tunnel: the sign-in code, the login, the session, a transport: sse stream and the sign-out.
Seed at serve. Rendering happened at build, but the seed/render split leaves the value free: when serving a route the handler may run a Rust loader and splice the result into the document’s window.__PHAZE_CF__ before returning it. Hydration then adopts live local state with no fetch and no flash — render at build, seed at serve.
Where the in-process Rust lives — in the app, or in the phaze-native crate as a webview backend beside workerd and hyper — is open.
Transport in a native shell
Section titled “Transport in a native shell”The wire contract is runtime-neutral, so the compiled client arrow does not know it is talking to a shell. What changes is which fences can answer.
| Fence | In the shell | Why |
|---|---|---|
lang: rust body, or rust: pointing at an in-process handler | in-process | dispatched by the handler to the Rust router; a direct fence is a same-origin GET on its rust: path, which the handler serves |
response: Flat<T> / response: Binary | in-process, one copy | the handler returns a borrow of Rust memory; the accessor reads fields in place with zero shipped reader. Prefer this for anything numeric or large |
a TypeScript-bodied fence (use: / mint: / tags: steps, a TS handler) | not in the shell | there is no JavaScript server; declare it as a Rust fence, or as a cloud fence the shell forwards |
| a cloud fence behind the shell’s forward | forwarded | the SwiftUI shell carries the view’s relative call to the API origin with its cookies (above); the view does not know the call left the process. This is how an app whose API is a Workers app runs in a shell — the dash’s fences are its web app’s, TypeScript bodies and all |
transport: sse | through the forward on the SwiftUI shell; not over wry’s or Tauri’s protocol | the Swift handler replies in data events as they arrive, so a stream’s frames reach the view as frames (measured: the dash’s live session line); wry’s and Tauri’s responders take a complete body |
transport: ws | not over the protocol | see the push case below |
transport: moq | as in the browser | the shell process publishes to the relay over WebTransport; the webview subscribes with the same s.bind recipe |
The push case. A native app has one push shape a browser never needs: host → view, low rate, in-process — device state, a progress line, a status change. That is neither a stream fence nor IPC-by-hand; it is a future wire word on a stream fence, compiled to the shell’s own channel the way moq compiles to WebTransport, and it is not part of this surface yet.
Development
Section titled “Development”Dev is cloudflare()’s dev, composed: 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 gets Vite’s HMR, at an http://localhost origin the router already accepts. Pointing the shell at it is a build, not a switch: the embedded app is a default-on cargo feature of the shell crate, embed, and a build without it skips include_dir! and the protocol handler and loads the dev server URL its build.rs baked 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. phaze native dev runs it: the app’s dev server, the port wait, cargo run --no-default-features, and the teardown of both. Edits apply in the webview with nothing rebuilt — the dev entry is the HMR boundary, as on the web target — and devStylesheets links the app’s stylesheet so the document paints styled before the entry runs; dist/app stays what ships, so phaze native build remains the proof before a commit. The tooling page has the reasoning; the wry shell runs it.
The SwiftUI shell has no cargo feature to switch, so it reads the URL from its environment at launch: with PHAZE_NATIVE_DEV_URL set it loads that instead of <scheme>://localhost/. The same phaze native dev drives it from a native.toml beside its Package.swift — Vite on the shell’s own strict port with the shell’s config, the wait, swift build, the product launched with the variable set, and a relaunch when its Sources/ change — and phaze native build --run is the built-app test. Transport under the dev server is api: Vite proxies the dispatcher’s namespace and each direct fence’s path to the API’s dev server, so the view’s relative calls answer there. The built app forwards from the shell instead — transport through PhazeRouter’s api, and its served views through PhazeRouter’s views, the app’s own worker. Both origins are declared in the app’s src/env.ts under public, valued in .env for a development build and .env.production for a production one, and read at launch with PhazeEnv(dist:) from the shipped phaze-env.json: the dash names them NEURALKIT_API_ORIGIN — the host that mints the session, which a cookie and a passkey are each scoped to — and NEURALKIT_NATIVE_WORKER.
What does not cross the boundary
Section titled “What does not cross the boundary”- The rendering runtime. linkedom and
renderToStringAsyncrun in the app’s worker, at build for an artifact and per request for a served view, and never ship in the binary. - Credentials. Attached by the shell, never minted in the view. The API’s
Set-Cookielands in the shell’s store and is dropped from the response the view sees. Content-Encoding. WebKit hands a scheme-handler body to the parser as given; a compressed body with the header set arrives as garbage. Compression in a binary is therefore the shell’s to undo, which is whatcompressand the handler’s decode are.- An
import()target, from a string-loaded document. A document loaded as a string has no base URL, so a lazy chunk beside it cannot be reached; a document served over the shell’s protocol can reach every one, which is why the target serves a directory and never a string. - Streams. See the transport table.
Where it lives
Section titled “Where it lives”- Build:
@madenowhere/phaze-native/vite—native({ shell, views, router, actions, content, env, auth, api, cloudflare, compress, artifacts, chunks, … }), composingcloudflare(), and thenativeconfig key for a shell’s three. - The CLI:
phaze native dev·build·bundle·info— the shell’s dev loop, release build and signed bundle, from[package.metadata.native](phaze-cli › The native shell).builddrives cargo; a Swift shell’s binary goes throughphaze native bundle --binary(design, not shipped). - Runtime:
@madenowhere/phaze-native(root) with the app-config entries/auth·/env·/transport·/actions·/content;/wry—drag();/taurideferred;/swift— nothing a view imports: the drag is the Swift router’s own user script. - The shell: a protocol handler in Rust (wry, Tauri) or the Swift package
Phaze(phaze/swift—PhazeRouter(dist:scheme:api:views:)overURLSchemeHandler), serving the emitteddist/app, forwarding transport to the API origin and what it does not hold to the app’s worker;PhazeEnv(dist:)reads the build’s public env from that directory. - The reasoning: Phaze + Wry.