Add HTTP guide and WebSocket header forwarding

This commit is contained in:
Chris Wanstrath 2026-05-14 00:25:05 -07:00
parent 75b40b7ed1
commit d53e8c8cf1
2 changed files with 105 additions and 4 deletions

View File

@ -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://<app>.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 `<app>.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:

View File

@ -13,6 +13,7 @@ interface WsData {
port: number
path: string
protocols: string[]
headers: Record<string, string>
}
export function extractSubdomain(host: string): string | null {
@ -98,10 +99,20 @@ export function proxyWebSocket(subdomain: string, req: Request, server: Server<W
const protocolHeader = req.headers.get('sec-websocket-protocol')
const protocols = protocolHeader ? protocolHeader.split(',').map(p => p.trim()) : []
const headers: Record<string, string> = {}
if (protocolHeader) headers['sec-websocket-protocol'] = protocolHeader
// Collect headers to forward to the upstream app
const forwardHeaders: Record<string, string> = {}
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<string, string> = {}
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<W
export const websocket = {
open(ws: ServerWebSocket<WsData>) {
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)