Docs

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.

TS
// frontend/transport.ts
import { serverFetch } from "@nifrajs/web/shell"

export default serverFetch({ origin: "https://api.example.com" })

With a token that refreshes:

TS
// 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.

TS
// 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.

BASH
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.

TS
// 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:

BASH
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.ts module 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 a useQuery fetcher or to the typed client: client(url, { fetch: appFetch }).
TS
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 use hook of backend/framework.ts: page data and form posts are its routes, not the backend's. List the headers the router reads in exposedHeaders; "*" 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.
  • serverFunctions takes origins.
  • live takes trustedOrigins, for the load endpoint and the socket.
TS
// 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"],
    }),
  )
}
TS
// 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))
TS
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"],
})
TS
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.

BASH
nifra shell tauri --origin https://api.example.com
BASH
bun add -d @tauri-apps/cli
bunx tauri icon path/to/icon.png   # a square PNG, 1024x1024 recommended
bunx tauri dev

A 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.

BASH
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.

BASH
nifra shell tauri --mobile android   # or ios, or all

By 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.

JSON
// 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.