Skip to main content

Connecting and staying connected

Claude
Authored by Claude · Last updated AI generated documentation written by reading the source code
self-hosted

The address that reaches a server changes as a device moves: the LAN address works at home, the internet address works away from it, and neither works forever. Cardinal's own apps reopen the last working address immediately, confirm it in the background, walk the Remote Access candidates again when requests stop getting answers, and move every request and stream to the new address without interrupting the user. Connecting to a server describes the negotiation API itself; this recipe is the loop around it.

The relay is not available to third-party apps yet, so this recipe uses direct candidates only.

Rules​

  1. Store the server's identity, not only an address. Keep the instanceId from GET /api/v1/instance alongside the last address that worked. An address alone cannot be found again after it stops working.
  2. Start on the remembered address. Send requests to it straight away, so the app opens without waiting on the network. At the same time, probe it with GET /api/v1/instance and a 2-second timeout, and check that the response's instanceId matches. When the probe fails, or names another server, forget the address. A router can hand a remembered LAN address to a different device.
  3. Walk the candidates on start as well, even when the remembered address answers. When the walk lands on a different address, move to it.
  4. Negotiate with GET https://api.cardinalapps.host/connect/{instanceId}:
    • 404: the server does not use Remote Access. Stop and tell the user; retrying does not help.
    • Any other error status, or no response: the negotiation API is unavailable. Keep the current address.
    • online: false, or an empty candidates list: there is no direct route right now.
  5. Probe candidates one at a time, in the order given. Send GET {candidate}/api/v1/instance, abort it after 2 seconds, and take the first one that answers with a 2xx. Any other outcome (refused, TLS error, error status, timeout) means that candidate is dead; move to the next. Never probe candidates in parallel: the order encodes preference, and a race picks whichever answers first.
  6. In a browser, check local network access once, before the first lan candidate. Query the local-network-access permission. When it is denied, skip lan candidates. When it is prompt, give the lan candidate up to 60 seconds, because the browser holds the request until the user answers. When the permission does not exist, as in native apps, treat it as granted.
  7. A walk that finds nothing never replaces a working address. A probe that fails on a new address leaves the current address in use.
  8. Run one walk at a time. A trigger that arrives while a walk is running joins that walk.
  9. Walk again when a request gets no response. A network error or timeout means the route may be gone, for example because the device changed networks. An error status is not a reason to walk: the server answered. Leave at least 10 seconds between walks.
  10. Keep one base URL and read it for every request. Do not store full URLs built from it, except where a media element needs one (rule 11).
  11. When the base URL changes:
    • New requests go to the new address. Requests already in flight to the old address finish or fail on their own; a failure from the old address does not start another walk.
    • Media elements keep the URL they were created with. Rebuild each stream on the new address at its current position and play state, as Playback and the queue describes.
    • Reopen the event stream on the new address, as Live updates with events describes.
    • Remember the new address for the next start.
  12. The credential does not change with the address. The same credential works on every address of the same server.

Reference implementation​

getToken() is the app's credential function, described in Recipes.

declare function getToken(options?: { refresh?: boolean }): Promise<string>

type Candidate = { kind: 'lan' | 'wan', hostname: string, port: number, scheme?: 'https' | 'http' }

const INSTANCE_ID = 'abc123' // stored when the app first connected
const NEGOTIATION_API = 'https://api.cardinalapps.host'
const CANDIDATE_TIMEOUT_MS = 2_000
const LAN_PROMPT_TIMEOUT_MS = 60_000
const MIN_WALK_INTERVAL_MS = 10_000
const STORAGE_KEY = 'cardinal.origin'

let origin: string | null = null
let walk: Promise<void> | null = null
let lastWalkAt = 0
const listeners = new Set<(origin: string) => void>()

export const getOrigin = () => origin

export function onOriginChange(listener: (origin: string) => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}

function setOrigin(next: string): void {
next = next.replace(/\/+$/, '')
if (next === origin) return
origin = next
try { localStorage.setItem(STORAGE_KEY, next) } catch {}
listeners.forEach((listener) => listener(next))
}

// True when the address answers in time, and, if asked, belongs to the expected server.
async function probe(base: string, timeoutMs: number, expectInstanceId?: string): Promise<boolean> {
try {
const res = await fetch(`${base}/api/v1/instance`, { signal: AbortSignal.timeout(timeoutMs) })
if (!res.ok) return false
if (!expectInstanceId) return true
const { instanceId } = await res.json().catch(() => ({}))
return !instanceId || instanceId === expectInstanceId
} catch {
return false
}
}

