// `plane:*` — the project file plane over the loopback bridge, so a browser // tab can reach a real local box. the envelope and its constraints are decided // in plans/rnx-cloud/transport-decision.md; this is the local half. // // the vocabulary is exactly ProjectFilePlane's members, because the point is // that a remote plane is a third provider behind that seam rather than a // second file API. it is remotable without changing the interface because // `index()` is synchronous but `refresh()` is the async step the contract // already requires before an index read: a remote plane refreshes into a local // cache and reads that. // // WHY A ROOT MUST BE REGISTERED FIRST. this listener is loopback-only, but a // WebSocket to 127.0.0.1 is not subject to CORS, so any page the user visits // can open one. if `plane:open` took an arbitrary path, that page could read // any file on the machine through this daemon. a root is reachable only while // a box has registered it, and the registration dies with its socket. import fs from 'node:fs' import path from 'node:path' import { dispatchPlaneCommand, indexVersion } from '@rnx/box/plane' import { CheckoutFilePlane } from '../../cli/commands/box/checkout-plane.ts' import type { ProjectFilePlane } from '@rnx/box/plane' import type { WebSocket } from 'ws' const WS_OPEN = 1 interface OpenPlane { root: string plane: ProjectFilePlane version: string sockets: Set } export class PlaneNotRegisteredError extends Error {} export class PlaneHost { // roots a box authorized, each tied to the socket that authorized it private registered = new Map>() private open = new Map() private byRoot = new Map() private nextPlaneId = 1 /** handle a plane:* message. returns true iff it was one. */ async handleMessage(ws: WebSocket, msg: unknown): Promise { if (typeof msg !== 'object' || msg === null) return false const record: Record = { ...msg } const type = record.type if (typeof type !== 'string' || !type.startsWith('plane:')) return false const id = record.id try { this.respond(ws, id, await this.dispatch(ws, type, record)) } catch (err) { const code = err instanceof PlaneNotRegisteredError ? 'plane-not-registered' : undefined this.respondError(ws, id, err instanceof Error ? err.message : String(err), code) } return true } unregisterSocket(ws: WebSocket): void { for (const [root, sockets] of [...this.registered]) { if (!sockets.delete(ws)) continue if (sockets.size > 0) continue // the box that authorized this root is gone, so drop the plane with it // rather than leaving a reachable handle behind. this.registered.delete(root) const planeId = this.byRoot.get(root) if (planeId) { this.open.delete(planeId) this.byRoot.delete(root) } } for (const [planeId, entry] of [...this.open]) { entry.sockets.delete(ws) if (entry.sockets.size === 0 && !this.registered.has(entry.root)) { this.open.delete(planeId) this.byRoot.delete(entry.root) } } } /** roots currently reachable, for the daemon's status output. */ registeredRoots(): string[] { return [...this.registered.keys()] } private async dispatch( ws: WebSocket, type: string, msg: Record, ): Promise { switch (type) { case 'plane:register': { const root = this.realRoot(String(msg.root ?? '')) const sockets = this.registered.get(root) ?? new Set() sockets.add(ws) this.registered.set(root, sockets) // `holders` INCLUDING this one, because two boxes over one checkout is // a mistake only the daemon can see: CheckoutFilePlane re-runs // `git ls-files` before every command, so each box changes the file // set the other is working from mid-run. the daemon reports it rather // than refusing, because a box whose socket dropped and reconnected // can briefly race its own stale entry, and a refusal there would // brick a legitimate restart. the caller decides; see box.ts, which // already refuses a box inside a box for the same reason. return { root, holders: sockets.size } } // READ ONLY, and that is the whole point of it existing beside the // `holders` field on plane:register. // // registration is the AUTHORIZATION step: it is what permits any page on // this machine to open that root over a loopback socket that CORS does // not protect. so a caller asking "is anyone else in this checkout" // cannot be made to authorize the root in order to find out, even // briefly. it also means the count is the honest one: no box reads 0 and // a single box reads 1, where asking through register would have made // every answer one higher and forced a threshold that counts the asker. case 'plane:status': { const root = this.realRoot(String(msg.root ?? '')) const sockets = this.registered.get(root) return { root, holders: sockets?.size ?? 0, registered: Boolean(sockets) } } case 'plane:open': { const root = this.realRoot(String(msg.root ?? '')) if (!this.registered.has(root)) { throw new PlaneNotRegisteredError( `no box has registered ${root}; start one with \`rnx box\` there`, ) } const existingId = this.byRoot.get(root) const existing = existingId ? this.open.get(existingId) : undefined if (existingId && existing) { existing.sockets.add(ws) return { planeId: existingId, version: existing.version } } const plane = new CheckoutFilePlane(root) await plane.refresh() const planeId = `plane-${this.nextPlaneId++}` const entry: OpenPlane = { root, plane, version: indexVersion(plane.index()), sockets: new Set([ws]), } this.open.set(planeId, entry) this.byRoot.set(root, planeId) return { planeId, version: entry.version } } case 'plane:close': { const entry = this.open.get(String(msg.planeId ?? '')) if (entry) entry.sockets.delete(ws) return { ok: true } } default: { const entry = this.entry(msg) const handled = await dispatchPlaneCommand(entry.plane, type, msg) if (!handled) throw new Error(`unknown plane command: ${type}`) // no companion change event on a write. the write lands on the real // checkout and the dev server's own watcher sees it, so a notification // here would be a second channel for something the filesystem already // reports. the cloud placement, whose store nothing can watch, is where // a write has to fan one out. if (handled.version) entry.version = handled.version return handled.result } } } private entry(msg: Record): OpenPlane { const entry = this.open.get(String(msg.planeId ?? '')) if (!entry) throw new Error(`unknown planeId: ${String(msg.planeId)}`) return entry } // resolve symlinks and relative segments before comparing, so a registered // root and an opened one cannot disagree about the same directory. private realRoot(root: string): string { if (!root) throw new Error('root is required') const resolved = path.resolve(root) let real: string try { real = fs.realpathSync(resolved) } catch { throw new Error(`no such directory: ${resolved}`) } if (!fs.statSync(real).isDirectory()) throw new Error(`not a directory: ${real}`) return real } private respond(ws: WebSocket, id: unknown, result: unknown): void { if (ws.readyState !== WS_OPEN) return try { ws.send(JSON.stringify({ id, result })) } catch {} } private respondError(ws: WebSocket, id: unknown, error: string, code?: string): void { if (ws.readyState !== WS_OPEN) return try { ws.send(JSON.stringify({ id, error, ...(code ? { code } : {}) })) } catch {} } }