From d53e8c8cf1fedaf3052f25c55d674a46aeb94394 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 14 May 2026 00:25:05 -0700 Subject: [PATCH] Add HTTP guide and WebSocket header forwarding --- docs/GUIDE.md | 87 +++++++++++++++++++++++++++++++++++++++++++++ src/server/proxy.ts | 22 +++++++++--- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 48690d4..11e5b8f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -26,6 +26,7 @@ Toes is a personal web appliance that runs multiple web apps on your home networ - [Sharing](#sharing) - [Environment Variables](#environment-variables-1) - [Health Checks](#health-checks) +- [Running over HTTP](#running-over-http) - [App Lifecycle](#app-lifecycle) - [Cron Jobs](#cron-jobs) - [Data Persistence](#data-persistence) @@ -747,6 +748,92 @@ app.get('/ok', c => c.text('ok')) --- +## Running over HTTP + +Toes serves apps over plain HTTP (`http://.toes.local`), not HTTPS. This is fine for a home network appliance, but a few browser features assume HTTPS and will silently break if you're not aware of them. + +> **Note:** `localhost` gets a special pass — browsers treat it as a secure context even over HTTP. But `.local` domains don't get that exemption, so these gotchas apply when accessing your apps at `.toes.local` from another device. + +### Cookies + +If you set cookies with the `Secure` flag, browsers will silently ignore them — the cookie just won't be stored. + +Don't do this: + +```tsx +c.header('Set-Cookie', 'session=abc123; HttpOnly; Secure; SameSite=Lax') +``` + +Do this instead: + +```tsx +c.header('Set-Cookie', 'session=abc123; HttpOnly; SameSite=Lax') +``` + +If you're using a cookie library, make sure `secure` is set to `false` (or omitted): + +```tsx +import { setCookie } from 'hono/cookie' + +setCookie(c, 'session', token, { + httpOnly: true, + sameSite: 'Lax', + secure: false, // toes apps run over HTTP +}) +``` + +### Clipboard API + +`navigator.clipboard.writeText()` and `navigator.clipboard.readText()` require a secure context. They'll throw on `.local` domains. + +Use the legacy fallback instead: + +```tsx +function copyToClipboard(text: string) { + const textarea = document.createElement('textarea') + textarea.value = text + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) +} +``` + +### Service Workers + +Service workers only register on HTTPS origins (plus `localhost`). If you're building a PWA or want offline caching, it won't work on `.local`. This is a hard browser restriction with no workaround. + +### Web Push Notifications + +The Push API and `Notification.requestPermission()` require a secure context. For notifications on the local network, consider polling or SSE instead: + +```tsx +app.sse('/notifications', (send, c) => { + // push updates over SSE instead of Web Push + send({ title: 'New item', body: 'Something happened' }) + return () => {} +}) +``` + +### Geolocation & Camera/Mic + +`navigator.geolocation` and `navigator.mediaDevices.getUserMedia()` require a secure context. These won't work on `.local` domains. + +### Web Crypto + +`crypto.subtle` (for hashing, encryption, key generation) requires a secure context. Use a library like `tweetnacl` if you need crypto in the browser, or do it server-side: + +```tsx +// Server-side — works fine, no secure context needed +const hash = new Bun.CryptoHasher('sha256').update(data).digest('hex') +``` + +### What about `toes share`? + +`toes share` tunnels your app through HTTPS, so all of the above works when accessed through the tunnel URL. But since your app should also work locally, don't rely on secure-context APIs unless you're okay with them only working when shared. + +--- + ## App Lifecycle Apps move through these states: diff --git a/src/server/proxy.ts b/src/server/proxy.ts index d2342be..c8d7e7a 100644 --- a/src/server/proxy.ts +++ b/src/server/proxy.ts @@ -13,6 +13,7 @@ interface WsData { port: number path: string protocols: string[] + headers: Record } export function extractSubdomain(host: string): string | null { @@ -98,10 +99,20 @@ export function proxyWebSocket(subdomain: string, req: Request, server: Server p.trim()) : [] - const headers: Record = {} - if (protocolHeader) headers['sec-websocket-protocol'] = protocolHeader + // Collect headers to forward to the upstream app + const forwardHeaders: Record = {} + for (const name of ['cookie', 'authorization', 'x-app-url']) { + const value = req.headers.get(name) + if (value) forwardHeaders[name] = value + } + if (!forwardHeaders['x-app-url']) { + forwardHeaders['x-app-url'] = app.tunnelUrl ?? `${url.protocol}//${subdomain}.${url.hostname}` + } - const ok = server.upgrade(req, { data: { port: app.port, path, protocols } as WsData, headers }) + const upgradeHeaders: Record = {} + if (protocolHeader) upgradeHeaders['sec-websocket-protocol'] = protocolHeader + + const ok = server.upgrade(req, { data: { port: app.port, path, protocols, headers: forwardHeaders } as WsData, headers: upgradeHeaders }) if (ok) return undefined return new Response('WebSocket upgrade failed', { status: 500 }) } @@ -109,7 +120,10 @@ export function proxyWebSocket(subdomain: string, req: Request, server: Server) { const { port, path } = ws.data - const upstream = new WebSocket(`ws://localhost:${port}${path}`, ws.data.protocols) + const upstream = new WebSocket(`ws://localhost:${port}${path}`, { + headers: { ...ws.data.headers, host: `localhost:${port}` }, + protocols: ws.data.protocols, + }) upstream.binaryType = 'arraybuffer' upstreams.set(ws, upstream)