Files
dsh-remote/index.js
T
lyuksovannyy 8872e73e76 dsh-remote 0.1.0: master/slave remote control for DSH
- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing
  codes, per-device keys (hashed), revoke/remove
- /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with
  per-device cookie minting; slave->slave relay through the master
- Devices sidebar panel (all devices' sessions, new session on device),
  Remote devices settings page, finished-session toasts
- plugin + config sync master->slave: registry bundles via pluginManager,
  link: plugins as tarballs, cordis.patch.yml into a managed bundle layer
  (!!js preserved), allow/deny, opt-in per-secret credential sync
- deploy helpers + e2e probes, unit tests
2026-10-11 08:23:19 +00:00

728 lines
32 KiB
JavaScript

// @local/dsh-remote — Host side.
//
// Master/slave remote control for DSH (Claude-Desktop-remote style):
// * slaves keep one authenticated WebSocket link to the master (outbound, NAT-friendly);
// * every device's DSH is reachable from every other device at /r/<device>/ (HTTP +
// WebSocket tunnelled over the link; sessions execute on the owning machine);
// slave→slave goes through the master's relay;
// * the master aggregates device + session lists for the Devices UI;
// * plugins and config are synced master → slave (managed bundle layer, allow/deny,
// opt-in per-secret credential sync).
//
// HTTP routes (all behind DSH's own browser auth via ctx.connection.requestRejection):
// GET /dsh-remote/state → role, self, link status, devices (+ online)
// GET /dsh-remote/sessions → sessions of every reachable device
// POST /dsh-remote/pairing → master: create a one-time pairing code
// POST /dsh-remote/pair → slave: pair with {masterUrl, code}
// POST /dsh-remote/unpair → slave: forget the master
// POST /dsh-remote/devices/<id>/revoke → master: revoke a device key
// POST /dsh-remote/devices/<id>/remove → master: delete a device
// POST /dsh-remote/devices/<id>/sync → master: set {enabled, allow, deny, secrets}
// POST /dsh-remote/sync → slave: pull + apply now
// GET /dsh-remote/sync/status → slave: last sync result / master: manifest summary
// POST /dsh-remote/new-session → {device, cwd?} create a session on a device
// * /r/<device>/... → proxy to that device's DSH
// Upgrade routes:
// /dsh-remote/link → slave → master link (device id + key headers)
// /r/<device>/api/remote.mux → DSH Remote stream WebSocket of another device
import os from 'node:os'
import { acceptUpgrade, connect, rejectUpgrade } from './lib/ws.mjs'
import { Mux } from './lib/mux.mjs'
import { Store, randomId, setProfileDir, slug } from './lib/store.mjs'
import { CookieJar } from './lib/auth-cookie.mjs'
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade } from './lib/proxy.mjs'
import * as sync from './lib/sync.mjs'
export const name = 'dsh-remote'
export const inject = {
webServer: { required: true },
connection: { required: true },
credentials: { required: false },
}
/** Optional services, read at call time (they may load after this plugin). */
function svc(ctx, key) {
try { return ctx.get(key) } catch { return undefined }
}
const MAX_BODY = 256 * 1024
const PREFIX = '/r/'
const LINK_PATH = '/dsh-remote/link'
const MUX_PATH = '/api/remote.mux'
function sendJson(res, status, payload) {
res.statusCode = status
res.setHeader('content-type', 'application/json; charset=utf-8')
res.setHeader('cache-control', 'no-store')
res.end(JSON.stringify(payload))
}
async function readJson(req) {
const chunks = []
let size = 0
for await (const c of req) {
size += c.length
if (size > MAX_BODY) throw Object.assign(new Error('body too large'), { status: 413 })
chunks.push(c)
}
if (!size) return {}
const v = JSON.parse(Buffer.concat(chunks).toString('utf8'))
if (!v || typeof v !== 'object' || Array.isArray(v)) throw Object.assign(new Error('body must be an object'), { status: 400 })
return v
}
const httpErr = (status, message) => Object.assign(new Error(message), { status })
export function apply(ctx, config = {}) {
const prof = svc(ctx, 'profileContext')
if (prof?.dir) setProfileDir(prof.dir)
const store = new Store()
const self = store.identity(config.deviceName || os.hostname())
if (config.deviceName && config.deviceName !== self.name) {
self.name = config.deviceName
store.save()
}
const role = config.role === 'master' ? 'master' : config.role === 'slave' ? 'slave' : (store.master ? 'slave' : 'master')
const log = ctx.logger ?? console
const cookies = new CookieJar(ctx)
const localTarget = () => ({ host: '127.0.0.1', port: ctx.webServer.port })
// device id -> { mux, info, connectedAt } (master: slaves; slave: { master })
const links = new Map()
const listeners = new Set()
const notify = (event) => { for (const l of listeners) { try { l(event) } catch { /* ignore */ } } }
let lastSync = store.data.lastSync || null
let masterLink = null // slave only
// ------------------------------------------------------------------ stream routing
// A stream's meta.to names the device that must serve it. "self" streams hit local DSH.
// The master relays streams whose target is another slave.
function serveStream(stream) {
const to = stream.meta.to
if (to && to !== self.id) {
if (role !== 'master') return stream.fail(`cannot relay to ${to}`)
const peer = links.get(to)
if (!peer) return stream.fail(`device ${to} offline`)
return relay(stream, peer.mux)
}
if (stream.kind === 'http') return ownHttp(stream, { target: localTarget(), cookie: (a) => cookies.get(a) })
if (stream.kind === 'ws') return ownUpgrade(stream, { target: localTarget(), cookie: (a) => cookies.get(a) })
if (stream.kind === 'blob') return serveBlob(stream)
return stream.fail(`unknown stream kind ${stream.kind}`)
}
/** Splice an incoming stream onto a new stream to another link (master relay). */
function relay(inbound, mux) {
const out = mux.open(inbound.kind, inbound.meta)
const pipe = (a, b) => {
a.on('head', (m) => b.head(m))
a.on('data', (d) => b.write(d))
a.on('end', () => b.end())
a.on('error', (e) => b.fail(e.message))
a.on('remote-pause', () => b.mux.sendControl({ t: 'pause', id: b.id }))
a.on('remote-resume', () => b.mux.sendControl({ t: 'resume', id: b.id }))
}
pipe(inbound, out)
pipe(out, inbound)
inbound.start()
}
// ------------------------------------------------------------------ control handlers
const handlers = {
hello: async (p) => ({ id: self.id, name: self.name, role, version: config.version || '1' }),
sessions: async () => listLocalSessions(),
newSession: async (p) => createLocalSession(p),
devices: async (p, mux) => devicesView(),
route: async () => ({ ok: true }),
manifest: async (p, mux) => {
if (role !== 'master') throw httpErr(400, 'not a master')
const dev = store.devices()[mux.deviceId]
if (dev?.sync?.enabled === false) return { version: 1, bundles: [], patch: [], secrets: [], disabled: true }
const m = await buildManifestCached()
return { ...m, _dirs: undefined, secrets: offerSecretsFor(dev) }
},
secret: async (p, mux) => {
if (role !== 'master') throw httpErr(400, 'not a master')
const dev = store.devices()[mux.deviceId]
const name = String(p.name || '')
if (!offerSecretsFor(dev).includes(name)) throw httpErr(403, `secret ${name} not offered to this device`)
const c = svc(ctx, 'credentials')
if (!c) throw httpErr(503, 'credentials service unavailable')
const r = await c.resolve(name)
if (!r?.value) throw httpErr(404, `secret ${name} not set on master`)
return { name, value: r.value }
},
// Forwarded calls: master answers for another device by asking it.
forward: async (p) => {
if (role !== 'master') throw httpErr(400, 'not a master')
const peer = links.get(p.device)
if (!peer) throw httpErr(404, `device ${p.device} offline`)
return peer.mux.call(p.method, p.params || {}, 20000)
},
}
function offerSecretsFor(dev) {
const offered = new Set(config.sync?.offerSecrets || [])
return (dev?.sync?.secrets || []).filter((n) => offered.has(n))
}
let manifestCache = null
async function buildManifestCached() {
if (manifestCache && Date.now() - manifestCache.at < 15000) return manifestCache.value
const value = await sync.buildManifest({
self,
include: config.sync || {},
offerSecrets: config.sync?.offerSecrets || [],
})
manifestCache = { at: Date.now(), value }
return value
}
async function serveBlob(stream) {
// Only the master serves blobs: packed local plugins named in its manifest.
try {
if (role !== 'master') throw new Error('not a master')
const m = await buildManifestCached()
const dir = m._dirs?.[stream.meta.name]
if (!dir) throw new Error(`no local plugin ${stream.meta.name}`)
const dev = store.devices()[stream.mux.deviceId]
if (dev && !sync.allowed(stream.meta.name, dev.sync || {})) throw new Error('denied for this device')
const buf = await sync.packLocal(dir)
stream.head({ status: 200, size: buf.length })
stream.write(buf)
stream.end()
} catch (e) {
stream.fail(e.message)
}
}
// ------------------------------------------------------------------ sessions
async function listLocalSessions() {
const sc = svc(ctx, 'sessionController')
if (!sc) return { items: [], workspaces: [] }
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 10000)
try {
const { items } = await sc.list({}, ac.signal)
const wr = svc(ctx, 'workspaceRegistry')
const workspaces = wr ? wr.list().map((w) => ({ id: w.id, title: w.title, path: w.path })) : []
return {
items: items
.filter((s) => !s.parentSessionId && !s.blank)
.slice(0, config.maxSessions || 200)
.map((s) => ({
sessionId: s.sessionId,
updatedAt: s.updatedAt,
running: s.running,
cwd: s.cwd,
title: s.projections?.values?.title ?? null,
})),
workspaces,
}
} finally {
clearTimeout(t)
}
}
async function createLocalSession({ cwd, workspaceId } = {}) {
const sc = svc(ctx, 'sessionController')
if (!sc) throw httpErr(503, 'session controller unavailable')
const req = {}
if (workspaceId) req.workspaceId = workspaceId
else if (cwd) req.cwd = cwd
const r = await sc.create(req)
return { sessionId: r.sessionId }
}
// ------------------------------------------------------------------ device views
function devicesView() {
const out = [{ id: self.id, name: self.name, role, self: true, online: true }]
if (role === 'master') {
for (const d of Object.values(store.devices())) {
const l = links.get(d.id)
out.push({
id: d.id, name: d.name, platform: d.platform, role: 'slave', revoked: !!d.revoked,
online: !!l, connectedAt: l?.connectedAt || null, lastSeenAt: d.lastSeenAt || null,
sync: d.sync,
})
}
} else if (masterLink?.info) {
out.push({ ...masterLink.info, role: 'master', online: true })
// Peers in the master's broadcast are by definition online right now.
for (const d of masterLink.peers || []) if (d.id !== self.id && d.role !== 'master') out.push({ ...d, online: true, via: 'master' })
}
return out
}
/** Mux that reaches `device` from here (null = local). */
function routeTo(device) {
if (!device || device === self.id) return { local: true }
if (role === 'master') {
const l = links.get(device)
return l ? { mux: l.mux, meta: {} } : null
}
if (!masterLink?.mux || masterLink.mux.closed) return null
if (masterLink.info?.id === device) return { mux: masterLink.mux, meta: {} }
return { mux: masterLink.mux, meta: { to: device } } // relay through master
}
async function callDevice(device, method, params) {
const r = routeTo(device)
if (!r) throw httpErr(404, `device ${device} unreachable`)
if (r.local) return handlers[method](params || {})
if (r.meta.to) return r.mux.call('forward', { device, method, params }, 25000)
return r.mux.call(method, params || {}, 20000)
}
async function allSessions() {
const devices = devicesView().filter((d) => d.online && !d.revoked)
const results = await Promise.allSettled(devices.map(async (d) => ({ device: d, ...(await callDevice(d.id, 'sessions', {})) })))
return results.map((r, i) => r.status === 'fulfilled'
? { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, items: r.value.items, workspaces: r.value.workspaces }
: { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, error: String(r.reason?.message ?? r.reason), items: [] })
}
// ------------------------------------------------------------------ session activity watch
// Poll local running states; emit "finished" events (for notifications) to all viewers.
const lastRunning = new Map()
async function pollActivity() {
try {
const { items } = await listLocalSessions()
for (const s of items) {
const was = lastRunning.get(s.sessionId)
if (was === true && !s.running) {
const ev = { type: 'session-finished', device: self.id, deviceName: self.name, sessionId: s.sessionId, title: s.title, at: Date.now() }
notify(ev)
broadcast('activity', ev)
}
lastRunning.set(s.sessionId, s.running)
}
} catch { /* ignore */ }
}
const activityTimer = setInterval(pollActivity, config.activityPollMs || 4000)
activityTimer.unref?.()
ctx.effect(() => () => clearInterval(activityTimer), 'dsh-remote: activity poll')
function broadcast(name, data) {
if (role === 'master') for (const l of links.values()) l.mux.event(name, data)
else masterLink?.mux?.event(name, data)
}
function onLinkEvent(name, data, fromDevice) {
if (name === 'activity') {
notify(data)
// Master fans slave activity out to the other slaves.
if (role === 'master') for (const [id, l] of links) if (id !== fromDevice) l.mux.event(name, data)
} else if (name === 'peers' && role === 'slave' && masterLink) {
masterLink.peers = data
notify({ type: 'devices' })
} else if (name === 'sync-available' && role === 'slave') {
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
}
}
function pushPeers() {
if (role !== 'master') return
const peers = devicesView().filter((d) => d.online).map(({ id, name, role: r, platform }) => ({ id, name, role: r, platform }))
for (const l of links.values()) l.mux.event('peers', peers)
notify({ type: 'devices' })
}
// ------------------------------------------------------------------ master: accept links
if (role === 'master') {
ctx.effect(() => ctx.webServer.registerUpgrade({
path: LINK_PATH,
handler: (req, socket, head) => {
const id = String(req.headers['x-dsh-remote-device'] || '')
const key = String(req.headers['x-dsh-remote-key'] || '')
const dev = store.verifyDevice(id, key)
if (!dev) return rejectUpgrade(socket, 401, 'unknown or revoked device')
const ws = acceptUpgrade(req, socket, head)
if (!ws) return
const prev = links.get(id)
if (prev) prev.mux.close(4000, 'replaced by new connection')
const mux = new Mux(ws, { odd: false, handlers, onStream: serveStream })
mux.deviceId = id
const entry = { mux, connectedAt: Date.now(), info: { id, name: dev.name } }
links.set(id, entry)
dev.lastSeenAt = Date.now()
dev.platform = String(req.headers['x-dsh-remote-platform'] || dev.platform || '')
store.save()
mux.on('event', (n, d) => onLinkEvent(n, d, id))
mux.on('close', () => {
if (links.get(id) === entry) links.delete(id)
const d = store.devices()[id]
if (d) { d.lastSeenAt = Date.now(); store.save() }
log.info?.(`dsh-remote: ${id} disconnected`)
pushPeers()
})
log.info?.(`dsh-remote: ${id} connected`)
pushPeers()
},
}), 'dsh-remote: link endpoint')
}
// ------------------------------------------------------------------ slave: keep link up
let stopped = false
let backoff = 1000
async function connectMaster() {
const m = store.master
if (!m || stopped) return
const url = new URL(m.url)
url.protocol = url.protocol === 'https:' ? 'wss:' : url.protocol === 'http:' ? 'ws:' : url.protocol
url.pathname = url.pathname.replace(/\/+$/, '') + LINK_PATH
try {
const ws = await connect(url.href, {
headers: {
'x-dsh-remote-device': m.deviceId,
'x-dsh-remote-key': m.key,
'x-dsh-remote-platform': `${process.platform}-${process.arch}`,
},
})
const mux = new Mux(ws, { odd: true, handlers, onStream: serveStream })
mux.deviceId = 'master'
masterLink = { mux, info: null, peers: [] }
backoff = 1000
mux.on('event', (n, d) => onLinkEvent(n, d, 'master'))
mux.on('close', (code, reason) => {
masterLink = null
notify({ type: 'devices' })
if (code === 4401) {
lastLinkError = `master rejected this device (${reason || 'revoked'}); re-pair it`
log.warn(`dsh-remote: ${lastLinkError}`)
return
}
lastLinkError = `link closed (${code}${reason ? ' ' + reason : ''}); reconnecting`
if (!stopped) schedule()
})
const info = await mux.call('hello', { id: self.id, name: self.name })
masterLink.info = { id: info.id, name: info.name }
// Only devices that are online on the master are reachable through its relay.
masterLink.peers = (await mux.call('devices', {})).filter((d) => d.online && !d.revoked).map(({ id, name, role: r }) => ({ id, name, role: r }))
notify({ type: 'devices' })
lastLinkError = null
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`)
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
} catch (e) {
masterLink = null
if (e.status === 401) { log.warn(`dsh-remote: master refused key (${e.message}); re-pair this device`); lastLinkError = e.message; return }
lastLinkError = e.message
schedule()
}
}
let lastLinkError = null
let timer = null
function schedule() {
clearTimeout(timer)
timer = setTimeout(connectMaster, backoff)
timer.unref?.()
backoff = Math.min(backoff * 2, 30000)
}
if (role === 'slave') connectMaster()
ctx.effect(() => () => {
stopped = true
clearTimeout(timer)
masterLink?.mux?.close(1001, 'shutdown')
for (const l of links.values()) l.mux.close(1001, 'shutdown')
}, 'dsh-remote: links')
// ------------------------------------------------------------------ slave: sync
let syncing = null
async function runSlaveSync() {
if (role !== 'slave') throw httpErr(400, 'only slaves sync')
if (syncing) return syncing
syncing = (async () => {
const mux = masterLink?.mux
if (!mux) throw httpErr(503, 'not linked to a master')
const local = config.sync || {}
const manifest = await mux.call('manifest', {}, 30000)
const result = { at: Date.now(), installed: [], updated: [], skipped: [], failed: [], secrets: [], patchEntries: manifest.patch?.length || 0 }
if (manifest.disabled) { result.skipped.push('sync disabled for this device on master'); return result }
const pm = svc(ctx, 'pluginManager')
const prev = store.data.synced || {}
const next = {}
let profileChanged = false
for (const b of manifest.bundles || []) {
if (!sync.allowed(b.name, local)) { result.skipped.push(`${b.name} (local deny/allow)`); continue }
try {
if (b.kind === 'local') {
if (prev[b.name]?.hash === b.hash) { next[b.name] = prev[b.name]; continue }
const stream = mux.open('blob', { name: b.name })
const buf = await new Promise((resolve, reject) => {
const parts = []
stream.on('data', (d) => parts.push(d))
stream.on('end', () => { resolve(Buffer.concat(parts)); stream.end() })
stream.on('error', reject)
})
const dir = await sync.unpackLocal(b.name, b.version, buf)
profileChanged = sync.linkLocalBundle(b.name, dir) || profileChanged
next[b.name] = { kind: 'local', hash: b.hash, version: b.version, dir }
;(prev[b.name] ? result.updated : result.installed).push(`${b.name}@${b.version}`)
} else {
if (!pm) throw new Error('plugin manager unavailable')
const have = (await pm.listBundles()).find((x) => x.name === b.name)
if (have?.installed && have.version === b.version) {
if (!have.enabled) await pm.setBundleEnabled(b.name, true)
next[b.name] = { kind: 'registry', version: b.version }
continue
}
const r = await pm.installBundle(`${b.name}@${b.version}`, { enabled: true })
if (r.application === 'failed') throw new Error(r.error?.message || 'install failed')
next[b.name] = { kind: 'registry', version: b.version }
;(have ? result.updated : result.installed).push(`${b.name}@${b.version}`)
}
} catch (e) {
result.failed.push(`${b.name}: ${e.message}`)
if (prev[b.name]) next[b.name] = prev[b.name]
}
}
// Config layer.
await sync.writeManagedBundle(manifest.patch || [], Object.keys(next))
profileChanged = sync.ensureManagedBundleOrder() || profileChanged
// Opt-in secrets.
for (const name of manifest.secrets || []) {
if (!(local.secrets || []).includes(name)) continue
try {
const { value } = await mux.call('secret', { name }, 15000)
const creds = svc(ctx, 'credentials')
if (!creds) throw new Error('credentials service unavailable')
await creds.set(name, value)
result.secrets.push(name)
} catch (e) {
result.failed.push(`secret ${name}: ${e.message}`)
}
}
store.data.synced = next
store.data.lastSync = result
store.save()
lastSync = result
result.restartRequired = profileChanged
notify({ type: 'sync', result })
return result
})()
try { return await syncing } finally { syncing = null }
}
// ------------------------------------------------------------------ HTTP API
const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path,
handler: async (req, res) => {
const rejection = ctx.connection.requestRejection(req)
if (rejection !== undefined) return sendJson(res, rejection, { error: 'rejected' })
if (!methods.includes(req.method)) { res.statusCode = 405; res.setHeader('allow', methods.join(', ')); return res.end() }
try {
sendJson(res, 200, await handler(req, new URL(req.url || '/', 'http://x')))
} catch (e) {
sendJson(res, e.status && e.status < 600 ? e.status : 500, { error: String(e.message || e) })
}
},
}), `dsh-remote: ${path}`)
route('/dsh-remote/state', ['GET'], async () => ({
role,
self,
master: role === 'slave' ? { url: store.master?.url || null, linked: !!masterLink?.mux, info: masterLink?.info || null, error: masterLink ? null : lastLinkError } : null,
devices: devicesView(),
publicUrl: config.publicUrl || null,
lanUrl: config.lanUrl || null,
lastSync,
sync: { auto: config.sync?.auto !== false, allow: config.sync?.allow || [], deny: config.sync?.deny || [], secrets: config.sync?.secrets || [], offerSecrets: config.sync?.offerSecrets || [] },
}))
route('/dsh-remote/sessions', ['GET'], async () => ({ devices: await allSessions() }))
route('/dsh-remote/pairing', ['POST'], async (req) => {
if (role !== 'master') throw httpErr(400, 'pairing codes are created on the master')
const body = await readJson(req)
return store.createPairingCode({ label: String(body.label || '') })
})
// Pair endpoint ON THE MASTER, called by a slave's DSH (not by a browser): exchanges a
// pairing code for a device key. Not behind browser auth (the code is the credential).
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: '/dsh-remote/enroll',
handler: async (req, res) => {
if (req.method !== 'POST') { res.statusCode = 405; return res.end() }
try {
if (role !== 'master') throw httpErr(400, 'not a master')
const body = await readJson(req)
const rec = store.consumePairingCode(body.code)
if (!rec) throw httpErr(403, 'invalid or expired pairing code')
const dev = store.addDevice({ id: body.id, name: body.name, platform: body.platform })
sendJson(res, 200, { deviceId: dev.id, key: dev.secret, master: { id: self.id, name: self.name } })
} catch (e) {
sendJson(res, e.status || 500, { error: String(e.message || e) })
}
},
}), 'dsh-remote: enroll')
route('/dsh-remote/pair', ['POST'], async (req) => {
const body = await readJson(req)
const masterUrl = String(body.masterUrl || '').trim().replace(/\/+$/, '')
if (!/^https?:\/\//.test(masterUrl)) throw httpErr(400, 'masterUrl must start with http:// or https://')
const r = await fetch(`${masterUrl}/dsh-remote/enroll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: body.code, id: self.id, name: self.name, platform: `${process.platform}-${process.arch}` }),
})
const j = await r.json().catch(() => ({}))
if (!r.ok) throw httpErr(r.status, j.error || `enroll failed (HTTP ${r.status})`)
store.setMaster({ url: masterUrl, deviceId: j.deviceId, key: j.key, master: j.master, pairedAt: Date.now() })
return { ok: true, deviceId: j.deviceId, master: j.master, restartRequired: role !== 'slave' }
})
route('/dsh-remote/unpair', ['POST'], async () => {
store.setMaster(null)
masterLink?.mux?.close(1000, 'unpaired')
return { ok: true }
})
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/dsh-remote/devices',
handler: async (req, res) => {
const rejection = ctx.connection.requestRejection(req)
if (rejection !== undefined) return sendJson(res, rejection, { error: 'rejected' })
try {
if (role !== 'master') throw httpErr(400, 'devices are managed on the master')
if (req.method !== 'POST') throw httpErr(405, 'POST only')
const m = new URL(req.url || '/', 'http://x').pathname.match(/^\/dsh-remote\/devices\/([a-z0-9-]+)\/(revoke|remove|sync)$/)
if (!m) throw httpErr(404, 'not found')
const [, id, action] = m
if (action === 'revoke') { store.revokeDevice(id); links.get(id)?.mux.close(4401, 'revoked') }
else if (action === 'remove') { store.removeDevice(id); links.get(id)?.mux.close(4401, 'removed') }
else {
const dev = store.devices()[id]
if (!dev) throw httpErr(404, 'unknown device')
const body = await readJson(req)
const asList = (v) => (Array.isArray(v) ? v.map(String).filter(Boolean) : [])
dev.sync = { enabled: body.enabled !== false, allow: asList(body.allow), deny: asList(body.deny), secrets: asList(body.secrets) }
store.save()
links.get(id)?.mux.event('sync-available', {})
}
pushPeers()
sendJson(res, 200, { ok: true, devices: devicesView() })
} catch (e) {
sendJson(res, e.status || 500, { error: String(e.message || e) })
}
},
}), 'dsh-remote: devices')
route('/dsh-remote/sync', ['POST'], async () => {
if (role === 'master') {
manifestCache = null
for (const l of links.values()) l.mux.event('sync-available', {})
return { ok: true, pushed: links.size }
}
return runSlaveSync()
})
route('/dsh-remote/sync/status', ['GET'], async () => {
if (role === 'master') {
const m = await buildManifestCached()
return { role, bundles: m.bundles, patchIds: m.patch.map((e) => e.id || (e.insert ? 'insert' : '?')), patchError: m.patchError || null, offerSecrets: m.secrets }
}
return { role, lastSync, synced: store.data.synced || {} }
})
route('/dsh-remote/new-session', ['POST'], async (req) => {
const body = await readJson(req)
const device = String(body.device || self.id)
const r = await callDevice(device, 'newSession', { cwd: body.cwd, workspaceId: body.workspaceId })
return { device, sessionId: r.sessionId, url: device === self.id ? `./#/session/${r.sessionId}` : `./r/${device}/#/session/${r.sessionId}` }
})
// Server-sent events for the client: devices changes, activity, sync results.
route('/dsh-remote/events', ['GET'], async () => ({}))
ctx.effect(() => {
const dispose = ctx.webServer.register({
kind: 'exact',
path: '/dsh-remote/stream',
handler: (req, res) => {
const rejection = ctx.connection.requestRejection(req)
if (rejection !== undefined) { res.statusCode = rejection; return res.end() }
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' })
res.write(': ok\n\n')
const l = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`)
listeners.add(l)
const ka = setInterval(() => res.write(': ka\n\n'), 20000)
req.on('close', () => { listeners.delete(l); clearInterval(ka) })
},
})
return dispose
}, 'dsh-remote: event stream')
// ------------------------------------------------------------------ /r/<device>/ proxy
function parseRemote(urlPath) {
const m = urlPath.match(/^\/r\/([a-z0-9-]+)(\/.*)?$/)
if (!m) return null
return { device: m[1], rest: m[2] || '/' }
}
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/r',
handler: (req, res) => {
const rejection = ctx.connection.requestRejection(req)
if (rejection !== undefined) {
res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
return res.end(rejection === 401 ? 'dsh-remote: open this DSH first (authentication required)\n' : 'forbidden\n')
}
const url = new URL(req.url || '/', 'http://x')
const p = parseRemote(url.pathname)
if (!p) { res.statusCode = 404; return res.end() }
// "/r/ai-pc" → "/r/ai-pc/" so relative URLs (<base href="./">) resolve under the mount.
if (url.pathname === `/r/${p.device}`) {
res.writeHead(301, { location: `/r/${p.device}/${url.search}${url.hash}` })
return res.end()
}
if (p.device === self.id) {
res.writeHead(302, { location: `/${url.search}` })
return res.end()
}
const r = routeTo(p.device)
if (!r) {
res.writeHead(502, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
return res.end(`<!doctype html><meta charset=utf-8><title>offline</title><body style="font:15px system-ui;padding:3em;background:#111;color:#ddd">
<h2>Device <code>${p.device}</code> is offline</h2><p>Its DSH is not linked right now. <a style="color:#8af" href="/">Back</a></p>`)
}
viewHttp(req, res, (kind, meta) => r.mux.open(kind, { ...meta, ...r.meta }), { rest: p.rest + url.search, basePath: `/r/${p.device}` })
},
}), 'dsh-remote: /r proxy')
// Upgrades: DSH's webserver matches upgrade routes exactly, so register the Remote mux
// path for each known device id (and re-register as devices appear).
const upgradeRoutes = new Map()
function ensureUpgradeRoute(device) {
if (device === self.id || upgradeRoutes.has(device)) return
const path = `/r/${device}${MUX_PATH}`
try {
const dispose = ctx.webServer.registerUpgrade({
path,
handler: (req, socket, head) => {
if (ctx.connection.requestRejection(req) !== undefined) return rejectUpgrade(socket, 401, 'auth required')
const r = routeTo(device)
if (!r) return rejectUpgrade(socket, 502, 'device offline')
const url = new URL(req.url || '/', 'http://x')
viewUpgrade(req, socket, head, (kind, meta) => r.mux.open(kind, { ...meta, ...r.meta }), { rest: MUX_PATH + url.search })
},
})
upgradeRoutes.set(device, dispose)
} catch (e) {
log.warn(`dsh-remote: upgrade route ${path}: ${e.message}`)
}
}
const upgradeSync = setInterval(() => { for (const d of devicesView()) ensureUpgradeRoute(d.id) }, 2000)
upgradeSync.unref?.()
for (const d of Object.keys(store.devices())) ensureUpgradeRoute(d)
ctx.effect(() => () => {
clearInterval(upgradeSync)
for (const dispose of upgradeRoutes.values()) { try { dispose() } catch { /* ignore */ } }
upgradeRoutes.clear()
}, 'dsh-remote: upgrade routes')
log.info?.(`dsh-remote: ${role} "${self.name}" (${self.id}) ready`)
}