Since version 2.9 deno has support for desktop apps which can package up web applications as native applications that can be run locally and puts it basically into direct competition with electron, tauri and webui to name a few similar options.
All of the above have a plethora of different trade-offs and configuration options, but I am not going to open that can of worms today.
As of the time of writing, you can just point deno at your web app and package it up as native application that just works - if you happen to use one of a couple of common web frameworks that it can auto-detect. In that case it finds the build output of your app, packages it up and serves it when the native app is started.
That’s pretty cool!
On the other hand you can write a custom entrypoint and start the server yourself. This allows you to implement an api but you can also just serve the files from your webapp build output more less manually.
That’s pretty flexible!
Now for a desktop application to make any sense, you would probably want to access some OS functionality that is not available in the browser sandbox, else why do you need a desktop app in the first place?!
This is where the Deno API comes into play. Similiar to NodeJS (or bun for that matter) it provides local file-access and so on - things that you would expect from a native app.
Now, you cannot access those Deno APIs directly from your webapp - not only would that be a security nightmare, but it would also be strange for your normal web app development process.
The way that this works is that when you write your own entrypoint (just before you start your own server) you can add some bindings to the global scope of your web app. The web app can than access those bindings and call the functions you provided at startup.
Which is also pretty cool!
Now my issues was: I want both (of course!) and that’s not (yet) supported. I wanted what the autodection gave me - hot module reload during development, embedding of the build outputs and a server logic that serves those bundled resources while also having access to the Deno api via custom bindings.
After making sure I didn’t miss anything in the documentation and asking around on github I got some nice pointers to the deno source code and I came up with the following solution that works fine for me:
To do that, I did 3 things:
To get the following layout:
├── deno.json
├── desktop.ts
├── server.ts
| ...
├── dist
│ └── ...
| src
│ └── ...
I’ve added a custom entrypoint desktop.ts that adds bindings before starting the server.
const win = new Deno.BrowserWindow({ title: 'An application ' })
win.bind('test', async () => {
return {
test: 'this is a test!!!',
}
})
import "./server.ts"
You may want more sophisticated bindings :p
To actually use them in the client code you want something like the following (only nicer)
const bindings = globalThis['bindings'] as any
bindings?.test().then((r: any) => console.log('bindings test returned:', r))
I added a server.ts that I shamelessly copied from deno’s sources:
// Pin the major version so the compiled binary is reproducible and doesn't pick
// up a breaking `@std/http` release on a later rebuild.
import { serveDir } from 'jsr:@std/http@^1/file-server'
// `vite build` emits a static site into `dist/`. Resolve it against the VFS in
// the compiled binary via import.meta.dirname rather than the runtime CWD.
const fsRoot = import.meta.dirname + '/dist'
Deno.serve(async (req) => {
const res = await serveDir(req, { fsRoot, quiet: true })
// SPA fallback: route unmatched HTML navigations back to index.html so
// client-side routers keep working after a hard refresh.
if (
res.status === 404 &&
req.method === 'GET' &&
(req.headers.get('accept') ?? '').includes('text/html')
) {
const index = new Request(new URL('/index.html', req.url), {
headers: req.headers,
})
return await serveDir(index, { fsRoot, quiet: true })
}
return res
})
Then I added some glueing tasks to deno.json to make developing and building easier.
"tasks": {
"build": "vite build",
"desktop:build": {
"dependencies": ["build"],
"command": "deno desktop --include dist desktop.ts"
},
"dev": "vite --port=8008 --strictPort",
"desktop:hmr": {
"command": "DENO_DESKTOP_DEV_URL=http://127.0.0.1:8008 deno desktop --hmr desktop.ts"
},
"desktop:dev": {
"dependencies": ["dev", "desktop:hmr"]
}
}
deno task desktop:dev launches the dev server (vite dev) and instructs deno to use it for serving and hotreloading by supplying the dev server url via the DENO_DESKTOP_DEV_URL variable setting which I found in the code here.
deno task desktop:build triggers the production build (vite build) and then starts deno, instructs it to embedd the build output (--include dist) and points it at my entryoint.
As I am supplying the dev server port to deno, I’ve used --strictPort when invoking vite so that it actually fails if it couldn’t get that exact port.
Now, for development I can do: deno task desktop:dev
And to get a production build I can do: deno task desktop:build