cloudaxe-opencode/packages/opencode/src/server/push-relay.ts
Ryan Vogel 15fae6cb60 update mobile pairing flow and audio session handling
Improve pairing reliability and UX by letting users choose among scanned hosts with health checks and cleaner row styling while shrinking QR payloads. Handle iOS call-time audio session conflicts more gracefully with user-friendly messaging and lower-noise logs.
2026-03-30 16:53:35 -04:00

450 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os from "node:os"
import { createHash } from "node:crypto"
import { SessionID } from "@/session/schema"
import { GlobalBus } from "@/bus/global"
import { Log } from "@/util/log"
type Type = "complete" | "permission" | "error"
type Pair = {
v: 1
serverID?: string
relayURL: string
relaySecret: string
hosts: string[]
}
type Input = {
relayURL: string
relaySecret: string
hostname: string
port: number
advertiseHosts?: string[]
}
type State = {
relayURL: string
relaySecret: string
pair: Pair
stop: () => void
seen: Map<string, number>
gc: number
}
type Event = {
type: string
properties: unknown
}
type Notify = {
type: Type
sessionID: string
title?: string
body?: string
}
const log = Log.create({ service: "push-relay" })
let state: State | undefined
function obj(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null
}
function str(input: unknown) {
return typeof input === "string" && input.length > 0 ? input : undefined
}
function norm(input: string) {
return input.replace(/\/+$/, "")
}
function secretHash(input: string) {
if (!input) return "none"
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
}
function serverID(input: { relayURL: string; relaySecret: string }) {
return createHash("sha256").update(`${input.relayURL}|${input.relaySecret}`).digest("hex").slice(0, 16)
}
/**
* Classify an IPv4 address into a reachability tier.
* Lower number = more likely reachable from an external/overlay network device.
*
* 0 public / routable
* 1 CGNAT / shared (100.64.0.0/10) used by Tailscale, Cloudflare WARP, carrier NAT, etc.
* 2 private LAN (10.0.0.0/8, 172.16-31.x, 192.168.x)
* 3 link-local (169.254.x)
* 4 loopback (127.x)
*/
function ipTier(address: string): number {
const parts = address.split(".")
if (parts.length !== 4) return 4
const a = Number(parts[0])
const b = Number(parts[1])
// loopback 127.0.0.0/8
if (a === 127) return 4
// link-local 169.254.0.0/16
if (a === 169 && b === 254) return 3
// private 10.0.0.0/8
if (a === 10) return 2
// private 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return 2
// private 192.168.0.0/16
if (a === 192 && b === 168) return 2
// CGNAT / shared address space 100.64.0.0/10 (100.64.x 100.127.x)
if (a === 100 && b >= 64 && b <= 127) return 1
// everything else is routable
return 0
}
function advertiseURL(input: string, port: number): string | undefined {
const raw = input.trim()
if (!raw) return
try {
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
if (!parsed.hostname) return
if (!parsed.port) {
parsed.port = String(port)
}
return norm(`${parsed.protocol}//${parsed.host}`)
} catch {
return
}
}
function list(hostname: string, port: number, advertised: string[] = []) {
const seen = new Set<string>()
const preferred: string[] = []
const hosts: Array<{ url: string; tier: number }> = []
const addPreferred = (input: string) => {
const url = advertiseURL(input, port)
if (!url) return
if (seen.has(url)) return
seen.add(url)
preferred.push(url)
}
const add = (host: string) => {
if (!host) return
if (host === "0.0.0.0") return
if (host === "::") return
const url = `http://${host}:${port}`
if (seen.has(url)) return
seen.add(url)
hosts.push({ url, tier: ipTier(host) })
}
advertised.forEach(addPreferred)
add(hostname)
const nets = Object.values(os.networkInterfaces())
.flatMap((item) => item ?? [])
.filter((item) => item.family === "IPv4" && !item.internal)
.map((item) => item.address)
nets.forEach(add)
// sort: most externally reachable first, loopback last
hosts.sort((a, b) => a.tier - b.tier)
return [...preferred, ...hosts.map((item) => item.url)]
}
function map(event: Event): { type: Type; sessionID: string } | undefined {
if (!obj(event.properties)) return
if (event.type === "permission.asked") {
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
return { type: "permission", sessionID }
}
if (event.type === "session.error") {
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
return { type: "error", sessionID }
}
if (event.type === "session.idle") {
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
return { type: "complete", sessionID }
}
if (event.type !== "session.status") return
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
if (!obj(event.properties.status)) return
if (event.properties.status.type !== "idle") return
return { type: "complete", sessionID }
}
function text(input: string) {
return input.replace(/\s+/g, " ").trim()
}
function words(input: string, max = 18, chars = 140) {
const clean = text(input)
if (!clean) return ""
const split = clean.split(" ")
const cut = split.slice(0, max).join(" ")
if (cut.length <= chars && split.length <= max) return cut
const short = cut.slice(0, chars).trim()
return short.endsWith("…") ? short : `${short}`
}
function fallback(input: Type) {
if (input === "complete") return "Session complete."
if (input === "permission") return "OpenCode needs your permission decision."
return "OpenCode reported an error for your session."
}
async function notify(input: { type: Type; sessionID: string }): Promise<Notify> {
const out: Notify = {
type: input.type,
sessionID: input.sessionID,
}
try {
const [{ Session }, { MessageV2 }] = await Promise.all([import("@/session"), import("@/session/message-v2")])
const sessionID = SessionID.make(input.sessionID)
const session = await Session.get(sessionID)
out.title = session.title
let latestUser: string | undefined
for await (const msg of MessageV2.stream(sessionID)) {
const body = msg.parts
.map((part) => {
if (part.type !== "text") return ""
if (part.ignored) return ""
return part.text
})
.filter(Boolean)
.join(" ")
const next = words(body)
if (!next) continue
if (msg.info.role === "assistant") {
out.body = next
break
}
if (!latestUser && msg.info.role === "user") {
latestUser = next
}
}
if (!out.body) {
out.body = latestUser
}
} catch (error) {
log.info("notification metadata unavailable", {
type: input.type,
sessionID: input.sessionID,
error: String(error),
})
}
if (!out.title) out.title = `Session ${input.type}`
if (!out.body) out.body = fallback(input.type)
return out
}
function dedupe(input: { type: Type; sessionID: string }) {
if (input.type !== "complete") return false
const next = state
if (!next) return false
const now = Date.now()
if (next.seen.size > 2048 || now - next.gc > 60_000) {
next.gc = now
for (const [key, time] of next.seen) {
if (now - time > 60_000) {
next.seen.delete(key)
}
}
const drop = next.seen.size - 2048
if (drop > 0) {
let i = 0
for (const key of next.seen.keys()) {
next.seen.delete(key)
i += 1
if (i >= drop) break
}
}
}
const key = `${input.type}:${input.sessionID}`
const prev = next.seen.get(key)
next.seen.set(key, now)
if (!prev) return false
return now - prev < 5_000
}
async function post(input: { type: Type; sessionID: string }) {
const next = state
if (!next) return false
if (dedupe(input)) return true
const content = await notify(input)
console.log("[ APN RELAY ] posting event", {
serverID: next.pair.serverID,
relayURL: next.relayURL,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
log.info("[ APN RELAY ] posting event", {
serverID: next.pair.serverID,
relayURL: next.relayURL,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
void fetch(`${next.relayURL}/v1/event`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: next.relaySecret,
serverID: next.pair.serverID,
eventType: input.type,
sessionID: input.sessionID,
title: content.title,
body: content.body,
}),
})
.then(async (res) => {
if (res.ok) {
console.log("[ APN RELAY ] relay accepted event", {
status: res.status,
serverID: next.pair.serverID,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
log.info("[ APN RELAY ] relay accepted event", {
status: res.status,
serverID: next.pair.serverID,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
return
}
const error = await res.text().catch(() => "")
log.warn("relay post failed", {
status: res.status,
type: input.type,
sessionID: input.sessionID,
title: content.title,
error,
})
})
.catch((error) => {
log.warn("relay post failed", {
type: input.type,
sessionID: input.sessionID,
title: content.title,
error: String(error),
})
})
return true
}
export namespace PushRelay {
export function start(input: Input) {
const relayURL = norm(input.relayURL.trim())
const relaySecret = input.relaySecret.trim()
if (!relayURL) return
if (!relaySecret) return
stop()
const pair: Pair = {
v: 1,
serverID: serverID({ relayURL, relaySecret }),
relayURL,
relaySecret,
hosts: list(input.hostname, input.port, input.advertiseHosts ?? []),
}
const callback = (event: { payload: Event }) => {
const next = map(event.payload)
if (!next) return
void post(next)
}
GlobalBus.on("event", callback)
const unsub = () => {
GlobalBus.off("event", callback)
}
state = {
relayURL,
relaySecret,
pair,
stop: unsub,
seen: new Map(),
gc: 0,
}
log.info("enabled", {
relayURL,
hosts: pair.hosts,
})
return pair
}
export function stop() {
const next = state
if (!next) return
state = undefined
next.stop()
}
export function status() {
const next = state
if (!next) {
return {
enabled: false,
relaySecretSet: false,
} as const
}
return {
enabled: true,
relaySecretSet: next.relaySecret.length > 0,
} as const
}
export function pair() {
return state?.pair
}
export function test(input: { type: Type; sessionID: string }) {
void post(input)
return true
}
export function auth(input: string) {
const next = state
if (!next) return false
return next.relaySecret === input
}
}