UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

761 lines (658 loc) 23.8 kB
// main daemon HTTP/WebSocket server import * as http from 'node:http' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' const debugLogPath = path.join(os.homedir(), '.one', 'daemon-debug.log') function debugLog(msg: string) { fs.appendFileSync(debugLogPath, `${new Date().toISOString()} ${msg}\n`) } // cache app names from config files const serverAppNames = new Map<string, string>() // try to get app name from app.json, app.config.ts, or fallback to dirname async function getAppNameForServer(root: string): Promise<string | null> { // check cache first const cached = serverAppNames.get(root) if (cached) return cached try { // try app.json first const appJsonPath = path.join(root, 'app.json') if (fs.existsSync(appJsonPath)) { const content = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8')) const name = content.expo?.name || content.name if (name) { serverAppNames.set(root, name) return name } } // try app.config.ts - execute it to get the config const appConfigTsPath = path.join(root, 'app.config.ts') if (fs.existsSync(appConfigTsPath)) { // read the file and look for name pattern const content = fs.readFileSync(appConfigTsPath, 'utf-8') // simple regex to find name: "..." or name: '...' const nameMatch = content.match(/name:\s*['"]([^'"]+)['"]/) if (nameMatch) { const name = nameMatch[1] serverAppNames.set(root, name) return name } } // try app.config.js const appConfigJsPath = path.join(root, 'app.config.js') if (fs.existsSync(appConfigJsPath)) { const content = fs.readFileSync(appConfigJsPath, 'utf-8') const nameMatch = content.match(/name:\s*['"]([^'"]+)['"]/) if (nameMatch) { const name = nameMatch[1] serverAppNames.set(root, name) return name } } // fallback to directory name const dirName = path.basename(root) if (dirName) { serverAppNames.set(root, dirName) return dirName } } catch (err) { debugLog(`Failed to get app name for ${root}: ${err}`) } return null } // unified mapping: tracks what we know about each client identifier interface ClientInfo { serverId: string // the server this client should route to simulatorUdid?: string // the simulator (if known) matchedBy: 'user-agent' | 'tui' | 'auto' // how we learned this lastUsed: number // timestamp for TTL cleanup } const clientMappings = new Map<string, ClientInfo>() // cleanup stale mappings every 30s const MAPPING_TTL_MS = 3600000 // 1 hour setInterval(() => { const now = Date.now() let cleaned = 0 for (const [key, info] of clientMappings) { if (now - info.lastUsed > MAPPING_TTL_MS) { clientMappings.delete(key) cleaned++ } } if (cleaned > 0) { debugLog(`Cleaned ${cleaned} stale mappings`) } }, 30000) // parse one-route-id cookie from request function getRouteIdFromCookies(req: http.IncomingMessage): string | null { const cookieHeader = req.headers.cookie if (!cookieHeader) return null const match = cookieHeader.match(/one-route-id=([^;]+)/) return match ? match[1] : null } // pending mappings: when TUI connects sim to server, map next request's identifiers // key: serverId, value: simulatorUdid const pendingMappings = new Map<string, string>() // called by TUI when user manually connects a simulator to a server export function setPendingMapping(serverId: string, simulatorUdid: string) { pendingMappings.set(serverId, simulatorUdid) debugLog( `Pending mapping: next request to server ${serverId} will map to sim ${simulatorUdid}` ) } // clear ALL mappings for a specific simulator (called when TUI changes a cable) export function clearMappingsForSimulator(simulatorUdid: string) { let count = 0 for (const [key, info] of clientMappings) { if (info.simulatorUdid === simulatorUdid) { clientMappings.delete(key) count++ } } debugLog(`Cleared ${count} mappings for simulator ${simulatorUdid}`) } // for backwards compat - clear all mappings (used sparingly) export function clearAllMappings() { const count = clientMappings.size clientMappings.clear() debugLog(`Cleared all ${count} client mappings`) } // get all simulator -> server mappings (for TUI visualization) export function getSimulatorMappings(): Map<string, string> { const result = new Map<string, string>() for (const [_key, info] of clientMappings) { if (info.simulatorUdid) { result.set(info.simulatorUdid, info.serverId) } } return result } // set a simulator -> server mapping directly (called by TUI when cable connects) export function setSimulatorMapping(simulatorUdid: string, serverId: string) { // use a synthetic key for TUI-set mappings const key = `tui:${simulatorUdid}` clientMappings.set(key, { serverId, simulatorUdid, matchedBy: 'tui', lastUsed: Date.now(), }) debugLog(`TUI set mapping: sim=${simulatorUdid} -> server=${serverId}`) } // try to match user-agent app name to a registered server async function matchUserAgentToServer( headers: http.IncomingHttpHeaders, servers: ServerRegistration[] ): Promise<ServerRegistration | null> { const userAgent = headers['user-agent'] if (!userAgent || typeof userAgent !== 'string') return null // extract app name from user-agent (first part before /) const uaAppName = userAgent.split('/')[0] if (!uaAppName) return null debugLog(`Trying to match user-agent app "${uaAppName}" to servers`) for (const server of servers) { const appName = await getAppNameForServer(server.root) if (!appName) continue // normalize names for comparison (remove spaces, lowercase) const normalizedUa = uaAppName.toLowerCase().replace(/\s+/g, '') const normalizedApp = appName.toLowerCase().replace(/\s+/g, '') debugLog(` Comparing "${normalizedUa}" to server app "${normalizedApp}"`) // check if they match (exact or contains) if ( normalizedUa === normalizedApp || normalizedUa.includes(normalizedApp) || normalizedApp.includes(normalizedUa) ) { debugLog(` Matched! ${uaAppName} -> ${server.root}`) return server } } return null } // check if user-agent is generic expo go (can't identify the app) function isGenericExpoAgent(ua: string): boolean { return ua.startsWith('Expo/') || ua.startsWith('Exponent/') } // extract app name from user-agent: "TakeoutDev/1 CFNetwork/..." -> "TakeoutDev" function extractAppNameFromUA(ua: string): string | null { const firstPart = ua.split(' ')[0] || '' const appName = firstPart.split('/')[0] return appName || null } // track recent HTTP connections: remotePort -> serverId // WebSocket from same port likely belongs to same app const recentConnections = new Map<number, { serverId: string; timestamp: number }>() const CONNECTION_MEMORY_MS = 5000 // remember connections for 5 seconds // get the best identifier key for this request function getPrimaryIdentifier(headers: http.IncomingHttpHeaders): string | null { const userAgent = headers['user-agent'] || '' // built apps have unique user-agent like "TakeoutDev/1" if (!isGenericExpoAgent(userAgent)) { const appName = extractAppNameFromUA(userAgent) if (appName) { return `app:${appName}` } } // expo go - use eas-client-id if available (highest confidence) const easClientId = headers['eas-client-id'] if (easClientId && typeof easClientId === 'string') { return `eas:${easClientId}` } // fallback: use full user-agent as identifier (even for Expo Go) if (userAgent) { return `ua:${userAgent}` } return null } // result of looking up client info interface ClientLookup { info: ClientInfo | null identifier: string | null } // look up what we know about this client function lookupClient(headers: http.IncomingHttpHeaders): ClientLookup { const identifier = getPrimaryIdentifier(headers) if (!identifier) { return { info: null, identifier: null } } const info = clientMappings.get(identifier) if (info) { // update lastUsed on cache hit info.lastUsed = Date.now() } return { info: info || null, identifier } } // save client info function saveClientMapping( identifier: string, serverId: string, simulatorUdid: string | undefined, matchedBy: ClientInfo['matchedBy'] ) { clientMappings.set(identifier, { serverId, simulatorUdid, matchedBy, lastUsed: Date.now(), }) debugLog( `Saved mapping: ${identifier} -> server=${serverId}, sim=${simulatorUdid || 'unknown'}, via=${matchedBy}` ) } import type { DaemonState, ServerRegistration } from './types' import { createRegistry, findServersByBundleId, findServerById, getAllServers, getRoute, setRoute, clearRoute, touchServer, pruneDeadServers, checkServerAlive, registerServer, } from './registry' import { createIPCServer, getSocketPath, cleanupSocket, readServerFiles } from './ipc' import { proxyHttpRequest, proxyWebSocket } from './proxy' import { pickServer, getBootedSimulators, resolvePendingPicker } from './picker' import colors from 'picocolors' const DEFAULT_PORT = 8081 interface DaemonOptions { port?: number host?: string quiet?: boolean } // allow TUI to override route mode let routeModeOverride: 'most-recent' | 'ask' | null = null export function setRouteMode(mode: 'most-recent' | 'ask' | null) { routeModeOverride = mode } // track which daemon state is active for marking servers let activeDaemonState: DaemonState | null = null // infer simulator from unmapped sims async function inferSimulator( clientInfo: ClientInfo | null ): Promise<string | undefined> { if (clientInfo?.simulatorUdid) return clientInfo.simulatorUdid const simulators = await getBootedSimulators() const existingMappings = getSimulatorMappings() const unmappedSims = simulators.filter((s) => !existingMappings.has(s.udid)) return unmappedSims[0]?.udid || simulators[0]?.udid } // core routing logic - returns the server to route to async function resolveServer( state: DaemonState, headers: http.IncomingHttpHeaders, servers: ServerRegistration[], bundleId: string | null ): Promise<{ server: ServerRegistration; learned: boolean }> { const { info: clientInfo, identifier } = lookupClient(headers) debugLog( `resolveServer: identifier=${identifier}, clientInfo=${JSON.stringify(clientInfo)}` ) // helper to learn mapping const learnMapping = async ( server: ServerRegistration, matchedBy: ClientInfo['matchedBy'] ) => { if (identifier && !clientInfo?.simulatorUdid) { const simUdid = await inferSimulator(clientInfo) if (simUdid) { saveClientMapping(identifier, server.id, simUdid, matchedBy) return true } } return false } // single server - always use it, but learn mapping if (servers.length === 1) { const server = servers[0] const learned = await learnMapping(server, 'auto') debugLog(`Single server: ${server.root}`) return { server, learned } } // PRIORITY 0: pending TUI mapping if (pendingMappings.size > 0 && identifier) { for (const [serverId, simUdid] of pendingMappings) { const server = findServerById(state, serverId) if (server && servers.some((s) => s.id === serverId)) { debugLog(`TUI pending mapping: ${server.root}, sim=${simUdid}`) saveClientMapping(identifier, serverId, simUdid, 'tui') pendingMappings.delete(serverId) return { server, learned: true } } } } // PRIORITY 1: TUI cable route (if we know the simulator) if (clientInfo?.simulatorUdid) { const simRoute = getRoute(state, `sim:${clientInfo.simulatorUdid}`) if (simRoute) { const server = findServerById(state, simRoute.serverId) if (server) { debugLog(`TUI cable route: sim=${clientInfo.simulatorUdid} -> ${server.root}`) return { server, learned: false } } } } // PRIORITY 2: cached client->server mapping if (clientInfo?.serverId) { const server = findServerById(state, clientInfo.serverId) if (server) { debugLog(`Cached mapping: ${identifier} -> ${server.root}`) return { server, learned: false } } } // PRIORITY 3: user-agent app name matching const userAgent = headers['user-agent'] || '' if (!isGenericExpoAgent(userAgent)) { const matchedServer = await matchUserAgentToServer(headers, servers) if (matchedServer) { debugLog(`UA match: ${extractAppNameFromUA(userAgent)} -> ${matchedServer.root}`) await learnMapping(matchedServer, 'user-agent') return { server: matchedServer, learned: true } } } // PRIORITY 4: fallback route const routeKey = bundleId || 'default' const fallbackRoute = getRoute(state, bundleId || '') || getRoute(state, 'default') if (fallbackRoute) { const server = findServerById(state, fallbackRoute.serverId) if (server) { debugLog(`Fallback route: ${server.root}`) await learnMapping(server, 'auto') return { server, learned: true } } } // PRIORITY 5: most recent server const mostRecent = [...servers].sort((a, b) => b.registeredAt - a.registeredAt)[0] debugLog(`Most recent fallback: ${mostRecent.root}`) setRoute(state, routeKey, mostRecent.id) await learnMapping(mostRecent, 'auto') return { server: mostRecent, learned: true } } function proxyAndTouch( req: http.IncomingMessage, res: http.ServerResponse, server: ServerRegistration ) { // check for pending mapping - when TUI connected a sim to this server const pendingSimId = pendingMappings.get(server.id) if (pendingSimId) { const identifier = getPrimaryIdentifier(req.headers) if (identifier) { // KEY: learn that this client identifier belongs to this simulator AND server saveClientMapping(identifier, server.id, pendingSimId, 'tui') pendingMappings.delete(server.id) } } // mark this server as recently active if (activeDaemonState) { touchServer(activeDaemonState, server.id) } proxyHttpRequest(req, res, server) } export async function startDaemon(options: DaemonOptions = {}) { const port = options.port || DEFAULT_PORT const host = options.host || '0.0.0.0' const quiet = options.quiet || false const log = quiet ? (..._args: any[]) => {} : console.log const state = createRegistry() activeDaemonState = state // recover servers from disk (written by dev servers) const persistedServers = readServerFiles() for (const ps of persistedServers) { // verify server is actually still running const alive = await checkServerAlive({ port: ps.port } as ServerRegistration) if (alive) { registerServer(state, { port: ps.port, bundleId: ps.bundleId, root: ps.root, }) log(colors.cyan(`[daemon] Recovered server: ${ps.bundleId} → :${ps.port}`)) } } // start IPC server for CLI communication const ipcServer = createIPCServer( state, (id) => { const server = findServerById(state, id) if (server) { const shortRoot = server.root.replace(process.env.HOME || '', '~') log( colors.green( `[daemon] Server registered: ${server.bundleId} → :${server.port} (${shortRoot})` ) ) } }, (id) => { log(colors.yellow(`[daemon] Server unregistered: ${id}`)) } ) // create HTTP server const httpServer = http.createServer(async (req, res) => { debugLog(`${req.method} ${req.url}`) // daemon management endpoints if (req.url?.startsWith('/__daemon')) { await handleDaemonEndpoint(req, res, state) return } // parse app from query string const url = new URL(req.url || '/', `http://${req.headers.host}`) const bundleId = url.searchParams.get('app') // get available servers const servers = bundleId ? findServersByBundleId(state, bundleId) : getAllServers(state) if (servers.length === 0) { res.writeHead(404) res.end(bundleId ? `No server for app: ${bundleId}` : 'No servers registered') return } // resolve which server to use (shared logic) const { server } = await resolveServer(state, req.headers, servers, bundleId) // set cookie for WebSocket correlation - this is the key fix! // WebSocket upgrades will include this cookie, allowing us to route them correctly res.setHeader('Set-Cookie', `one-route-id=${server.id}; Path=/; Max-Age=3600`) // remember this connection for WebSocket matching (fallback) const remotePort = req.socket?.remotePort if (remotePort) { recentConnections.set(remotePort, { serverId: server.id, timestamp: Date.now() }) debugLog(`HTTP: port ${remotePort} -> ${server.root}`) } proxyAndTouch(req, res, server) }) // handle WebSocket upgrades (HMR, etc) httpServer.on('upgrade', async (req, rawSocket, head) => { const socket = rawSocket as import('node:net').Socket const url = new URL(req.url || '/', `http://${req.headers.host}`) const bundleId = url.searchParams.get('app') const servers = bundleId ? findServersByBundleId(state, bundleId) : getAllServers(state) if (servers.length === 0) { socket.end('HTTP/1.1 404 Not Found\r\n\r\n') return } let server: ServerRegistration | undefined // PRIORITY 1: cookie-based routing (most reliable) // HTTP requests set a cookie with the server ID, WebSocket upgrades include it const routeIdFromCookie = getRouteIdFromCookies(req) if (routeIdFromCookie) { server = findServerById(state, routeIdFromCookie) if (server && servers.some((s) => s.id === server!.id)) { debugLog(`WebSocket: cookie route -> ${server.root}`) } else { server = undefined // cookie pointed to invalid server } } // PRIORITY 2: try to match by recent connection from same port (fallback) if (!server) { const remotePort = req.socket?.remotePort if (remotePort) { const recent = recentConnections.get(remotePort) if (recent && Date.now() - recent.timestamp < CONNECTION_MEMORY_MS) { server = findServerById(state, recent.serverId) if (server) { debugLog(`WebSocket: port ${remotePort} matched to ${server.root}`) } } } } // PRIORITY 3: fallback to regular resolution if (!server) { const result = await resolveServer(state, req.headers, servers, bundleId) server = result.server debugLog(`WebSocket: fallback -> ${server.root}`) } touchServer(state, server.id) proxyWebSocket(req, socket, head, server) }) // start listening httpServer.listen(port, host, () => { log(colors.cyan('\n═══════════════════════════════════════════════════')) log(colors.cyan(' one daemon')) log(colors.cyan('═══════════════════════════════════════════════════')) log( `\n Listening on ${colors.green(`http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`)}` ) log(` IPC socket: ${colors.dim(getSocketPath())}`) log('') log(colors.dim(' Waiting for dev servers to register...')) log(colors.dim(" Run 'one dev' in your project directories")) log('') }) // start health check polling to prune dead servers const HEALTH_CHECK_INTERVAL = 5000 // 5 seconds const healthCheckInterval = setInterval(async () => { const prunedCount = await pruneDeadServers(state, (server) => { log( colors.yellow( `[daemon] Pruned dead server: ${server.bundleId} (port ${server.port})` ) ) }) if (prunedCount > 0) { log(colors.dim(`[daemon] Pruned ${prunedCount} dead server(s)`)) } }, HEALTH_CHECK_INTERVAL) // graceful shutdown const shutdown = () => { log(colors.yellow('\n[daemon] Shutting down...')) clearInterval(healthCheckInterval) httpServer.close() ipcServer.close() cleanupSocket() process.exit(0) } process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) return { httpServer, ipcServer, state, shutdown, healthCheckInterval, } } async function handleDaemonEndpoint( req: http.IncomingMessage, res: http.ServerResponse, state: DaemonState ) { const url = new URL(req.url || '/', `http://${req.headers.host}`) // GET /__daemon/status if (url.pathname === '/__daemon/status') { const servers = getAllServers(state) const simulators = await getBootedSimulators() const simMappings = getSimulatorMappings() const simulatorRoutes: Record<string, string> = {} for (const [udid, serverId] of simMappings) { simulatorRoutes[udid] = serverId } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end( JSON.stringify( { servers: servers.map((s) => ({ id: s.id, port: s.port, bundleId: s.bundleId, root: s.root, })), simulators, simulatorRoutes, routeMode: routeModeOverride || 'most-recent', }, null, 2 ) ) return } // POST /__daemon/route?bundleId=...&serverId=... if (url.pathname === '/__daemon/route' && req.method === 'POST') { const bundleId = url.searchParams.get('bundleId') const serverId = url.searchParams.get('serverId') if (!bundleId || !serverId) { res.writeHead(400) res.end('Missing bundleId or serverId') return } const server = findServerById(state, serverId) if (!server) { res.writeHead(404) res.end('Server not found') return } setRoute(state, bundleId, serverId) // also resolve any pending picker resolvePendingPicker(bundleId, serverId) res.writeHead(200) res.end('Route set') return } // POST /__daemon/simulator-route?simulatorUdid=...&serverId=... // used by tray app to set simulator -> server mappings if (url.pathname === '/__daemon/simulator-route' && req.method === 'POST') { const simulatorUdid = url.searchParams.get('simulatorUdid') const serverId = url.searchParams.get('serverId') if (!simulatorUdid || !serverId) { res.writeHead(400) res.end('Missing simulatorUdid or serverId') return } const server = findServerById(state, serverId) if (!server) { res.writeHead(404) res.end('Server not found') return } // set the mapping (same as TUI cable connect) setSimulatorMapping(simulatorUdid, serverId) setPendingMapping(serverId, simulatorUdid) setRoute(state, `sim:${simulatorUdid}`, serverId) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: true })) return } // DELETE /__daemon/simulator-route?simulatorUdid=... // used by tray app to clear simulator -> server mappings if (url.pathname === '/__daemon/simulator-route' && req.method === 'DELETE') { const simulatorUdid = url.searchParams.get('simulatorUdid') if (!simulatorUdid) { res.writeHead(400) res.end('Missing simulatorUdid') return } clearMappingsForSimulator(simulatorUdid) clearRoute(state, `sim:${simulatorUdid}`) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: true })) return } res.writeHead(404) res.end('Not found') }