Appearance
Native HTTP
Running the app with bun tauri dev works fine — lustre_dev_tools is running and proxies /api requests, so there's no CORS issue. But bun tauri build produces a standalone desktop app with no dev server and no proxy. The webview loads static files directly from disk, so requests to http://localhost:8000 run into CORS restrictions and fail.
Tauri's HTTP plugin solves this by routing requests through the Rust backend, which isn't subject to browser CORS policy. The webview calls the plugin instead of fetch, and Rust makes the actual HTTP request.
Five files change, two are new[1]:
sh
doable/
└── client/
├── package.json # @tauri-apps/plugin-http added
├── src-tauri/
│ ├── Cargo.toml # tauri-plugin-http added
│ ├── capabilities/
│ │ └── default.json # http permission + url allowlist
│ ├── src/
│ │ └── lib.rs # http plugin registered
│ └── tauri.conf.json # app identifier updated
└── src/
├── api.gleam # platform-aware base URL + send
└── tauri/
├── http.gleam # raw_send external
└── http_ffi.js # fetch bridgeInstalling the Plugin
sh
cd client
bun tauri add httpThe CLI adds tauri-plugin-http to Cargo.toml, @tauri-apps/plugin-http to package.json, registers the plugin in lib.rs, and updates capabilities/default.json. Unlike os:default, the HTTP permission requires an explicit URL allowlist — requests to unlisted URLs are blocked:
json
// client/src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"permissions": [
"core:default",
"os:default",
{
"identifier": "http:default",
"allow": [
{
"url": "http://localhost:8000/**"
}
]
}
]
}The HTTP Bridge
http_ffi.js is the core of the solution. It wraps both fetch and Tauri's HTTP plugin behind a single function, choosing at runtime based on whether the code is running inside Tauri:
js
// client/src/tauri/http_ffi.js
import { Ok, Error } from "../gleam.mjs";
import { NetworkError } from "../../gleam_fetch/gleam/fetch.mjs";
import { isTauri } from "@tauri-apps/api/core";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
export async function raw_send(request) {
try {
return new Ok(await (isTauri() ? tauriFetch(request) : fetch(request)));
} catch (error) {
return new Error(new NetworkError(error.toString()));
}
}isTauri() checks at runtime whether the code is running inside a Tauri webview. In the desktop app it calls tauriFetch, which routes through the Rust backend. In a browser it falls back to the native fetch — so the same code works in both environments without any conditional logic in Gleam.
http.gleam exposes raw_send as a typed Gleam external:
gleam
// client/src/tauri/http.gleam
import gleam/fetch.{type FetchError, type FetchRequest, type FetchResponse}
import gleam/javascript/promise.{type Promise}
@external(javascript, "./http_ffi.js", "raw_send")
pub fn raw_send(a: FetchRequest) -> Promise(Result(FetchResponse, FetchError))It works at the level of raw JS Request and Response objects — the same types gleam_fetch uses internally.
API Changes
Two things change in api.gleam: the base URL becomes platform-aware, and a send function replaces direct calls to fetch.send.
In a browser, window.location.origin points to the lustre_dev_tools dev server or the production Caddy server, both of which proxy /api requests. In the desktop app there's no proxy, so requests go directly to the server:
gleam
// client/src/api.gleam
fn api_base_url() -> String {
browser.window_location_origin()
case platform.platform() {
Browser -> browser.window_location_origin()
_ -> "http://localhost:8000"
}
}The send function bridges between Gleam's Request type and the raw JS objects that raw_send expects:
gleam
// client/src/api.gleam
fn send(
request: request.Request(String),
) -> Promise(Result(Response(FetchBody), FetchError)) {
request
|> fetch.to_fetch_request
|> tauri_http.raw_send
|> promise.try_await(fn(resp) {
promise.resolve(Ok(fetch.from_fetch_response(resp)))
})
}fetch.to_fetch_request converts a Gleam Request to a JS Request object. raw_send sends it — via Tauri or the browser depending on context. fetch.from_fetch_response converts the JS Response back to a Gleam Response so the rest of api.gleam is unchanged.
execute and delete swap fetch.send for the new send:
gleam
// client/src/api.gleam
pub fn delete(path: String) -> Promise(Result(Nil, ApiError)) {
use request <- with_json_request(path)
request
|> request.set_method(Delete)
|> fetch.send
|> send
|> promise.map(result.map_error(_, FetchError))
|> promise.map_try(fn(response) {
use <- bool.guard(
response.status != 204,
Error(UnexpectedStatus(response.status)),
)
Ok(Nil)
})
}
fn execute(
req: Request(String),
expect expect: Int,
decoder decoder: Decoder(a),
) -> Promise(Result(a, ApiError)) {
req
|> fetch.send
|> send
|> promise.try_await(fetch.read_json_body)
|> promise.map(result.map_error(_, FetchError))
|> promise.map_try(fn(response) {
use <- bool.guard(
response.status != expect,
Error(UnexpectedStatus(response.status)),
)
response.body
|> decode.run(decoder)
|> result.map_error(DecodeError)
})
}App Identifier
While here, tauri.conf.json gets a proper app identifier to replace the placeholder the initializer generated:
json
// client/src-tauri/tauri.conf.json
{
"identifier": "com.tauri.dev",
"identifier": "com.lukwol.doable"
...
}The identifier is how the OS distinguishes the app — it shows up in system preferences, update registries, and app bundles. We'll come back to swapping it for your own reverse-domain identifier when we cover distribution in chapter 14.
Running
sh
cd client
bun tauri devAPI requests work as before in dev. To verify the fix actually matters, run bun tauri build and open the resulting app — tasks load correctly without the proxy.
What's Next
The desktop build is production-ready: HTTP flows through Rust, CORS is no longer a problem, and the same Gleam code runs in both the browser and the webview. Next, we'll take that same frontend to iOS and Android — two init commands, and the app runs on a phone.