Deno Desktop (deno desktop, Deno ≥ 2.9) packages a web app together with the Deno runtime and a rendering engine into one binary per platform.
Point it at a project directory and it auto-detects the framework: Next.js, Astro, Nuxt, SvelteKit, SolidStart, TanStack Start, and several more. It embeds the build output and runs the framework's own production server (or its dev server under --hmr), with the webview pointed at it:
1deno desktop --hmr . # dev: framework dev server + native window
2deno desktop -o ./dist/myapp . # package: one binary for this platform
A sweet feature is how server-side code runs in the Deno runtime automatically, with no IPC. In Electron you split code into "main" and "renderer" and wire channels between them:
1
2ipcMain.handle("read-notes", () => fs.readFile(notesPath, "utf8"));
3
4
5contextBridge.exposeInMainWorld("api", { readNotes: () => ipcRenderer.invoke("read-notes") });
6
7
8const notes = await window.api.readNotes();
With deno desktop, the framework server is the backend. A TanStack Start server function (or a Next.js server action) does the same job in one place:
1
2export const readNotes = createServerFn().handler(() => fs.readFile(notesPath, "utf8"));
3
4const notes = await readNotes();
Server routes work the same way. With full Node compat, node:fs, node:child_process, native npm modules like onnxruntime-node, and embedded Postgres (PGlite) all run right next to your UI code.
For a local RAG tool that matters a lot. Fetching from GitHub, running an ONNX embedding model, and writing to a vector index are all "server" work, and we want it in plain TypeScript next to the routes that trigger it.
For the handful of things that really are native (window, menus, the OS browser for OAuth), Deno Desktop has bindings. A function bound on the Deno side shows up on a global bindings object in the webview, through in-process channels rather than socket IPC:
1
2win.bind("openExternal", (url: string) => openInSystemBrowser(url));
3
4
5await bindings.openExternal("https://github.com/login");
The real versions are in the preload section below.
TanStack Start builds with Nitro into .output/server/index.*, which is exactly the entry deno desktop looks for:
1vp build
2 └── .output/
3 ├── public/ static assets
4 └── server/index.mjs <- deno desktop finds and runs this
Everything else is a normal TanStack Start app, so the same code also runs in a browser tab (pnpm dev:vite) for fast UI iteration.
apps/desktop sits in the pnpm + Turbo monorepo next to apps/api (Better Auth + Turso) and apps/web (the browser dashboard and the OAuth /auth page). Shared code lives in packages/*.
1apps/desktop/
2├── deno.json # desktop.app identity, icons, backend, output paths
3├── package.json # dev / build / package scripts
4├── vite.config.ts # TanStack Start + Nitro + React Compiler + Relay
5├── nitro.config.ts # Nitro server (evlog drain)
6├── deno/ # Deno-side preload (NOT bundled by Vite)
7│ ├── window.ts # BrowserWindow + bindings exposed to the webview
8│ ├── menu.ts # native application menu
9│ └── auth/ # PKCE, loopback server, session jar (chapter 2)
10├── src/ # TanStack Start app
11│ ├── routes/ # file-based routes (UI + server routes)
12│ │ └── api/elysia/$.ts # Elysia mounted as a catch-all server route
13│ ├── elysia/ # embedded Elysia API + Eden treaty client
14│ ├── pglite/ # local Postgres + pgvector (Drizzle)
15│ ├── lib/pub-sub/ # in-process pub/sub that feeds SSE
16│ └── hooks/use-*-sse.ts # React hooks that consume SSE streams
17├── .output/ # `vp build` output, embedded into the binary
18└── dist-desktop/ # packaged app (never committed)
Shared packages used here:
The local data lives in the user's config directory, not in the repo: PGlite data at ~/.config/tangerine-desktop/pgdata (or DATABASE_URL), plus session JSON and the job-queue SQLite files.
The desktop block in deno.json holds all the desktop-specific config (docs):
1{
2 "nodeModulesDir": "manual",
3 "unstable": ["sloppy-imports"],
4 "imports": {
5 "@/": "./src/",
6 "@api/": "../api/src/"
7 },
8 "tasks": {
9 "dev": "pnpm run dev:vite",
10 "build": "pnpm run build"
11 },
12 "desktop": {
13 "app": {
14 "name": "Tangerine",
15 "identifier": "com.tigawanna.tangerine",
16 "deepLinks": ["com.tigawanna.tangerine"],
17 "icons": {
18 "linux": "./public/icon.png",
19 "macos": "./public/icon.png",
20 "windows": "./public/favicon.ico"
21 }
22 },
23 "backend": "webview",
24 "output": {
25 "linux": "./dist-desktop/tangerine",
26 "macos": "./dist-desktop/Tangerine.app",
27 "windows": "./dist-desktop/Tangerine"
28 }
29 }
30}
**** A few things are not obvious:
nodeModulesDir: "manual": pnpm owns node_modules. Deno reads it and never installs into it.imports mirrors the Vite @/ alias so the same imports resolve under both Deno and Vite.tasks.dev must point at Vite, not at deno desktop. Under --hmr, Deno Desktop runs the framework dev server by calling deno task dev. If that task started deno desktop again, it would recurse.backend: "webview" uses the OS webview (WebKitGTK / WebKit / WebView2) for small binaries. Switch to cef (bundled Chromium) with --backend=cef when you need identical rendering or DevTools. See Backends.deepLinks registers the custom scheme when the app is packaged, but Deno does not yet deliver open-url to JS, so OAuth returns through a loopback server instead (chapter 2).
The scripts in package.json:
1{
2 "dev": "NODE_OPTIONS='--dns-result-order=ipv4first --no-network-family-autoselection' DENO_DESKTOP_DEVTOOLS=1 deno desktop --hmr --preload ./deno/window.ts --env-file=.env -A .",
3 "dev:vite": "vp dev --port 3070 --host",
4 "desktop:build": "pnpm run build && deno desktop --preload ./deno/window.ts --env-file=.env --no-check --node-modules-dir=none --exclude-unused-npm --exclude ./.output/server/node_modules/onnxruntime-node --compress -A -o ./dist-desktop/tangerine ."
5}
What the flags do:
--hmr: runs the framework's own dev server, so React fast refresh works inside the native window just like in a browser (HMR docs). The Deno runtime and webview stay alive across edits.--preload ./deno/window.ts: runs our Deno-side code before the UI loads (next section). Preload is not hot-reloaded. Restart after editing deno/*.--env-file=.env: env is read once at startup, so restart after changing .env too.-A: all permissions. Bindings and server code inherit the runtime's permissions, and desktop apps usually ship with broad ones baked in.NODE_OPTIONS=...ipv4first: avoids localhost resolving to ::1 while the API listens on IPv4.DENO_DESKTOP_DEVTOOLS=1: the preload opens DevTools when this is set.- Packaging flags (
--exclude-unused-npm, --exclude …onnxruntime-node, --compress) keep the binary small. deno desktop does not run your framework build, which is why desktop:build runs pnpm run build first.
Env basics (see .env.example):
1VITE_APP_URL=http://localhost:3070 # the desktop UI itself
2VITE_API_URL=http://localhost:5000 # apps/api (auth + token exchange)
3VITE_SIGN_IN_URL=http://localhost:3064/auth # apps/web sign-in page, opened in the system browser
4DATABASE_URL=./pgdata # PGlite dir, relative to ~/.config/tangerine-desktop/
No GitHub client secrets ever go in this file. They stay on apps/api.
deno/window.ts is the only "main process"-style code we write. It takes over the startup window, installs the menu, and exposes a small set of functions to the webview:
1
2
3
4const win = new Deno.BrowserWindow({
5 title: "Tangerine",
6 width: 1280,
7 height: 840,
8 resizable: true,
9});
10
11installApplicationMenu(win);
12
13win.bind("openExternal", async (url: string) => {
14 if (typeof url !== "string" || url.length === 0) {
15 throw new TypeError("openExternal(url) requires a non-empty string");
16 }
17 await openExternal(url);
18});
19
20win.bind("getSession", async () => {
21 return await getSession();
22});
23
24if (Deno.env.get("DENO_DESKTOP_DEVTOOLS") === "1") {
25 win.openDevtools();
26}
On the React side, src/lib/desktop-bindings.ts types the bindings proxy. Deno gives you no type bridge between the two realms, so we declare one ourselves. We also feature-detect it so the same UI still works in a plain browser tab:
1declare global {
2
3 var bindings: DesktopBindings | undefined;
4}
5
6
7export function hasDesktopBindings(): boolean {
8 return typeof globalThis.bindings?.requestAuth === "function";
9}
Rule of thumb: bindings are for things only the native shell can do (window, menus, OS browser, the auth session). Everything else (GitHub fetches, embeddings, database, SSE) goes through normal TanStack Start server code, which already runs in Deno.
Related docs: Windows · Menus · Bindings.
Because the Nitro server runs inside the Deno runtime, a TanStack Start server function can use Node APIs, the local database, or the GitHub token directly. From src/data-access-layer/github/repos.ts:
1export const getPinnedRepos = createServerFn({ method: "GET" }).handler(async () => {
2 try {
3 const nodes = await createGitHubClient(await getGithubToken()).getPinnedRepos();
4 return {
5 data: {
6 viewer: {
7 pinnedItems: { nodes },
8 repositories: { nodes: [] },
9 },
10 },
11 } satisfies PinnedViewerReposResponse;
12 } catch {
13 return null;
14 }
15});
There's no preload channel and no IPC: the client calls getPinnedRepos() and TanStack Start handles the RPC over the local HTTP server that the webview is already talking to.
Server functions work well for request/response calls. A RAG indexer, though, needs long-lived streams: "fetched repo X", "embedded chunk 40/200", "upserted into the vector index". Those should be pushed to the UI as they happen, not polled.
TanStack Start server routes can return a raw Response, so you can stream SSE by hand. But then you build the ReadableStream, encode data: frames, set text/event-stream / no-cache / X-Accel-Buffering headers, and wire up abort handling yourself. src/lib/sse.ts shows what that looks like. Doing it per endpoint gets old fast.
So this project mounts Elysia inside TanStack Start, following the official TanStack Start integration. Elysia gives us:
- SSE as async generators:
yield sse({ data }) inside async function*, with request.signal for disconnects (Elysia SSE docs). - Validation: TypeBox
t.Object(...) schemas on body/query/params. - End-to-end types: Eden Treaty gives a typed client (tRPC-style) derived from the route tree.
- One composable API tree: feature routes (
/system, /models, /enrich, /worker, /chat, …) are separate Elysia instances combined with .use().
It all runs in the same Nitro process as the UI, on the same port, and inside the same deno desktop binary. There's no second server to start.
src/routes/api/elysia/$.ts forwards every method under /api/elysia/* to Elysia's fetch:
1import { elysiaApp } from "@/elysia/app";
2import { createFileRoute } from "@tanstack/react-router";
3
4
5
6
7
8const handle = ({ request }: { request: Request }) => elysiaApp.fetch(request);
9
10export const Route = createFileRoute("/api/elysia/$")({
11 server: {
12 handlers: {
13 GET: handle,
14 POST: handle,
15 PUT: handle,
16 PATCH: handle,
17 DELETE: handle,
18 },
19 },
20});
With pnpm, add Elysia's peer deps explicitly (pnpm add @sinclair/typebox openapi-types). pnpm does not auto-install them.
src/elysia/app.ts owns the prefix and composes the feature routes. The /tick route is the smallest possible SSE stream: one event per second until the client disconnects.
1import { sleep } from "@/lib/sse";
2import { Elysia, sse } from "elysia";
3
4export const elysiaApp = new Elysia({ prefix: "/api/elysia" })
5 .get("/tick", async function* ({ request }) {
6 let n = 0;
7 while (!request.signal.aborted) {
8 n += 1;
9 yield sse({
10 event: "tick",
11 data: { n, at: new Date().toISOString() },
12 });
13 try {
14 await sleep(1000, request.signal);
15 } catch (caught) {
16 if (caught instanceof DOMException && caught.name === "AbortError") break;
17 throw caught;
18 }
19 }
20 })
21 .use(consoleRoute)
22 .use(systemRoute)
23 .use(embeddingsRoute)
24 .use(workerRoute)
25 .use(enrichRoute)
26 .use(helloRoute)
27 .use(chatRoute);
28
29export type ElysiaApp = typeof elysiaApp;
src/elysia/routes/hello/index.ts is the toy version of the live activity bus from chapter 4. POST /hello publishes a message, and every open GET /hello/sse connection receives it:
1import { pubSub } from "@/lib/pub-sub/client";
2import { PUB_SUB_TOPICS } from "@/lib/pub-sub/topics";
3import { Elysia, sse, t } from "elysia";
4
5export const helloRoute = new Elysia({ prefix: "/hello" })
6 .get("/", () => [{ id: "1", message: "Hello, world!" }])
7 .get("/sse", async function* ({ request }) {
8
9 for await (const message of pubSub.listen<string>(PUB_SUB_TOPICS.HELLO_MESSAGE, {
10 signal: request.signal,
11 })) {
12 yield sse({ data: message });
13 }
14 })
15 .post(
16 "/",
17 ({ body }) => {
18 pubSub.publish(PUB_SUB_TOPICS.HELLO_MESSAGE, body.message);
19 return { message: `Emitted ${body.message}` };
20 },
21 { body: t.Object({ message: t.String() }) },
22 );
The bus in src/lib/pub-sub/client.ts is a process-wide EventEmitter. It's pinned on globalThis so Vite HMR re-evaluating the module doesn't give routes and workers two different emitters:
1export const pubSub: PubSub = (() => {
2 const g = globalThis as GlobalWithPubSub;
3 g[GLOBAL_KEY] ??= new PubSub();
4 return g[GLOBAL_KEY];
5})();
src/elysia/treaty.ts uses TanStack Start's createIsomorphicFn. On the server (SSR, loaders, server functions) Eden calls the Elysia app in-process with no HTTP. In the browser it makes HTTP calls to the same origin:
1export const getElysiaTreaty = createIsomorphicFn()
2 .server(() => getServerElysiaTreaty())
3 .client(() => {
4 const origin = globalThis.location?.origin ?? clientEnv.VITE_APP_URL;
5 return (treaty(origin) as unknown as ElysiaTreatyRoot).api.elysia;
6 });
Two project-specific tweaks on top of the Elysia docs:
- The server half lives in
treaty.server.ts, so the client bundle never imports app.ts (and with it every route, worker, and Node-only module). This also avoids a circular import with the route tree. - Types come from
ElysiaApp["~Routes"], not treaty<typeof app>(). Deno and Vite can resolve two copies of the elysia types, and typeof app then fails to line up. Pulling out the route map sidesteps that.
A plain mutation, fully typed from the route's TypeBox schema (PingMessage.tsx):
1const { mutate, isPending } = useMutation({
2 mutationFn: async (input: { message: string }) => {
3 const { data, error } = await getElysiaTreaty().hello.post({
4 message: input.message,
5 });
6 if (error) throw new Error(treatyErrorMessage(error));
7 return data;
8 },
9});
And the SSE side (src/hooks/use-hello-sse.ts). Eden's ~path gives us the URL without hardcoding it, and a native EventSource handles reconnects:
1const connect = () => {
2 const path = getElysiaTreaty().hello.sse["~path"];
3 source = new EventSource(path);
4 source.onmessage = (event) => appendHelloMessage(event.data);
5 source.onerror = () => {
6 if (source && source.readyState !== EventSource.CONNECTING) {
7 source.close();
8 }
9 };
10};
appendHelloMessage writes into a TanStack DB collection (hello-collection.ts). The collection is seeded from GET /hello, and live SSE rows are appended on top. Chapters 3–4 use the same pattern for embedding progress.
This is the product the rest of the series builds toward:
- Natural-language search over starred repos. Ask "that Rust crate for terminal UIs with a flexbox layout" and get the right starred repo back, even if you starred it three years ago and forgot the name.
- Local RAG pipeline: fetch → embed → store → retrieve.
- Fetch: README and metadata for each starred repo via the user's GitHub token, paced under API rate limits.
- Embed: EmbeddingGemma via ONNX Runtime (
onnxruntime-node), on-device. - Store: vectors in PGlite with pgvector, ranked by cosine distance (
src/pglite/client.ts). - Retrieve: vector similarity search, with the matching repos shown in the UI.
- Stay on-device. Your stars, READMEs, and vectors never leave the machine. There's no hosted RAG SaaS or vector DB in the loop. The only network calls are to GitHub (the source of truth) and to
apps/api for sign-in. - Visible progress. Indexing takes minutes on purpose, so the UI shows live activity over SSE instead of a spinner.
- Chapter 2: Better Auth: sign in with GitHub through the system browser, PKCE, and a loopback server in the preload, so the app gets a user token without shipping secrets.
- Chapter 3: Worker engine: a durable job queue that crawls stars, stays under rate limits, and embeds repos in batches.
- Chapter 4: Live activity bus: the
/hello pub/sub pattern above, reused as typed progress events from the worker to the UI. - Chapter 5: Query path: embed the question, run a pgvector search, and render results.