Native shells
A shell wraps an app's pages in a native window or a phone app: Tauri, Capacitor and Electron all load the built client from disk. The pages then live on one origin, such as tauri://localhost, and the server on another. A transport tells the client where the server is. The router's requests, server functions, the live socket and the calls you make with appFetch go through it; a plain fetch("/api/todos") does not, and goes to the shell's own origin.
Build the pages
Write the transport in a frontend file with serverFetch from @nifrajs/web/shell. A path-absolute URL, such as /todos, goes to origin. The headers are added to each request to that origin, and may be a function that returns them; a request to any other host goes out as written, without the headers or credentials, so a call to a third party never carries your token.
// frontend/transport.ts
import { serverFetch } from "@nifrajs/web/shell"
export default serverFetch({ origin: "https://api.example.com" })With a token that refreshes:
// frontend/transport.ts
import { serverFetch } from "@nifrajs/web/shell"
export default serverFetch({
origin: "https://api.example.com",
// A bearer token that refreshes: the function runs before each request.
headers: async () => ({ authorization: `Bearer ${await currentToken()}` }),
})
// The app's own: where it keeps and refreshes its token.
declare function currentToken(): Promise<string>An app that signs in with a cookie sets credentials: "include". The server then needs credentials: true in its CORS options and a cookie a cross-site request may carry (SameSite=None; Secure), and a web view may restrict cookies further, so a bearer token is the safer choice in a shell.
// doc-check: skip - a fragment of frontend/transport.ts.
export default serverFetch({ origin: "https://api.example.com", credentials: "include" })Pass the module to the shell build with --transport, and name the pages the shell opens on with --prerender (comma-separated paths such as /,/about). Only this build prerenders them, so a web build of the same app keeps rendering them per request; a route that exports prerender = true is included too. A path that is not a static page route fails the build.
nifra build --target static --out dist --prerender /transport in the framework config does the same for an app that ships nothing but the shell. It applies to every build, and --transport replaces it but cannot turn it off, so leave it out of an app that also ships a web build.
// doc-check: skip - nifra.config.ts for the CLI; the adapter is the app's own.
// Only for an app that ships nothing but the shell: it applies to every build.
export { adapter } from "./backend/framework"
export const clientModule = "@nifrajs/web-react/client"
export const transport = "./frontend/transport.ts"The output has an index.html for each prerendered route and the assets they load, which is what a shell's frontendDist points at. The same option reaches the Bun and Vite builds and both dev servers, and buildClient takes it as transport.
A path the shell has no page for, such as a reload at /todos, hydrates the document the shell ships and then navigates to that URL. A prerendered page's route data loads from the bundle, except a navigation with a query string and, in an app with live, the loads the live endpoint makes: those go to the server.
--transport names the module for one run and wins over the config, so an app can ship a web build and a shell build from one tree. --prerender only applies to --target static:
nifra build --target static --out dist-shell --transport ./frontend/shell-transport.ts --prerender /What goes through the transport
- Route data, prefetches and action posts of the router.
- Server functions: the stub that stands in for a
*.fn.tsmodule calls through it. - The live socket and its load endpoint. The socket connects to the transport's origin.
- Keep-alive pages, layered over the transport.
- Your own calls, with
appFetch. Hand it to auseQueryfetcher or to the typed client:client(url, { fetch: appFetch }).
import { appFetch } from "@nifrajs/web/shell"
// The transport in a shell build, the global fetch anywhere else.
export const todos = (): Promise<unknown> => appFetch("/api/todos").then((res) => res.json())Let the server accept the shell
A shell's requests carry its own scheme as their Origin, so the server refuses them until it lists that origin. Tauri uses tauri://localhost on macOS, iOS and Linux and http://tauri.localhost on Windows and Android; Capacitor uses capacitor://localhost on iOS.
- CORS on the web app, as the
usehook ofbackend/framework.ts: page data and form posts are its routes, not the backend's. List the headers the router reads inexposedHeaders;"*"is read literally by a credentialed request. - A page action checks the route's own
allowedOrigins, which replaces the same-origin default. Its second argument says whether that default would admit the request, so a route can keep its own site and add the shell. serverFunctionstakesorigins.livetakestrustedOrigins, for the load endpoint and the socket.
// backend/framework.ts: the web app answers page data and form posts, so the CORS goes on it.
import { cors } from "@nifrajs/middleware"
const SHELLS = ["tauri://localhost", "http://tauri.localhost", "capacitor://localhost"]
export const use = (app: { use: (plugin: ReturnType<typeof cors>) => unknown }): void => {
app.use(
cors({
origin: SHELLS,
// Named, not "*": a credentialed request reads "*" as a header called "*".
exposedHeaders: ["x-nifra-redirect", "x-nifra-status", "x-nifra-revalidate"],
}),
)
}// doc-check: skip - a route's backend half; the action is the app's own.
// routes/todos.backend.ts
// allowedOrigins replaces the same-origin default, so the site's own posts come in as sameOrigin.
const SHELLS = ["tauri://localhost", "http://tauri.localhost", "capacitor://localhost"]
export const allowedOrigins = (origin: string | null, sameOrigin: boolean): boolean =>
sameOrigin || (origin !== null && SHELLS.includes(origin))import { serverFunctions } from "@nifrajs/web/fn"
import * as todos from "./todos.fn.ts"
export const fns = serverFunctions("todos", todos, {
origins: ["tauri://localhost", "http://tauri.localhost", "capacitor://localhost"],
})import { live } from "@nifrajs/web/live"
export const runtime = live({
// Required in production, at least 32 bytes: set LIVE_SECRET (openssl rand -base64 32).
secret: process.env.LIVE_SECRET ?? "",
// Anonymous viewers. An app with accounts returns the session's user id, so a change of account
// clears the client's cache and revoke(userId) reaches this viewer's grants.
subject: () => null,
trustedOrigins: ["tauri://localhost", "http://tauri.localhost", "capacitor://localhost"],
})Tauri 2
nifra shell tauri adds the transport and a src-tauri/ project to an existing app. It writes files only, skips any that exist unless --force, and leaves package.json and the config alone. The project builds the static target with --transport into dist-shell/, and its content security policy allows the server's origin and socket. The window opens hidden and shows on the page's first paint, with a three second timer as the fallback, so there is no blank window while the web view starts.
nifra shell tauri --origin https://api.example.combun add -d @tauri-apps/cli
bunx tauri icon path/to/icon.png # a square PNG, 1024x1024 recommended
bunx tauri devA new site can start with a shell: --shell tauri adds the same project, lets the server's CORS accept the shell origins in backend/shell-origins.ts, and adds shell:dev and shell:build scripts.
bun create nifra my-app --shell tauri
cd my-app && bun install
bunx tauri icon path/to/icon.png # a square PNG, 1024x1024 recommended
bun run shell:dev--mobile also runs tauri android init or tauri ios init with the project's Tauri CLI, which needs the platform toolchain: the Android SDK and NDK, or Xcode. A platform that fails is reported and the files already written stay.
nifra shell tauri --mobile android # or ios, or allBy hand, point frontendDist at the build and allow the server in the content security policy. On iOS, a development server on a local address needs NSAllowsLocalNetworking in the app's Info.plist.
// doc-check: skip - src-tauri/tauri.conf.json
{
"build": { "frontendDist": "../dist-shell" },
"app": { "security": { "csp": "default-src 'self'; connect-src 'self' https://api.example.com wss://api.example.com" } }
}Limits
- The transport module must sit in a zone:
frontend/, or a package that declares"nifra": { "environment": "frontend" }. - Tauri on macOS, the iOS Simulator and an Android emulator are what this was run on. Windows and Linux use the same transport but have not been run.
- On Android the web view draws under the status bar, so a page that starts at the top needs its own top padding.
- A page in a shell starts in the system web view, so its first paint comes after the view does.