async function localNetworkAccess(): Promise<string> {
try {
return (await navigator.permissions.query({ name: 'local-network-access' as PermissionName })).state
} catch {
return 'unsupported'
}
}

function candidateUrl({ hostname, port, scheme = 'https' }: Candidate): string {
return scheme === 'https' && port === 443 ? `https://${hostname}` : `${scheme}://${hostname}:${port}`
}

// The first candidate that answers, or null.
async function findDirectOrigin(): Promise<string | null> {
const res = await fetch(`${NEGOTIATION_API}/connect/${encodeURIComponent(INSTANCE_ID)}`)
if (res.status === 404) throw new Error('This server does not use Remote Access')
if (!res.ok) return null
const info: { online: boolean, candidates?: Candidate[] } = await res.json()
if (!info.online) return null

let lanAccess: string | null = null
for (const candidate of info.candidates ?? []) {
let timeoutMs = CANDIDATE_TIMEOUT_MS
if (candidate.kind === 'lan') {
lanAccess ??= await localNetworkAccess()
if (lanAccess === 'denied') continue
if (lanAccess === 'prompt') timeoutMs = LAN_PROMPT_TIMEOUT_MS
}
const url = candidateUrl(candidate)
if (await probe(url, timeoutMs)) return url
}
return null
}

// Concurrent callers share one walk. Finding nothing keeps the current address.
export function reresolve(): Promise<void> {
if (walk) return walk
if (Date.now() - lastWalkAt < MIN_WALK_INTERVAL_MS) return Promise.resolve()
lastWalkAt = Date.now()
walk = findDirectOrigin()
.then((found) => { if (found) setOrigin(found) })
.catch(() => {}) // a 404 here means the server does not use Remote Access: tell the user
.finally(() => { walk = null })
return walk
}

export async function connect(): Promise<void> {
let remembered: string | null = null
try { remembered = localStorage.getItem(STORAGE_KEY) } catch {}

if (remembered) {
setOrigin(remembered)
void probe(remembered, CANDIDATE_TIMEOUT_MS, INSTANCE_ID).then((ok) => {
if (!ok && origin === remembered) {
origin = null
try { localStorage.removeItem(STORAGE_KEY) } catch {}
}
})
}

await reresolve()
}

// Every API request goes through here.
export async function api(path: string, init: RequestInit = {}): Promise<Response> {
const base = origin
if (!base) throw new Error('No route to the server')

const send = async (refresh = false) => {
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${await getToken({ refresh })}`)
return fetch(`${base}/api/v1${path}`, { ...init, headers })
}

try {
const res = await send()
return res.status === 401 ? await send(true) : res
} catch (error) {
if (base === origin) void reresolve()
throw error
}
}

An app that connects only on the server's network, with an address the user typed, keeps that address in origin and skips reresolve().

Edge cases​

  • The server does not use Remote Access. Negotiation answers 404. The app can still use an address the user enters, such as the LAN address or the owner's custom domain.
  • Direct connections are turned off. The server is online with no candidates. Away from home, the only route a third-party app has is the owner's custom domain.
  • The lan candidate never answers at home. Some routers block DNS answers that point at private addresses. See When no candidate answers.
  • A web app on its own domain cannot call the negotiation API. See Calling the negotiation API from a browser.
  • A walk away from home takes about 2 seconds longer for each lan candidate before it, because each one waits out its timeout.
  • Many requests fail at once. They share one walk, and the 10-second spacing stops repeated failures from becoming repeated walks. The negotiation API also answers 429 to an address that asks too often.
  • The server is off. Every walk finds nothing, the last address stays in place, and requests keep failing until the server returns. Show the server as unreachable rather than signing the user out.
  • A request to a dead address can hang. Some networks drop packets instead of refusing them, and the request then waits for the platform's own timeout before it fails and starts a walk.

How to verify it works​

  1. On the server's network, start the app and confirm the lan candidate wins.
  2. Away from it, for example on a phone's hotspot, confirm the wan candidate wins after the lan candidate's 2-second timeout.
  3. Restart the app and confirm, in the network log, that requests go to the remembered address before the negotiation response arrives.
  4. Store a wrong address in place of the remembered one, restart, and confirm the app ends up on the walked address.
  5. While audio plays, move the device to another network. Confirm that a failed request starts one walk, the base URL changes, and playback continues from the same position.
  6. Stop the server and confirm the app reports it unreachable, keeps the last address, and walks no more than once every 10 seconds.

Was this article helpful?