Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock the rpc-client module
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
import { containerClient } from '../container-client'
import { rpcClient } from '@/api/rpc-client'
const mockedRpc = vi.mocked(rpcClient)
describe('containerClient', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('installApp calls container-install with manifest path', async () => {
mockedRpc.call.mockResolvedValue('container-abc123')
const result = await containerClient.installApp('/apps/bitcoin/manifest.yml')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-install',
params: { manifest_path: '/apps/bitcoin/manifest.yml' },
})
expect(result).toBe('container-abc123')
})
it('startContainer calls container-start with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.startContainer('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-start',
params: { app_id: 'bitcoin-knots' },
})
})
it('stopContainer calls container-stop with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.stopContainer('lnd')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-stop',
params: { app_id: 'lnd' },
})
})
it('removeContainer calls container-remove with app_id', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.removeContainer('mempool')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-remove',
params: { app_id: 'mempool' },
})
})
it('getContainerStatus returns status for a container', async () => {
const mockStatus = {
id: '1',
name: 'bitcoin-knots',
state: 'running' as const,
image: 'bitcoinknots:29',
created: '2026-01-01',
ports: ['8332'],
lan_address: 'http://localhost:8332',
}
mockedRpc.call.mockResolvedValue(mockStatus)
const result = await containerClient.getContainerStatus('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-status',
params: { app_id: 'bitcoin-knots' },
})
expect(result).toEqual(mockStatus)
})
it('getContainerLogs returns log lines with default line count', async () => {
const mockLogs = ['Starting bitcoin...', 'Block 850000 synced', 'Peer connected']
mockedRpc.call.mockResolvedValue(mockLogs)
const result = await containerClient.getContainerLogs('bitcoin-knots')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-logs',
params: { app_id: 'bitcoin-knots', lines: 100 },
})
expect(result).toEqual(mockLogs)
})
it('getContainerLogs respects custom line count', async () => {
mockedRpc.call.mockResolvedValue([])
await containerClient.getContainerLogs('lnd', 50)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-logs',
params: { app_id: 'lnd', lines: 50 },
})
})
it('listContainers returns all containers', async () => {
const mockContainers = [
{ id: '1', name: 'bitcoin-knots', state: 'running', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'] },
{ id: '2', name: 'lnd', state: 'stopped', image: 'lnd:v0.18', created: '2026-01-01', ports: ['9735'] },
]
mockedRpc.call.mockResolvedValue(mockContainers)
const result = await containerClient.listContainers()
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-list',
params: {},
})
expect(result).toHaveLength(2)
})
it('getHealthStatus returns health map', async () => {
const mockHealth = { 'bitcoin-knots': 'healthy', lnd: 'unhealthy' }
mockedRpc.call.mockResolvedValue(mockHealth)
const result = await containerClient.getHealthStatus()
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'container-health',
params: {},
})
expect(result).toEqual(mockHealth)
})
it('startBundledApp sends full app config', async () => {
mockedRpc.call.mockResolvedValue(undefined)
const app = {
id: 'filebrowser',
name: 'FileBrowser',
image: 'filebrowser/filebrowser:v2',
ports: [{ host: 8083, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
}
await containerClient.startBundledApp(app)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'bundled-app-start',
params: {
app_id: 'filebrowser',
image: 'filebrowser/filebrowser:v2',
ports: [{ host: 8083, container: 80 }],
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
},
})
})
it('stopBundledApp calls bundled-app-stop', async () => {
mockedRpc.call.mockResolvedValue(undefined)
await containerClient.stopBundledApp('filebrowser')
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'bundled-app-stop',
params: { app_id: 'filebrowser' },
})
})
it('propagates RPC errors from the client', async () => {
mockedRpc.call.mockRejectedValue(new Error('Connection refused'))
await expect(containerClient.startContainer('bitcoin-knots')).rejects.toThrow('Connection refused')
})
})
@@ -0,0 +1,338 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { sanitizePath } from '../filebrowser-client'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// FileBrowserClient reads window.location.origin in constructor, so stub it
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' },
writable: true,
})
// Import after stubs
const { fileBrowserClient } = await import('../filebrowser-client')
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
// A real File Browser JSON response carries this; listDirectory now guards
// on it (B4) to detect the SPA-fallback HTML / 502 cases.
headers: new Headers({ 'content-type': 'application/json' }),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
formData: () => Promise.resolve(new FormData()),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
/** Set up authenticated state — bypasses jsdom cookie path restrictions */
function setAuthenticated() {
;(fileBrowserClient as any)._authenticated = true
document.cookie = 'auth=test-token'
}
describe('FileBrowserClient', () => {
beforeEach(() => {
mockFetch.mockReset()
;(fileBrowserClient as any)._authenticated = false
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
})
describe('login', () => {
it('authenticates via backend RPC and stores token', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-token-123' } }))
const result = await fileBrowserClient.login()
expect(result).toBe(true)
expect(fileBrowserClient.isAuthenticated).toBe(true)
expect(mockFetch).toHaveBeenCalledWith(
'/rpc/v1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ method: 'app.filebrowser-token' }),
}),
)
})
it('returns false on failed login', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
const result = await fileBrowserClient.login()
expect(result).toBe(false)
})
it('returns false on network error', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'))
const result = await fileBrowserClient.login()
expect(result).toBe(false)
})
})
describe('listDirectory', () => {
it('lists items in a directory', async () => {
setAuthenticated()
const mockItems = {
items: [
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'readme.txt', path: '/readme.txt', size: 1024, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
],
numDirs: 1,
numFiles: 1,
sorting: { by: 'name', asc: true },
}
mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))
const items = await fileBrowserClient.listDirectory('/')
expect(items).toHaveLength(2)
expect(items[0]!.name).toBe('photos')
expect(items[1]!.extension).toBe('txt')
})
it('adds leading slash if missing', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
await fileBrowserClient.listDirectory('photos')
const [url] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos')
})
it('throws on non-OK response', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('File Browser is not available (HTTP 404)')
})
it('throws a friendly error when File Browser is absent and nginx serves the SPA (B4)', async () => {
setAuthenticated()
// 200 but text/html (SPA index.html fallback) — res.json() would throw the
// opaque "Unexpected token '<'"; the guard must surface a friendly message.
const htmlResponse = {
...jsonResponse('<!doctype html><html></html>'),
headers: new Headers({ 'content-type': 'text/html' }),
} as Response
mockFetch.mockResolvedValueOnce(htmlResponse)
await expect(fileBrowserClient.listDirectory('/')).rejects.toThrow('File Browser is not available')
})
})
describe('downloadUrl', () => {
it('constructs download URL for file path', async () => {
const url = fileBrowserClient.downloadUrl('/photos/sunset.jpg')
expect(url).toContain('/api/raw/photos/sunset.jpg')
})
it('adds leading slash if missing', async () => {
const url = fileBrowserClient.downloadUrl('file.txt')
expect(url).toContain('/api/raw/file.txt')
})
})
describe('upload', () => {
it('uploads a file to the correct path', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
await fileBrowserClient.upload('/documents', file)
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/documents/test.txt')
expect(url).toContain('override=true')
expect(init.method).toBe('POST')
expect(init.body).toBe(file)
})
it('throws on upload failure', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
const file = new File(['data'], 'big.bin')
await expect(fileBrowserClient.upload('/', file)).rejects.toThrow('Upload failed (507)')
})
})
describe('createFolder', () => {
it('creates a folder at the correct path', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.createFolder('/documents', 'photos')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/documents/photos/')
expect(init.method).toBe('POST')
})
it('throws on failure', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
await expect(fileBrowserClient.createFolder('/', 'test')).rejects.toThrow('Create folder failed: 500')
})
})
describe('deleteItem', () => {
it('sends DELETE request for the item', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.deleteItem('/photos/old.jpg')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos/old.jpg')
expect(init.method).toBe('DELETE')
})
it('throws on failure', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
await expect(fileBrowserClient.deleteItem('/protected')).rejects.toThrow('Delete failed: 403')
})
})
describe('getUsage', () => {
it('returns usage summary for root directory', async () => {
setAuthenticated()
const mockData = {
items: [
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
{ name: 'file1.txt', path: '/file1.txt', size: 500, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
{ name: 'file2.jpg', path: '/file2.jpg', size: 1500, modified: '2026-01-01', isDir: false, type: '', extension: 'jpg' },
],
numDirs: 1,
numFiles: 2,
sorting: { by: 'name', asc: true },
}
mockFetch.mockResolvedValueOnce(jsonResponse(mockData))
const usage = await fileBrowserClient.getUsage()
expect(usage.totalSize).toBe(2000)
expect(usage.folderCount).toBe(1)
expect(usage.fileCount).toBe(2)
})
it('returns zeros on failed request', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
const usage = await fileBrowserClient.getUsage()
expect(usage).toEqual({ totalSize: 0, folderCount: 0, fileCount: 0 })
})
})
describe('isTextFile', () => {
it('identifies text file extensions', () => {
expect(fileBrowserClient.isTextFile('readme.md')).toBe(true)
expect(fileBrowserClient.isTextFile('config.json')).toBe(true)
expect(fileBrowserClient.isTextFile('script.py')).toBe(true)
expect(fileBrowserClient.isTextFile('main.rs')).toBe(true)
expect(fileBrowserClient.isTextFile('style.css')).toBe(true)
})
it('returns false for binary files', () => {
expect(fileBrowserClient.isTextFile('photo.jpg')).toBe(false)
expect(fileBrowserClient.isTextFile('video.mp4')).toBe(false)
expect(fileBrowserClient.isTextFile('archive.zip')).toBe(false)
})
})
describe('rename', () => {
it('sends PATCH request with new destination', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
await fileBrowserClient.rename('/photos/old.jpg', 'new.jpg')
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
expect(url).toContain('/api/resources/photos/old.jpg')
expect(init.method).toBe('PATCH')
expect(JSON.parse(init.body)).toEqual({ destination: '/photos/new.jpg' })
})
it('throws on rename failure', async () => {
setAuthenticated()
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
await expect(fileBrowserClient.rename('/a.txt', 'b.txt')).rejects.toThrow('Rename failed: 409')
})
})
})
describe('sanitizePath', () => {
it('returns / for empty path', () => {
expect(sanitizePath('')).toBe('/')
})
it('preserves simple paths', () => {
expect(sanitizePath('/photos')).toBe('/photos')
expect(sanitizePath('/docs/readme.md')).toBe('/docs/readme.md')
})
it('adds leading slash', () => {
expect(sanitizePath('photos/image.jpg')).toBe('/photos/image.jpg')
})
it('resolves . segments', () => {
expect(sanitizePath('/photos/./image.jpg')).toBe('/photos/image.jpg')
})
it('resolves .. segments', () => {
expect(sanitizePath('/photos/../etc/passwd')).toBe('/etc/passwd')
})
it('prevents traversal past root', () => {
expect(sanitizePath('/../../../etc/passwd')).toBe('/etc/passwd')
expect(sanitizePath('/../../..')).toBe('/')
})
it('handles multiple consecutive .. at root', () => {
expect(sanitizePath('/../../../etc/shadow')).toBe('/etc/shadow')
})
it('handles mixed . and .. segments', () => {
expect(sanitizePath('/a/./b/../c')).toBe('/a/c')
})
it('removes trailing slashes in segments', () => {
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
})
})
@@ -0,0 +1,98 @@
/**
* Regression pin for T-13-39 — `streamUrl` used to append `?auth=<jwt>` to
* the raw-file URL, leaking the filebrowser JWT into browser history,
* `Referer` headers and access logs. 13-CONTEXT.md names this "the known
* leak to fix rather than propagate"; this file pins the fix so it cannot
* silently regress.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// FileBrowserClient reads window.location.origin in its constructor.
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' },
writable: true,
})
const { fileBrowserClient } = await import('../filebrowser-client')
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
headers: new Headers({ 'content-type': 'application/json' }),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
formData: () => Promise.resolve(new FormData()),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('FileBrowserClient.streamUrl', () => {
beforeEach(() => {
mockFetch.mockReset()
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = false
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
})
it('resolves to a same-origin raw-file URL with no query component', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'super-secret-jwt-token' } }))
const url = await fileBrowserClient.streamUrl('/Music/song.m4a')
expect(url).toBe('http://localhost/app/filebrowser/api/raw/Music/song.m4a')
expect(url).not.toContain('?')
})
it('never embeds the filebrowser JWT anywhere in the returned string', async () => {
const token = 'super-secret-jwt-token-value-12345'
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token } }))
const url = await fileBrowserClient.streamUrl('/Videos/movie.mp4')
expect(url).not.toContain(token)
expect(url).not.toMatch(/[?&]auth=/)
})
it('awaits authentication (sets the cookie the media request relies on) before returning', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-abc' } }))
await fileBrowserClient.streamUrl('/Videos/movie.mp4')
// The cookie login() sets is what the same-origin media request depends
// on now that the URL itself carries no credential — assert it's really
// there by the time the caller has the URL in hand.
expect(document.cookie).toContain('auth=jwt-abc')
})
it('does not re-authenticate when a valid session already exists', async () => {
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true
document.cookie = 'auth=already-authed'
const url = await fileBrowserClient.streamUrl('/a.mp3')
expect(mockFetch).not.toHaveBeenCalled()
expect(url).toBe('http://localhost/app/filebrowser/api/raw/a.mp3')
})
it('still resolves traversal via sanitizePath — a path cannot escape root', async () => {
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true
document.cookie = 'auth=already-authed'
const url = await fileBrowserClient.streamUrl('/Music/../../etc/passwd')
expect(url).toBe('http://localhost/app/filebrowser/api/raw/etc/passwd')
expect(url).not.toContain('..')
})
})
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { isTextField, typeKeyIntoField } from '../remote-relay'
/**
* Companion-cursor text entry. Synthetic KeyboardEvents do NOT mutate input
* values in the browser, so the relay edits `.value` at the caret directly and
* fires an `input` event. These tests lock in that behaviour so a regression
* (like the old "type goes to document, nothing happens" bug) is caught before
* release rather than by a user with a companion controller.
*/
describe('isTextField', () => {
it('accepts text-like inputs and textareas', () => {
const text = document.createElement('input')
text.type = 'text'
const search = document.createElement('input')
search.type = 'search'
const area = document.createElement('textarea')
expect(isTextField(text)).toBe(true)
expect(isTextField(search)).toBe(true)
expect(isTextField(area)).toBe(true)
})
it('rejects non-text controls and null', () => {
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
expect(isTextField(checkbox)).toBe(false)
expect(isTextField(document.createElement('button'))).toBe(false)
expect(isTextField(document.createElement('div'))).toBe(false)
expect(isTextField(null)).toBe(false)
})
})
describe('typeKeyIntoField', () => {
let input: HTMLInputElement
let inputEvents: number
beforeEach(() => {
input = document.createElement('input')
input.type = 'search'
document.body.appendChild(input)
inputEvents = 0
input.addEventListener('input', () => { inputEvents++ })
})
it('inserts printable characters at the caret and fires input', () => {
typeKeyIntoField(input, 'b')
typeKeyIntoField(input, 't')
typeKeyIntoField(input, 'c')
expect(input.value).toBe('btc')
expect(input.selectionStart).toBe(3)
expect(inputEvents).toBe(3)
})
it('inserts a character in the middle of existing text', () => {
input.value = 'bc'
input.selectionStart = input.selectionEnd = 1
typeKeyIntoField(input, 't')
expect(input.value).toBe('btc')
expect(input.selectionStart).toBe(2)
})
it('backspace deletes the char before the caret', () => {
input.value = 'btc'
input.selectionStart = input.selectionEnd = 3
typeKeyIntoField(input, 'Backspace')
expect(input.value).toBe('bt')
expect(input.selectionStart).toBe(2)
})
it('backspace removes the active selection', () => {
input.value = 'bitcoin'
input.selectionStart = 0
input.selectionEnd = 3
typeKeyIntoField(input, 'Backspace')
expect(input.value).toBe('coin')
expect(input.selectionStart).toBe(0)
})
it('arrow keys move the caret without changing the value', () => {
input.value = 'abc'
input.selectionStart = input.selectionEnd = 3
typeKeyIntoField(input, 'ArrowLeft')
expect(input.selectionStart).toBe(2)
expect(input.value).toBe('abc')
})
it('Enter on a single-line input is left for the app to handle', () => {
input.value = 'query'
input.selectionStart = input.selectionEnd = 5
const consumed = typeKeyIntoField(input, 'Enter')
expect(consumed).toBe(false)
expect(input.value).toBe('query')
})
it('Enter inserts a newline in a textarea', () => {
const area = document.createElement('textarea')
area.value = 'a'
area.selectionStart = area.selectionEnd = 1
expect(typeKeyIntoField(area, 'Enter')).toBe(true)
expect(area.value).toBe('a\n')
})
it('non-text keys are not consumed as editing', () => {
input.value = 'x'
input.selectionStart = input.selectionEnd = 1
expect(typeKeyIntoField(input, 'Escape')).toBe(false)
expect(typeKeyIntoField(input, 'Tab')).toBe(false)
expect(input.value).toBe('x')
})
})
@@ -0,0 +1,601 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// We need to test the RPCClient class, so import it by re-creating the module
// Import the actual class and instance
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Import after stubbing fetch
const { rpcClient } = await import('../rpc-client')
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText,
json: () => Promise.resolve(body),
headers: new Headers(),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status, statusText),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
text: () => Promise.resolve(JSON.stringify(body)),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('RPCClient', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('makes a successful RPC call and returns the result', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
const result = await rpcClient.call<{ did: string }>({
method: 'node.did',
params: {},
})
expect(result).toEqual({ did: 'did:key:z123' })
expect(mockFetch).toHaveBeenCalledOnce()
const [url, init] = mockFetch.mock.calls[0]!
expect(url).toBe('/rpc/v1')
expect(init.method).toBe('POST')
expect(init.credentials).toBe('include')
expect(JSON.parse(init.body)).toEqual({ method: 'node.did', params: {} })
})
it('includes credentials for session cookies', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
await rpcClient.call({ method: 'test', params: {} })
const [, init] = mockFetch.mock.calls[0]!
expect(init.credentials).toBe('include')
})
it('retries on 502 Bad Gateway and eventually succeeds', async () => {
mockFetch
.mockResolvedValueOnce(jsonResponse(null, 502, 'Bad Gateway'))
.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
const result = await rpcClient.call({ method: 'test' })
expect(result).toBe('ok')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('retries on 503 Service Unavailable and eventually succeeds', async () => {
mockFetch
.mockResolvedValueOnce(jsonResponse(null, 503, 'Service Unavailable'))
.mockResolvedValueOnce(jsonResponse({ result: 'recovered' }))
const result = await rpcClient.call({ method: 'test' })
expect(result).toBe('recovered')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('throws after max retries on persistent 502', async () => {
mockFetch
.mockResolvedValue(jsonResponse(null, 502, 'Bad Gateway'))
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('HTTP 502: Bad Gateway')
expect(mockFetch).toHaveBeenCalledTimes(3)
})
it('throws immediately on non-retryable HTTP errors (e.g. 401)', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(null, 401, 'Unauthorized'))
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Session expired')
expect(mockFetch).toHaveBeenCalledOnce()
})
it('throws on RPC-level error in response body', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({ error: { code: -32600, message: 'Invalid method' } }),
)
await expect(rpcClient.call({ method: 'bad.method' })).rejects.toThrow('Invalid method')
expect(mockFetch).toHaveBeenCalledOnce()
})
it('throws timeout error when request times out', async () => {
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
mockFetch.mockRejectedValue(abortError)
await expect(
rpcClient.call({ method: 'slow', timeout: 100 }),
).rejects.toThrow('Request timeout')
expect(mockFetch).toHaveBeenCalledTimes(3)
})
it('retries on network/fetch errors and eventually succeeds', async () => {
mockFetch
.mockRejectedValueOnce(new Error('fetch failed'))
.mockResolvedValueOnce(jsonResponse({ result: 'back online' }))
const result = await rpcClient.call({ method: 'test' })
expect(result).toBe('back online')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('throws on non-retryable errors immediately', async () => {
mockFetch.mockRejectedValueOnce(new Error('some random error'))
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('some random error')
expect(mockFetch).toHaveBeenCalledOnce()
})
it('handles unknown (non-Error) thrown values', async () => {
mockFetch.mockRejectedValueOnce('string error')
await expect(rpcClient.call({ method: 'test' })).rejects.toThrow('Unknown error occurred')
})
it('uses default params when none provided', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
await rpcClient.call({ method: 'test' })
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.params).toEqual({})
})
it('sends an abort signal for timeout', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: 'ok' }))
await rpcClient.call({ method: 'test', timeout: 5000 })
const [, init] = mockFetch.mock.calls[0]!
expect(init.signal).toBeInstanceOf(AbortSignal)
})
})
describe('RPCClient convenience methods', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
function mockSuccess(result: unknown) {
mockFetch.mockResolvedValueOnce(jsonResponse({ result }))
}
function getLastMethod(): string {
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
return body.method
}
function getLastParams(): Record<string, unknown> {
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
return body.params
}
it('login calls auth.login with password', async () => {
mockSuccess(null)
await rpcClient.login('test123')
expect(getLastMethod()).toBe('auth.login')
expect(getLastParams().password).toBe('test123')
})
it('loginTotp calls auth.login.totp', async () => {
mockSuccess({ success: true })
await rpcClient.loginTotp('123456')
expect(getLastMethod()).toBe('auth.login.totp')
expect(getLastParams().code).toBe('123456')
})
it('loginBackup calls auth.login.backup', async () => {
mockSuccess({ success: true })
await rpcClient.loginBackup('ABCD-1234')
expect(getLastMethod()).toBe('auth.login.backup')
expect(getLastParams().code).toBe('ABCD-1234')
})
it('totpSetupBegin calls auth.totp.setup.begin', async () => {
mockSuccess({ qr_svg: '<svg/>', secret_base32: 'ABC', pending_token: 'tok' })
await rpcClient.totpSetupBegin('password')
expect(getLastMethod()).toBe('auth.totp.setup.begin')
})
it('totpSetupConfirm calls auth.totp.setup.confirm', async () => {
mockSuccess({ enabled: true, backup_codes: ['A', 'B'] })
await rpcClient.totpSetupConfirm({ code: '123456', password: 'pw', pendingToken: 'tok' })
expect(getLastMethod()).toBe('auth.totp.setup.confirm')
})
it('totpDisable calls auth.totp.disable', async () => {
mockSuccess({ disabled: true })
await rpcClient.totpDisable('pw', '123456')
expect(getLastMethod()).toBe('auth.totp.disable')
})
it('totpStatus calls auth.totp.status', async () => {
mockSuccess({ enabled: false })
await rpcClient.totpStatus()
expect(getLastMethod()).toBe('auth.totp.status')
})
it('changePassword calls auth.changePassword', async () => {
mockSuccess({ success: true })
await rpcClient.changePassword({ currentPassword: 'old', newPassword: 'new' })
expect(getLastMethod()).toBe('auth.changePassword')
expect(getLastParams().alsoChangeSsh).toBe(true)
})
it('changePassword respects alsoChangeSsh option', async () => {
mockSuccess({ success: true })
await rpcClient.changePassword({ currentPassword: 'old', newPassword: 'new', alsoChangeSsh: false })
expect(getLastParams().alsoChangeSsh).toBe(false)
})
it('logout calls auth.logout', async () => {
mockSuccess(undefined)
await rpcClient.logout()
expect(getLastMethod()).toBe('auth.logout')
})
it('completeOnboarding calls auth.onboardingComplete', async () => {
mockSuccess(true)
await rpcClient.completeOnboarding()
expect(getLastMethod()).toBe('auth.onboardingComplete')
})
it('isOnboardingComplete calls auth.isOnboardingComplete', async () => {
mockSuccess(true)
const result = await rpcClient.isOnboardingComplete()
expect(result).toBe(true)
expect(getLastMethod()).toBe('auth.isOnboardingComplete')
})
it('resetOnboarding calls auth.resetOnboarding', async () => {
mockSuccess(true)
await rpcClient.resetOnboarding()
expect(getLastMethod()).toBe('auth.resetOnboarding')
})
it('getNodeDid calls node.did', async () => {
mockSuccess({ did: 'did:key:z123', pubkey: 'abc' })
const result = await rpcClient.getNodeDid()
expect(result.did).toBe('did:key:z123')
expect(getLastMethod()).toBe('node.did')
})
it('signChallenge calls node.signChallenge', async () => {
mockSuccess({ signature: 'sig123' })
await rpcClient.signChallenge('test-challenge')
expect(getLastMethod()).toBe('node.signChallenge')
expect(getLastParams().challenge).toBe('test-challenge')
})
it('createBackup calls node.createBackup', async () => {
mockSuccess({ version: 1, did: 'did:key:z', pubkey: 'pk', kid: 'k1', encrypted: true, blob: 'data', timestamp: '2026-01-01' })
await rpcClient.createBackup('passphrase')
expect(getLastMethod()).toBe('node.createBackup')
})
it('resolveDid calls identity.resolve-did', async () => {
mockSuccess({})
await rpcClient.resolveDid('did:key:z123')
expect(getLastMethod()).toBe('identity.resolve-did')
expect(getLastParams().did).toBe('did:key:z123')
})
it('resolveDid without did sends empty params', async () => {
mockSuccess({})
await rpcClient.resolveDid()
expect(getLastParams()).toEqual({})
})
it('createPresentation calls identity.create-presentation', async () => {
mockSuccess({})
await rpcClient.createPresentation({ holderId: 'h1', credentialIds: ['c1'] })
expect(getLastMethod()).toBe('identity.create-presentation')
})
it('verifyPresentation calls identity.verify-presentation', async () => {
mockSuccess({ valid: true, holder_valid: true, credentials: [] })
await rpcClient.verifyPresentation({ type: 'test' })
expect(getLastMethod()).toBe('identity.verify-presentation')
})
it('createPsbt calls lnd.create-psbt', async () => {
mockSuccess({ psbt_base64: 'psbt', change_output_index: 0, total_amount_sats: 1000, fee_rate_sat_per_vbyte: 10 })
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }] })
expect(getLastMethod()).toBe('lnd.create-psbt')
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(10)
})
it('finalizePsbt calls lnd.finalize-psbt', async () => {
mockSuccess({ raw_final_tx: 'rawtx', broadcast: true })
await rpcClient.finalizePsbt('signed-psbt')
expect(getLastMethod()).toBe('lnd.finalize-psbt')
})
it('publishNostrIdentity calls node.nostr-publish', async () => {
mockSuccess({ event_id: 'evt', success: 1, failed: 0 })
await rpcClient.publishNostrIdentity()
expect(getLastMethod()).toBe('node.nostr-publish')
})
it('getNostrPubkey calls node.nostr-pubkey', async () => {
mockSuccess({ nostr_pubkey: 'npub1...' })
await rpcClient.getNostrPubkey()
expect(getLastMethod()).toBe('node.nostr-pubkey')
})
it('listPeers calls node-list-peers', async () => {
mockSuccess({ peers: [] })
await rpcClient.listPeers()
expect(getLastMethod()).toBe('node-list-peers')
})
it('addPeer calls node-add-peer', async () => {
mockSuccess({ peers: [] })
await rpcClient.addPeer({ onion: 'abc.onion', pubkey: 'pk' })
expect(getLastMethod()).toBe('node-add-peer')
})
it('removePeer calls node-remove-peer', async () => {
mockSuccess({ peers: [] })
await rpcClient.removePeer('pk123')
expect(getLastMethod()).toBe('node-remove-peer')
})
it('sendMessageToPeer calls node-send-message', async () => {
mockSuccess({ ok: true, sent_to: 'abc.onion' })
await rpcClient.sendMessageToPeer('abc.onion', 'hello')
expect(getLastMethod()).toBe('node-send-message')
})
it('checkPeerReachable calls node-check-peer', async () => {
mockSuccess({ onion: 'abc.onion', reachable: true })
await rpcClient.checkPeerReachable('abc.onion')
expect(getLastMethod()).toBe('node-check-peer')
})
it('getReceivedMessages calls node-messages-received', async () => {
mockSuccess({ messages: [] })
await rpcClient.getReceivedMessages()
expect(getLastMethod()).toBe('node-messages-received')
})
it('discoverNodes calls node-nostr-discover', async () => {
mockSuccess({ nodes: [] })
await rpcClient.discoverNodes()
expect(getLastMethod()).toBe('node-nostr-discover')
})
it('getTorAddress calls node.tor-address', async () => {
mockSuccess({ tor_address: 'abc123.onion' })
await rpcClient.getTorAddress()
expect(getLastMethod()).toBe('node.tor-address')
})
it('verifyNostrRevoked calls node-nostr-verify-revoked', async () => {
mockSuccess({ revoked: false, nostr_pubkey: 'npub' })
await rpcClient.verifyNostrRevoked()
expect(getLastMethod()).toBe('node-nostr-verify-revoked')
})
it('echo calls server.echo', async () => {
mockSuccess('hello')
const result = await rpcClient.echo('hello')
expect(result).toBe('hello')
expect(getLastMethod()).toBe('server.echo')
})
it('getSystemTime calls server.time', async () => {
mockSuccess({ now: '2026-03-11', uptime: 3600 })
await rpcClient.getSystemTime()
expect(getLastMethod()).toBe('server.time')
})
it('getMetrics calls server.metrics', async () => {
mockSuccess({ cpu: 50 })
await rpcClient.getMetrics()
expect(getLastMethod()).toBe('server.metrics')
})
it('updateServer calls server.update', async () => {
mockSuccess('no-updates')
await rpcClient.updateServer('https://example.com')
expect(getLastMethod()).toBe('server.update')
})
it('detectUsbDevices calls system.detect-usb-devices', async () => {
mockSuccess({ devices: [] })
await rpcClient.detectUsbDevices()
expect(getLastMethod()).toBe('system.detect-usb-devices')
})
it('restartServer calls server.restart', async () => {
mockSuccess(undefined)
await rpcClient.restartServer()
expect(getLastMethod()).toBe('server.restart')
})
it('shutdownServer calls server.shutdown', async () => {
mockSuccess(undefined)
await rpcClient.shutdownServer()
expect(getLastMethod()).toBe('server.shutdown')
})
it('installPackage calls package.install', async () => {
mockSuccess('bitcoin-knots')
await rpcClient.installPackage('btc', 'https://mp.com', '1.0')
expect(getLastMethod()).toBe('package.install')
})
it('uninstallPackage calls package.uninstall', async () => {
mockSuccess(undefined)
await rpcClient.uninstallPackage('btc')
expect(getLastMethod()).toBe('package.uninstall')
})
it('uninstallPackage forwards preserve_data when requested', async () => {
mockSuccess(undefined)
await rpcClient.uninstallPackage('btc', { preserveData: true })
expect(getLastParams()).toEqual({ id: 'btc', preserve_data: true })
})
it('startPackage calls package.start', async () => {
mockSuccess(undefined)
await rpcClient.startPackage('btc')
expect(getLastMethod()).toBe('package.start')
})
it('stopPackage calls package.stop', async () => {
mockSuccess(undefined)
await rpcClient.stopPackage('btc')
expect(getLastMethod()).toBe('package.stop')
})
it('restartPackage calls package.restart', async () => {
mockSuccess(undefined)
await rpcClient.restartPackage('btc')
expect(getLastMethod()).toBe('package.restart')
})
it('getMarketplace calls marketplace.get', async () => {
mockSuccess({})
await rpcClient.getMarketplace('https://mp.com')
expect(getLastMethod()).toBe('marketplace.get')
})
it('federationInvite calls federation.invite', async () => {
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
await rpcClient.federationInvite()
expect(getLastMethod()).toBe('federation.invite')
})
it('federationInvite omits password when none is given', async () => {
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
await rpcClient.federationInvite('observer')
expect(getLastParams()).not.toHaveProperty('password')
})
it('federationInvite forwards the password for a trusted invite', async () => {
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
await rpcClient.federationInvite('trusted', 'hunter2')
expect(getLastParams()).toMatchObject({ trust_level: 'trusted', password: 'hunter2' })
})
it('federationJoin calls federation.join', async () => {
mockSuccess({ joined: true, node: {} })
await rpcClient.federationJoin('invite-code')
expect(getLastMethod()).toBe('federation.join')
})
it('federationListNodes calls federation.list-nodes', async () => {
mockSuccess({ nodes: [] })
await rpcClient.federationListNodes()
expect(getLastMethod()).toBe('federation.list-nodes')
})
it('federationRemoveNode calls federation.remove-node', async () => {
mockSuccess({ removed: true, nodes_remaining: 0 })
await rpcClient.federationRemoveNode('did:key:z')
expect(getLastMethod()).toBe('federation.remove-node')
})
it('federationSetTrust calls federation.set-trust', async () => {
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'trusted' })
await rpcClient.federationSetTrust('did:key:z', 'trusted')
expect(getLastMethod()).toBe('federation.set-trust')
})
it('federationSetTrust omits password on demotion', async () => {
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'observer' })
await rpcClient.federationSetTrust('did:key:z', 'observer')
expect(getLastParams()).not.toHaveProperty('password')
})
it('federationSetTrust forwards the password when promoting', async () => {
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'trusted' })
await rpcClient.federationSetTrust('did:key:z', 'trusted', 'hunter2')
expect(getLastParams()).toMatchObject({
did: 'did:key:z',
trust_level: 'trusted',
password: 'hunter2',
})
})
it('federationSyncState calls federation.sync-state', async () => {
mockSuccess({ synced: 1, failed: 0, results: [] })
await rpcClient.federationSyncState()
expect(getLastMethod()).toBe('federation.sync-state')
})
it('federationDeployApp calls federation.deploy-app', async () => {
mockSuccess({ deployed: true, app_id: 'btc', peer_did: 'did', peer_onion: 'onion' })
await rpcClient.federationDeployApp({ did: 'did:key:z', appId: 'btc' })
expect(getLastMethod()).toBe('federation.deploy-app')
expect(getLastParams().version).toBe('latest')
})
it('vpnStatus calls vpn.status', async () => {
mockSuccess({ connected: false, peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: false, configured_provider: '' })
await rpcClient.vpnStatus()
expect(getLastMethod()).toBe('vpn.status')
})
it('vpnConfigure calls vpn.configure', async () => {
mockSuccess({ configured: true, provider: 'tailscale' })
await rpcClient.vpnConfigure({ provider: 'tailscale', auth_key: 'key' })
expect(getLastMethod()).toBe('vpn.configure')
})
it('vpnDisconnect calls vpn.disconnect', async () => {
mockSuccess({ disconnected: true })
await rpcClient.vpnDisconnect()
expect(getLastMethod()).toBe('vpn.disconnect')
})
it('marketplaceDiscover calls marketplace.discover', async () => {
mockSuccess({ apps: [], relay_count: 0 })
await rpcClient.marketplaceDiscover()
expect(getLastMethod()).toBe('marketplace.discover')
})
it('dnsStatus calls network.dns-status', async () => {
mockSuccess({ provider: 'system', servers: [], doh_enabled: false, doh_url: null, resolv_conf_servers: [] })
await rpcClient.dnsStatus()
expect(getLastMethod()).toBe('network.dns-status')
})
it('configureDns calls network.configure-dns', async () => {
mockSuccess({ ok: true, provider: 'cloudflare', servers: [], doh_enabled: true, doh_url: null })
await rpcClient.configureDns({ provider: 'cloudflare' })
expect(getLastMethod()).toBe('network.configure-dns')
})
it('diskStatus calls system.disk-status', async () => {
mockSuccess({ used_bytes: 100, total_bytes: 1000, free_bytes: 900, used_percent: 10, level: 'ok' })
await rpcClient.diskStatus()
expect(getLastMethod()).toBe('system.disk-status')
})
it('diskCleanup calls system.disk-cleanup', async () => {
mockSuccess({ freed_bytes: 500, freed_human: '500B', actions: [] })
await rpcClient.diskCleanup()
expect(getLastMethod()).toBe('system.disk-cleanup')
})
})
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// Import after stubbing fetch
const { rpcClient } = await import('../rpc-client')
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText,
json: () => Promise.resolve(body),
headers: new Headers(),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status, statusText),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
text: () => Promise.resolve(JSON.stringify(body)),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('marketplaceDiscover', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns apps array and relay_count on success', async () => {
const payload = {
apps: [
{
manifest: {
app_id: 'bitcoin',
name: 'Bitcoin Core',
version: '27.0',
description: { short: 'Full node', long: 'Bitcoin Core full node' },
author: { name: 'Bitcoin', did: 'did:key:z111', nostr_pubkey: 'npub1abc' },
container: { image: 'bitcoin:27.0', ports: [{ container: 8333, host: 8333 }] },
category: 'bitcoin',
icon_url: '/icons/bitcoin.png',
repo_url: 'https://github.com/bitcoin/bitcoin',
license: 'MIT',
},
trust_score: 95,
trust_tier: 'verified',
relay_count: 8,
first_seen: '2025-01-15T00:00:00Z',
nostr_pubkey: 'npub1abc',
},
],
relay_count: 12,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.marketplaceDiscover()
expect(result.apps).toHaveLength(1)
expect(result.apps[0]!.manifest.app_id).toBe('bitcoin')
expect(result.apps[0]!.manifest.name).toBe('Bitcoin Core')
expect(result.apps[0]!.trust_score).toBe(95)
expect(result.relay_count).toBe(12)
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('marketplace.discover')
expect(body.params).toEqual({})
})
it('handles empty results', async () => {
const payload = {
apps: [],
relay_count: 0,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.marketplaceDiscover()
expect(result.apps).toEqual([])
expect(result.apps).toHaveLength(0)
expect(result.relay_count).toBe(0)
})
})
describe('diskStatus', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns expected fields', async () => {
const payload = {
used_bytes: 500_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 500_000_000_000,
used_percent: 50,
level: 'ok' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.used_bytes).toBe(500_000_000_000)
expect(result.total_bytes).toBe(1_000_000_000_000)
expect(result.free_bytes).toBe(500_000_000_000)
expect(result.used_percent).toBe(50)
expect(result.level).toBe('ok')
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('system.disk-status')
})
it('level is warning when percent >= 85', async () => {
const payload = {
used_bytes: 850_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 150_000_000_000,
used_percent: 85,
level: 'warning' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.level).toBe('warning')
expect(result.used_percent).toBe(85)
})
it('level is critical when percent >= 90', async () => {
const payload = {
used_bytes: 950_000_000_000,
total_bytes: 1_000_000_000_000,
free_bytes: 50_000_000_000,
used_percent: 95,
level: 'critical' as const,
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskStatus()
expect(result.level).toBe('critical')
expect(result.used_percent).toBe(95)
})
})
describe('diskCleanup', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('returns freed_bytes and actions array', async () => {
const payload = {
freed_bytes: 2_000_000_000,
freed_human: '2 GB',
actions: ['Removed 5 dangling images', 'Cleared build cache'],
}
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
const result = await rpcClient.diskCleanup()
expect(result.freed_bytes).toBe(2_000_000_000)
expect(result.freed_human).toBe('2 GB')
expect(result.actions).toHaveLength(2)
expect(result.actions[0]).toBe('Removed 5 dangling images')
expect(result.actions[1]).toBe('Cleared build cache')
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
expect(body.method).toBe('system.disk-cleanup')
})
it('uses 60s timeout', async () => {
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
mockFetch.mockRejectedValue(abortError)
const promise = rpcClient.diskCleanup()
// The call should eventually reject with timeout after retries
await expect(promise).rejects.toThrow('Request timeout')
// Verify all 3 attempts used the signal (timeout is set via AbortController)
expect(mockFetch).toHaveBeenCalledTimes(3)
for (const call of mockFetch.mock.calls) {
expect(call[1].signal).toBeInstanceOf(AbortSignal)
}
})
})
@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock fast-json-patch
vi.mock('fast-json-patch', () => ({
applyPatch: vi.fn((doc: unknown, _ops: unknown[]) => ({
newDocument: { ...doc as Record<string, unknown>, patched: true },
})),
}))
// Mock WebSocket
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readyState = MockWebSocket.CONNECTING
onopen: ((ev: Event) => void) | null = null
onclose: ((ev: CloseEvent) => void) | null = null
onerror: ((ev: Event) => void) | null = null
onmessage: ((ev: MessageEvent) => void) | null = null
url: string
constructor(url: string) {
this.url = url
// Auto-open in next tick
setTimeout(() => {
this.readyState = MockWebSocket.OPEN
this.onopen?.(new Event('open'))
}, 0)
}
send = vi.fn()
close = vi.fn().mockImplementation(function (this: MockWebSocket) {
this.readyState = MockWebSocket.CLOSED
this.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
})
}
vi.stubGlobal('WebSocket', MockWebSocket)
// Must import after mocks
const { WebSocketClient, applyDataPatch } = await import('../websocket')
describe('WebSocketClient', () => {
let client: InstanceType<typeof WebSocketClient>
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
client = new WebSocketClient('/ws/test')
})
afterEach(() => {
client.reset()
vi.useRealTimers()
})
it('initializes with disconnected state', () => {
expect(client.state).toBe('disconnected')
expect(client.isConnected()).toBe(false)
})
it('connects and transitions to connected state', async () => {
const states: string[] = []
client.onConnectionStateChange((s) => states.push(s))
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
expect(client.state).toBe('connected')
expect(client.isConnected()).toBe(true)
expect(states).toContain('connecting')
expect(states).toContain('connected')
})
it('resolves immediately if already connected', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
// Second connect should resolve immediately
await client.connect()
expect(client.isConnected()).toBe(true)
})
it('subscribe returns unsubscribe function', async () => {
const callback = vi.fn()
const unsub = client.subscribe(callback)
expect(typeof unsub).toBe('function')
unsub()
// Should not throw
})
it('notifies subscribers on message', async () => {
const callback = vi.fn()
client.subscribe(callback)
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
// Simulate receiving a message
const ws = (client as unknown as { ws: MockWebSocket }).ws
const update = { id: 1, type: 'state', data: { running: true } }
ws.onmessage?.(new MessageEvent('message', { data: JSON.stringify(update) }))
expect(callback).toHaveBeenCalledWith(update)
})
it('handles malformed JSON messages gracefully', async () => {
const callback = vi.fn()
client.subscribe(callback)
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
// Should not throw
ws.onmessage?.(new MessageEvent('message', { data: 'not-json{' }))
expect(callback).not.toHaveBeenCalled()
})
it('onConnectionStateChange returns unsubscribe function', () => {
const callback = vi.fn()
const unsub = client.onConnectionStateChange(callback)
expect(typeof unsub).toBe('function')
unsub()
})
it('disconnect sets state to disconnecting then cleans up', async () => {
const states: string[] = []
client.onConnectionStateChange((s) => states.push(s))
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
client.disconnect()
expect(states).toContain('disconnecting')
expect(client.isConnected()).toBe(false)
})
it('reset clears all callbacks and disconnects', async () => {
const callback = vi.fn()
client.subscribe(callback)
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
client.reset()
expect(client.isConnected()).toBe(false)
})
it('sends ping messages via heartbeat', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
// Advance past ping interval (30s)
await vi.advanceTimersByTimeAsync(31000)
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'ping' }))
})
it('disconnect prevents reconnection after abnormal close', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
// Disconnect explicitly — should prevent future reconnections
const states: string[] = []
client.onConnectionStateChange((s) => states.push(s))
client.disconnect()
expect(states).toContain('disconnecting')
})
it('handles close event with normal closure code', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
// Simulate normal close — should still try to reconnect (shouldReconnect is true)
ws.readyState = MockWebSocket.CLOSED
ws.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
// After close, state transitions to disconnected
// Then reconnection happens automatically (mock auto-opens)
await vi.advanceTimersByTimeAsync(200)
// Client should have attempted reconnect (state went through disconnected → connecting → connected)
expect(client.state).toBe('connected')
})
it('heartbeat detects stale connection after 5 minutes', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
const closeSpy = ws.close
// Advance 5+ minutes without any messages
await vi.advanceTimersByTimeAsync(310000)
// Heartbeat should have closed the stale connection
expect(closeSpy).toHaveBeenCalled()
})
it('state getter returns current connection state', () => {
expect(client.state).toBe('disconnected')
})
})
describe('applyDataPatch', () => {
it('returns original data for empty patch', () => {
const data = { a: 1, b: 2 }
const result = applyDataPatch(data, [])
expect(result).toBe(data)
})
it('returns original data for non-array patch', () => {
const data = { a: 1 }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = applyDataPatch(data, null as any)
expect(result).toBe(data)
})
it('applies valid patch operations', () => {
const data = { name: 'test', count: 0 }
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/count', value: 5 }]
const result = applyDataPatch(data, patch)
// The mock returns { ...data, patched: true }
expect(result).toHaveProperty('patched', true)
})
it('returns original data when patch application throws', async () => {
// Override mock to throw
const { applyPatch: mockApplyPatch } = await import('fast-json-patch')
vi.mocked(mockApplyPatch).mockImplementationOnce(() => {
throw new Error('Invalid patch')
})
const data = { value: 42 }
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/invalid', value: 0 }]
const result = applyDataPatch(data, patch)
expect(result).toBe(data)
})
})
+160
View File
@@ -0,0 +1,160 @@
// Container management API client
// Extends RPC client with container-specific methods
import { rpcClient } from './rpc-client'
export interface ContainerStatus {
id: string
name: string
state:
| 'created'
| 'running'
| 'stopped'
| 'exited'
| 'paused'
| 'unknown'
| 'stopping'
| 'starting'
| 'restarting'
| 'installing'
| 'updating'
| 'removing'
| 'installed'
image: string
created: string
ports: string[]
lan_address?: string // Launch URL for the app's UI
}
export interface ContainerAppInfo {
id: string
name: string
version: string
status: ContainerStatus
health: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
}
export interface BundledAppConfig {
id: string
name: string
image: string
ports: { host: number; container: number }[]
volumes: { host: string; container: string }[]
}
export const containerClient = {
/**
* Install a container app from a manifest file
*/
async installApp(manifestPath: string): Promise<string> {
return rpcClient.call<string>({
method: 'container-install',
params: { manifest_path: manifestPath },
})
},
/**
* Start a container
*/
async startContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-start',
params: { app_id: appId },
})
},
/**
* Stop a container
*/
async stopContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-stop',
params: { app_id: appId },
})
},
/**
* Restart a container (async; returns immediately with restarting state)
*/
async restartContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-restart',
params: { app_id: appId },
})
},
/**
* Remove a container
*/
async removeContainer(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'container-remove',
params: { app_id: appId },
})
},
/**
* Get container status
*/
async getContainerStatus(appId: string): Promise<ContainerStatus> {
return rpcClient.call<ContainerStatus>({
method: 'container-status',
params: { app_id: appId },
})
},
/**
* Get container logs
*/
async getContainerLogs(appId: string, lines: number = 100): Promise<string[]> {
return rpcClient.call<string[]>({
method: 'container-logs',
params: { app_id: appId, lines },
})
},
/**
* List all containers
*/
async listContainers(): Promise<ContainerStatus[]> {
return rpcClient.call<ContainerStatus[]>({
method: 'container-list',
params: {},
})
},
/**
* Get health status for all containers
*/
async getHealthStatus(): Promise<Record<string, string>> {
return rpcClient.call<Record<string, string>>({
method: 'container-health',
params: {},
})
},
/**
* Start a bundled app (creates container if needed, then starts it)
*/
async startBundledApp(app: BundledAppConfig): Promise<void> {
return rpcClient.call<void>({
method: 'bundled-app-start',
params: {
app_id: app.id,
image: app.image,
ports: app.ports,
volumes: app.volumes,
},
})
},
/**
* Stop a bundled app
*/
async stopBundledApp(appId: string): Promise<void> {
return rpcClient.call<void>({
method: 'bundled-app-stop',
params: { app_id: appId },
})
},
}
+294
View File
@@ -0,0 +1,294 @@
export interface FileBrowserItem {
name: string
path: string
size: number
modified: string
isDir: boolean
type: string
extension: string
}
interface FileBrowserListResponse {
items: FileBrowserItem[]
numDirs: number
numFiles: number
sorting: { by: string; asc: boolean }
}
/**
* Normalize a path: resolve `.` and `..`, reject traversal outside root.
* Always returns a path starting with `/` and never containing `..`.
*/
export function sanitizePath(path: string): string {
const segments = path.split('/').filter(Boolean)
const resolved: string[] = []
for (const seg of segments) {
if (seg === '.') continue
if (seg === '..') {
resolved.pop() // go up one level, but never past root
} else {
resolved.push(seg)
}
}
return '/' + resolved.join('/')
}
class FileBrowserClient {
private _authenticated = false
private baseUrl: string
constructor() {
this.baseUrl = `${window.location.origin}/app/filebrowser`
}
get isAuthenticated(): boolean {
return this._authenticated
}
private getAuthCookie(): string | null {
const match = document.cookie.match(/(?:^|;\s*)auth=([^;]+)/)
return match ? match[1]! : null
}
async login(): Promise<boolean> {
try {
// Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser)
// Use credentials: 'include' and CSRF token for proper auth
const csrfMatch = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
const csrfToken = csrfMatch ? csrfMatch[1]! : ''
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
const rpcRes = await fetch('/rpc/v1', {
method: 'POST',
headers,
body: JSON.stringify({ method: 'app.filebrowser-token' }),
credentials: 'include',
})
if (!rpcRes.ok) return false
const rpcData = await rpcRes.json()
const token = rpcData?.result?.token
if (!token) return false
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString()
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie = `auth=${token}; path=/; SameSite=Lax${secure}; expires=${expires}`
this._authenticated = true
return true
} catch {
return false
}
}
private headers(): Record<string, string> {
const h: Record<string, string> = {}
const cookie = this.getAuthCookie()
if (cookie) h['X-Auth'] = cookie
return h
}
/** Don't hammer app.filebrowser-token after a failed login — one attempt
* per cooldown window, so a broken/missing filebrowser doesn't turn every
* poll of the Files card into a fresh login + 401 pair (console spam). */
private _lastLoginFailure = 0
private static readonly LOGIN_RETRY_COOLDOWN_MS = 60_000
/** Ensure we're authenticated before making a request. Auto-logins if needed. */
private async ensureAuth(): Promise<void> {
if (this._authenticated && this.getAuthCookie()) return
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
const ok = await this.login()
if (!ok) {
this._lastLoginFailure = Date.now()
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
}
/** fetch() with auth headers + ONE transparent re-login on 401. The JWT
* from app.filebrowser-token is short-lived; before this, an expired
* cookie kept `_authenticated` true and every Files-card poll 401'd
* forever (the console-spam bug, 2026-07-22). */
private async authedFetch(url: string, init?: RequestInit): Promise<Response> {
await this.ensureAuth()
let res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
if (res.status === 401) {
this._authenticated = false
await this.ensureAuth()
res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
}
return res
}
async listDirectory(path: string): Promise<FileBrowserItem[]> {
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`)
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
// When File Browser isn't installed, nginx falls through to the SPA and
// returns index.html (200, text/html); when it's down it returns 502.
// Either way res.json() would throw the opaque "Unexpected token '<'"
// error, so detect a non-JSON body and surface a friendly message instead.
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
throw new Error('File Browser is not available — install or start the File Browser app to use your folders')
}
const data: FileBrowserListResponse = await res.json()
return (data.items || []).map((item) => ({
...item,
extension: item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '',
}))
}
/**
* @deprecated Use fetchBlobUrl() instead to avoid exposing tokens in URLs.
* Returns a plain URL (no token in query string).
*/
downloadUrl(path: string): string {
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}`
}
/**
* Fetch a file as a blob URL using header-based auth (no token in URL).
* Use this for img/video/audio src attributes and download links.
* For large files (video/audio), prefer streamUrl() instead.
*/
async fetchBlobUrl(path: string): Promise<string> {
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
const blob = await res.blob()
return URL.createObjectURL(blob)
}
/**
* Get a direct streaming URL for video/audio `<src>` where the browser
* needs to make Range requests.
*
* Carries NO credential in the query string (T-13-39, fixed 2026-08-03 —
* this was "the known leak to fix rather than propagate", per
* 13-CONTEXT.md). `login()` already sets the filebrowser JWT as a
* `path=/` cookie on this page's own origin, `baseUrl` is that same
* origin, and the browser attaches the cookie to the same-origin media
* subresource request automatically — the same mechanism filebrowser's
* own web UI relies on. Putting the token in the URL too was redundant,
* and it reached browser history, `Referer` headers and any access log on
* the path. The cookie itself is unchanged by this fix: it is still a
* 24-hour JWT, now confined to the cookie jar rather than also appearing
* in the URL.
*/
async streamUrl(path: string): Promise<string> {
await this.ensureAuth()
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}`
}
/**
* Trigger a file download using header-based auth (no token in URL).
*/
async downloadFile(path: string): Promise<void> {
const blobUrl = await this.fetchBlobUrl(path)
const filename = path.split('/').pop() || 'download'
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
}
async upload(dirPath: string, file: File): Promise<void> {
const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name)
const res = await this.authedFetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
{ method: 'POST', body: file },
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Upload failed (${res.status}): ${text}`)
}
}
async createFolder(parentPath: string, name: string): Promise<void> {
const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
method: 'POST',
})
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
}
async deleteItem(path: string): Promise<void> {
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
}
async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> {
let res: Response
try {
res = await this.authedFetch(`${this.baseUrl}/api/resources/`)
} catch {
// Not installed / login cooling down — the Files card shows zeros.
return { totalSize: 0, folderCount: 0, fileCount: 0 }
}
if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
const data: FileBrowserListResponse = await res.json()
const items = data.items || []
const folderCount = items.filter(i => i.isDir).length
const fileCount = items.filter(i => !i.isDir).length
const totalSize = items.reduce((sum, i) => sum + (i.size || 0), 0)
return { totalSize, folderCount, fileCount }
}
private static TEXT_EXTENSIONS = new Set([
'txt', 'md', 'json', 'csv', 'log', 'conf', 'yaml', 'yml', 'toml', 'xml',
'html', 'css', 'js', 'ts', 'py', 'sh', 'bash', 'env', 'ini', 'cfg',
'sql', 'rs', 'go', 'java', 'c', 'h', 'cpp', 'hpp', 'rb', 'php',
'dockerfile', 'makefile', 'gitignore', 'editorconfig',
])
isTextFile(path: string): boolean {
const ext = path.includes('.') ? path.split('.').pop()!.toLowerCase() : ''
const name = path.split('/').pop()?.toLowerCase() || ''
return FileBrowserClient.TEXT_EXTENSIONS.has(ext) || FileBrowserClient.TEXT_EXTENSIONS.has(name)
}
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> {
if (!this.isTextFile(path)) {
throw new Error(`Cannot read binary file: ${path}`)
}
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
const blob = await res.blob()
const size = blob.size
const truncated = size > maxBytes
const slice = truncated ? blob.slice(0, maxBytes) : blob
const content = await slice.text()
return { content, truncated, size }
}
async rename(oldPath: string, newName: string): Promise<void> {
const safePath = sanitizePath(oldPath)
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
})
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
}
}
export const fileBrowserClient = new FileBrowserClient()
+411
View File
@@ -0,0 +1,411 @@
/**
* Remote Relay — receives companion app input via WebSocket and dispatches
* keyboard/mouse/scroll events into the browser, enabling the NES controller
* or companion keyboard to drive the web UI from another device.
*/
import { ref } from 'vue'
// xdotool key name → DOM key mapping
const KEY_MAP: Record<string, string> = {
Return: 'Enter',
BackSpace: 'Backspace',
Escape: 'Escape',
Tab: 'Tab',
Delete: 'Delete',
space: ' ',
Up: 'ArrowUp',
Down: 'ArrowDown',
Left: 'ArrowLeft',
Right: 'ArrowRight',
Home: 'Home',
End: 'End',
Prior: 'PageUp',
Next: 'PageDown',
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4', F5: 'F5', F6: 'F6',
F7: 'F7', F8: 'F8', F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
}
/** Reactive: relay WebSocket is connected to the server */
export const relayConnected = ref(false)
/** Reactive: a companion app is actively sending input (received input in last 30s) */
export const companionActive = ref(false)
/** Reactive: input is being received right now (flickers on each event) */
export const companionInputActive = ref(false)
let ws: WebSocket | null = null
let shouldReconnect = true
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
// Exponential backoff for the relay socket. It's a secondary feature (companion
// input), so when the backend is down it must NOT hammer a fixed-interval
// reconnect — that floods the console/network with failed-WS noise for the whole
// outage. Back off 1s → 30s, reset on a successful open. (Mirrors websocket.ts.)
let relayReconnectAttempts = 0
const RELAY_RECONNECT_BASE_MS = 1000
const RELAY_RECONNECT_MAX_MS = 30_000
let cursorEl: HTMLDivElement | null = null
let companionTimeout: ReturnType<typeof setTimeout> | null = null
let inputFlickerTimeout: ReturnType<typeof setTimeout> | null = null
let cursorX = typeof window !== 'undefined' ? window.innerWidth / 2 : 0
let cursorY = typeof window !== 'undefined' ? window.innerHeight / 2 : 0
let cursorHideTimer: ReturnType<typeof setTimeout> | null = null
function markCompanionActive() {
companionActive.value = true
companionInputActive.value = true
if (inputFlickerTimeout) clearTimeout(inputFlickerTimeout)
inputFlickerTimeout = setTimeout(() => { companionInputActive.value = false }, 200)
if (companionTimeout) clearTimeout(companionTimeout)
companionTimeout = setTimeout(() => { companionActive.value = false }, 30_000)
}
function createCursor(): HTMLDivElement {
if (cursorEl) return cursorEl
const el = document.createElement('div')
el.id = 'remote-relay-cursor'
el.style.cssText = `
position: fixed; z-index: 999999; pointer-events: none;
width: 20px; height: 20px; border-radius: 50%;
background: rgba(247, 147, 26, 0.7);
border: 2px solid rgba(247, 147, 26, 0.9);
transform: translate(-50%, -50%);
transition: opacity 0.3s;
opacity: 0; display: none;
`
document.body.appendChild(el)
cursorEl = el
return el
}
function showCursor() {
const el = createCursor()
el.style.display = 'block'
el.style.opacity = '1'
el.style.left = `${cursorX}px`
el.style.top = `${cursorY}px`
if (cursorHideTimer) clearTimeout(cursorHideTimer)
cursorHideTimer = setTimeout(() => {
if (cursorEl) cursorEl.style.opacity = '0'
}, 3000)
}
function moveCursor(dx: number, dy: number) {
cursorX = Math.max(0, Math.min(window.innerWidth, cursorX + dx))
cursorY = Math.max(0, Math.min(window.innerHeight, cursorY + dy))
showCursor()
}
function mapKey(xdotoolKey: string): string {
return KEY_MAP[xdotoolKey] ?? xdotoolKey
}
/** <input> types that accept free-text entry (so we should type into them). */
const TEXT_INPUT_TYPES = new Set([
'text', 'search', 'url', 'tel', 'password', 'email', 'number', '',
])
export function isTextField(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
if (!el) return false
if (el.tagName === 'TEXTAREA') return true
if (el.tagName === 'INPUT') {
const type = ((el as HTMLInputElement).type || 'text').toLowerCase()
return TEXT_INPUT_TYPES.has(type)
}
return false
}
/**
* elementFromPoint that descends through SAME-ORIGIN iframes, so the cursor
* can target elements *inside* embedded apps (gitea, uptime-kuma, AIUI — any
* app served same-origin via /app/… or /aiui/). Cross-origin iframes (apps on
* direct ports) are opaque to the parent by browser security policy, so the
* deepest reachable element there is the <iframe> itself.
*/
function deepElementFromPoint(x: number, y: number): Element | null {
let cx = x
let cy = y
let el = document.elementFromPoint(cx, cy)
let guard = 0
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
let doc: Document | null = null
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
if (!doc) break
const rect = el.getBoundingClientRect()
cx -= rect.left
cy -= rect.top
const inner = doc.elementFromPoint(cx, cy)
if (!inner || inner === el) break
el = inner
}
return el
}
/**
* Find the nearest scrollable ancestor of `el` for the given delta, hopping out
* of same-origin iframes when needed. Synthetic WheelEvents are untrusted and
* never actually scroll the page, so two-finger scroll must call scrollBy on a
* real scroll container — this locates it (e.g. the right-hand app frame). (#7)
*/
function findScrollable(el: Element | null, dx: number, dy: number): Element | null {
let node: Element | null = el
let guard = 0
while (node && guard++ < 60) {
const win = node.ownerDocument?.defaultView
const style = win?.getComputedStyle(node)
if (style) {
const oy = style.overflowY
const ox = style.overflowX
const isRoot = node === node.ownerDocument?.scrollingElement
const canY =
(oy === 'auto' || oy === 'scroll' || isRoot) &&
node.scrollHeight > node.clientHeight + 1
const canX =
(ox === 'auto' || ox === 'scroll' || isRoot) &&
node.scrollWidth > node.clientWidth + 1
if ((dy !== 0 && canY) || (dx !== 0 && canX)) return node
}
if (node.parentElement) {
node = node.parentElement
} else if (win?.frameElement) {
node = win.frameElement as Element // same-origin iframe → continue in parent doc
} else {
break
}
}
return null
}
/** The actually-focused element, descending through same-origin iframes. */
function deepActiveElement(): Element | null {
let el: Element | null = document.activeElement
let guard = 0
while (el && el.tagName === 'IFRAME' && guard++ < 5) {
let doc: Document | null = null
try { doc = (el as HTMLIFrameElement).contentDocument } catch { break }
if (!doc || !doc.activeElement || doc.activeElement === doc.body) break
el = doc.activeElement
}
return el
}
/**
* Apply a key to a focused text field. Synthetic KeyboardEvents do NOT mutate
* input values (browser security), so we edit `.value` at the caret directly
* and fire an `input` event so Vue v-model / reactive search pick it up.
* Returns true if the key was consumed as text editing.
*/
export function typeKeyIntoField(el: HTMLInputElement | HTMLTextAreaElement, key: string): boolean {
const value = el.value
const start = el.selectionStart ?? value.length
const end = el.selectionEnd ?? value.length
const setCaret = (pos: number) => { try { el.selectionStart = el.selectionEnd = pos } catch { /* e.g. number inputs */ } }
const replaceSelection = (text: string) => {
el.value = value.slice(0, start) + text + value.slice(end)
setCaret(start + text.length)
}
if (key === 'Backspace') {
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
else if (start > 0) { el.value = value.slice(0, start - 1) + value.slice(end); setCaret(start - 1) }
else return true
} else if (key === 'Delete') {
if (start !== end) { el.value = value.slice(0, start) + value.slice(end); setCaret(start) }
else { el.value = value.slice(0, start) + value.slice(start + 1); setCaret(start) }
} else if (key === 'ArrowLeft') {
setCaret(Math.max(0, start - 1))
} else if (key === 'ArrowRight') {
setCaret(Math.min(value.length, end + 1))
} else if (key === 'Home') {
setCaret(0)
} else if (key === 'End') {
setCaret(value.length)
} else if (key === 'Enter') {
if (el.tagName === 'TEXTAREA') replaceSelection('\n')
else return false // let the app's keydown handler act (e.g. search submit)
} else if (key.length === 1) {
replaceSelection(key) // printable character
} else {
return false // Tab / Escape / F-keys / etc. — not text editing
}
el.dispatchEvent(new Event('input', { bubbles: true }))
return true
}
function handleMessage(data: string) {
let msg: { t: string; k?: string; x?: number; y?: number; b?: number; p?: number }
try {
msg = JSON.parse(data)
} catch {
return
}
if (msg.t === 'ok') return // server ready, not companion input
markCompanionActive()
switch (msg.t) {
case 'k': {
if (!msg.k) break
const key = mapKey(msg.k)
// Dispatch player-tagged event for arcade/game apps (iframe postMessage or direct listeners)
const player = msg.p ?? 0 // 0 = untagged/broadcast, 1 = P1, 2 = P2
document.dispatchEvent(new CustomEvent('arcade-input', {
detail: { key, player, type: 'down' },
bubbles: true,
}))
// Also post to any iframe that might be listening (containerized apps like BotFights)
const iframe = document.querySelector('iframe') as HTMLIFrameElement | null
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'arcade-input', key, player, action: 'down' }, '*')
}
// Deliver the key to the actually-focused element (descending into
// same-origin iframes) so it reaches embedded-app inputs and search
// boxes, not just the top-level document.
const focused = deepActiveElement()
const keyTarget: EventTarget = focused ?? document
keyTarget.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
// Synthetic key events never insert text, so edit the field directly.
if (isTextField(focused)) {
typeKeyIntoField(focused, key)
}
keyTarget.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
break
}
case 'm': {
moveCursor(msg.x ?? 0, msg.y ?? 0)
break
}
case 'c': {
const target = deepElementFromPoint(cursorX, cursorY)
if (target) {
if (cursorEl) {
cursorEl.style.background = 'rgba(247, 147, 26, 1)'
setTimeout(() => { if (cursorEl) cursorEl.style.background = 'rgba(247, 147, 26, 0.7)' }, 150)
}
const eventInit: MouseEventInit = {
bubbles: true, cancelable: true, view: window,
clientX: cursorX, clientY: cursorY,
}
target.dispatchEvent(new MouseEvent('mousedown', eventInit))
target.dispatchEvent(new MouseEvent('mouseup', eventInit))
target.dispatchEvent(new MouseEvent('click', eventInit))
// A synthetic click does NOT move keyboard focus the way a real click
// does, so the app-store search box (and any input) would stay
// unfocused and untypable. Explicitly focus the nearest focusable
// element — for same-origin iframe targets this focuses inside the app.
const focusable = (target.closest?.(
'input, textarea, select, button, a[href], [contenteditable], [tabindex]',
) ?? target) as HTMLElement
if (typeof focusable.focus === 'function') {
focusable.focus({ preventScroll: true })
}
}
break
}
case 's': {
// Scroll the element under the virtual cursor (incl. inside same-origin
// app frames like the right-hand panel), not the top document. A synthetic
// wheel event won't scroll — call scrollBy on a real scroll container. (#7)
const dy = (msg.y ?? 0) * 100
const dx = (msg.x ?? 0) * 100
const start = deepElementFromPoint(cursorX, cursorY)
const scroller = findScrollable(start, dx, dy)
if (scroller) {
scroller.scrollBy({ left: dx, top: dy })
} else {
const win = start?.ownerDocument?.defaultView ?? window
win.scrollBy(dx, dy)
}
break
}
}
}
function doConnect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${protocol}//${window.location.host}/ws/remote-relay`
ws = new WebSocket(url)
ws.onopen = () => {
relayConnected.value = true
relayReconnectAttempts = 0 // healthy again — reset backoff
if (import.meta.env.DEV) console.log('[RemoteRelay] Connected')
}
ws.onmessage = (event) => {
handleMessage(event.data)
}
ws.onclose = () => {
relayConnected.value = false
ws = null
if (shouldReconnect) {
const delay = Math.min(
RELAY_RECONNECT_BASE_MS * 2 ** relayReconnectAttempts,
RELAY_RECONNECT_MAX_MS,
)
relayReconnectAttempts++
reconnectTimer = setTimeout(doConnect, delay)
}
}
ws.onerror = () => {
// onclose will handle reconnect
}
}
/**
* Ask the companion (phone) to open a URL in its own browser.
*
* "Open in external browser" apps can't be usefully opened on the kiosk when a
* companion is driving it — `window.open` lands on the kiosk, which the phone
* user never sees. When a companion is active we forward the URL over the relay
* socket ({"t":"o","url"}); the backend routes it to the phone, which opens it.
*
* Returns true if the request was forwarded (caller should NOT open locally),
* false if there's no active companion (caller should open normally).
*/
export function requestExternalOpen(url: string): boolean {
if (!url || !/^https?:\/\//i.test(url)) return false
if (!companionActive.value) return false
if (!ws || ws.readyState !== WebSocket.OPEN) return false
try {
ws.send(JSON.stringify({ t: 'o', url }))
if (import.meta.env.DEV) console.log('[RemoteRelay] Forwarded external-open to companion:', url)
return true
} catch {
return false
}
}
/** Start the remote relay listener. Connects to /ws/remote-relay. */
export function startRemoteRelay() {
shouldReconnect = true
relayReconnectAttempts = 0
doConnect()
}
/** Stop the remote relay listener and clean up. */
export function stopRemoteRelay() {
shouldReconnect = false
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null }
if (companionTimeout) { clearTimeout(companionTimeout); companionTimeout = null }
if (inputFlickerTimeout) { clearTimeout(inputFlickerTimeout); inputFlickerTimeout = null }
if (cursorHideTimer) { clearTimeout(cursorHideTimer); cursorHideTimer = null }
if (ws) { ws.onclose = null; ws.close(); ws = null }
if (cursorEl) { cursorEl.remove(); cursorEl = null }
relayConnected.value = false
companionActive.value = false
companionInputActive.value = false
}
File diff suppressed because it is too large Load Diff
+456
View File
@@ -0,0 +1,456 @@
// WebSocket handler for real-time updates
import type { Update, PatchOperation } from '../types/api'
import { applyPatch, type Operation } from 'fast-json-patch'
export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
type WebSocketCallback = (update: Update) => void
type ConnectionStateCallback = (state: ConnectionState) => void
export class WebSocketClient {
private ws: WebSocket | null = null
private callbacks: Set<WebSocketCallback> = new Set()
private connectionStateCallbacks: Set<ConnectionStateCallback> = new Set()
private reconnectAttempts = 0
private maxReconnectAttempts = 10
private reconnectDelay = 1000
private maxReconnectDelay = 30000
private shouldReconnect = true
private url: string
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private visibilityChangeHandler: (() => void) | null = null
private onlineHandler: (() => void) | null = null
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
private pingTimer: ReturnType<typeof setInterval> | null = null
private lastMessageTime: number = Date.now()
private heartbeatInterval = 10000 // Check connection every 10 seconds
private pingInterval = 30000 // Send ping every 30 seconds
private _state: ConnectionState = 'disconnected'
private isReconnecting = false
private parseErrorCount = 0
private connectCheckInterval: ReturnType<typeof setInterval> | null = null
constructor(url: string = '/ws/db') {
this.url = url
this.setupBrowserEventHandlers()
}
private setupBrowserEventHandlers(): void {
if (typeof window === 'undefined') return
// Handle page visibility changes (tab switching, browser minimizing)
this.visibilityChangeHandler = () => {
if (document.visibilityState === 'visible') {
if (import.meta.env.DEV) console.log('[WebSocket] Page became visible, checking connection...')
// Only reconnect if we haven't been explicitly disconnected
if (this.shouldReconnect && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
if (import.meta.env.DEV) console.log('[WebSocket] Connection lost while hidden, reconnecting...')
this.reconnectAttempts = 0
this.connect().catch(err => {
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect on visibility change:', err)
})
}
}
}
document.addEventListener('visibilitychange', this.visibilityChangeHandler)
// Handle network online/offline events
this.onlineHandler = () => {
// Only reconnect if we haven't been explicitly disconnected
if (!this.shouldReconnect) return
if (import.meta.env.DEV) console.log('[WebSocket] Network came online, reconnecting...')
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.reconnectAttempts = 0
this.connect().catch(err => {
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect when network came online:', err)
})
}
}
window.addEventListener('online', this.onlineHandler)
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
// If already connected, resolve immediately
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
if (import.meta.env.DEV) console.log('[WebSocket] Already connected, skipping')
resolve()
return
}
// If connecting, wait for it
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting, waiting...')
this.clearConnectCheck()
this.connectCheckInterval = setInterval(() => {
if (this.ws) {
if (this.ws.readyState === WebSocket.OPEN) {
this.clearConnectCheck()
resolve()
} else if (this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
this.clearConnectCheck()
// Connection failed or closing, will be handled by onclose
reject(new Error('Connection closed during connect'))
}
} else {
this.clearConnectCheck()
reject(new Error('WebSocket was cleared'))
}
}, 100)
// Timeout after 5 seconds
setTimeout(() => {
this.clearConnectCheck()
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
reject(new Error('Connection timeout'))
}
}, 5000)
return
}
// Don't close existing connection if it's still active
// Only close if it's in CLOSING or CLOSED state
if (this.ws && (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED)) {
this.ws = null
}
// If we have an active WebSocket, don't create a new one
if (this.ws) {
if (import.meta.env.DEV) console.log('[WebSocket] Connection exists, reusing it')
resolve()
return
}
// Only enable reconnect if not explicitly disconnected
// (shouldReconnect is set to false by disconnect())
if (this.shouldReconnect !== false) {
this.shouldReconnect = true
}
// In development, Vite proxies /ws to the backend
// In production, use the same host as the page
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}${this.url}`
if (import.meta.env.DEV) console.log('[WebSocket] Connecting to:', wsUrl)
this.setConnectionState('connecting')
this.ws = new WebSocket(wsUrl)
// Timeout handler in case connection hangs
const connectionTimeout = setTimeout(() => {
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
if (import.meta.env.DEV) console.warn('WebSocket connection timeout, retrying...')
this.ws.close()
reject(new Error('Connection timeout'))
}
}, 3000) // 3 second timeout
this.ws.onopen = () => {
clearTimeout(connectionTimeout)
this.reconnectAttempts = 0
this.lastMessageTime = Date.now()
if (import.meta.env.DEV) console.log('[WebSocket] Connected successfully')
this.setConnectionState('connected')
this.startHeartbeat()
resolve()
}
this.ws.onerror = (error) => {
clearTimeout(connectionTimeout)
if (import.meta.env.DEV) console.error('[WebSocket] Connection error:', error)
// Don't reject immediately - let onclose handle reconnection
// This prevents errors from blocking reconnection
}
this.ws.onmessage = (event) => {
this.lastMessageTime = Date.now()
try {
const update: Update = JSON.parse(event.data)
this.parseErrorCount = 0
this.callbacks.forEach((callback) => callback(update))
} catch (error) {
this.parseErrorCount++
if (import.meta.env.DEV) console.error(`Failed to parse WebSocket message (${this.parseErrorCount} consecutive):`, error)
if (this.parseErrorCount > 3) {
if (import.meta.env.DEV) console.warn('[WebSocket] Too many parse errors, closing to trigger reconnection')
this.ws?.close()
}
}
}
this.ws.onclose = (event) => {
clearTimeout(connectionTimeout)
this.stopHeartbeat()
if (import.meta.env.DEV) console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
// Notify connection state changed
this.setConnectionState('disconnected')
// Clear the WebSocket reference
this.ws = null
// Don't reconnect if we explicitly disconnected
if (!this.shouldReconnect) {
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection disabled')
return
}
// Always try to reconnect unless we've exceeded max attempts
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const isHMR = event.code === 1001
const isNormalClosure = event.code === 1000 || event.code === 1001
const isServiceRestart = event.code === 1012
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
const delay = needsImmediateReconnect ? 0 :
(this.reconnectAttempts === 0 ? 100 :
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay))
if (import.meta.env.DEV) console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
// Clear any existing reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
const doReconnect = () => {
// Check again if we should reconnect (might have been disabled)
if (!this.shouldReconnect) {
return
}
// Prevent parallel reconnections from duplicate onclose events
if (this.isReconnecting) {
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection already in progress, skipping')
return
}
// Don't increment attempts for expected disconnects (HMR, normal closure)
if (!isHMR && !isNormalClosure) {
this.reconnectAttempts++
}
if (import.meta.env.DEV) console.log('[WebSocket] Attempting reconnection...')
this.isReconnecting = true
this.connect().then(() => {
this.isReconnecting = false
}).catch((err) => {
this.isReconnecting = false
if (import.meta.env.DEV) console.error('[WebSocket] Reconnection failed:', err)
// onclose will be called again and will retry
})
}
if (delay === 0) {
// Immediate reconnection
doReconnect()
} else {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
doReconnect()
}, delay)
}
} else {
if (import.meta.env.DEV) console.warn('[WebSocket] Max reconnection attempts reached')
this.shouldReconnect = false
}
}
})
}
subscribe(callback: WebSocketCallback): () => void {
this.callbacks.add(callback)
return () => {
this.callbacks.delete(callback)
}
}
get state(): ConnectionState {
return this._state
}
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
this.connectionStateCallbacks.add(callback)
return () => {
this.connectionStateCallbacks.delete(callback)
}
}
private setConnectionState(state: ConnectionState): void {
this._state = state
this.connectionStateCallbacks.forEach((callback) => callback(state))
}
private clearConnectCheck(): void {
if (this.connectCheckInterval) {
clearInterval(this.connectCheckInterval)
this.connectCheckInterval = null
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
// Send ping messages every 30s
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'ping' }))
} catch {
// Send failed, connection likely broken
}
}
}, this.pingInterval)
// Check connection health every 10s
this.heartbeatTimer = setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
if (import.meta.env.DEV) console.warn('[WebSocket] Heartbeat detected closed connection')
this.stopHeartbeat()
return
}
const timeSinceLastMessage = Date.now() - this.lastMessageTime
// If no message for more than 5 minutes, assume connection is stale
if (timeSinceLastMessage > 300000) {
if (import.meta.env.DEV) console.warn('[WebSocket] No messages for 5m, reconnecting...')
this.ws.close()
return
}
}, this.heartbeatInterval)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.pingTimer) {
clearInterval(this.pingTimer)
this.pingTimer = null
}
}
disconnect(): void {
this.shouldReconnect = false
this.reconnectAttempts = 0
this.setConnectionState('disconnecting')
this.stopHeartbeat()
this.clearConnectCheck()
// Clear reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.ws) {
// Remove handlers to prevent reconnection
this.ws.onclose = null
this.ws.onerror = null
try {
this.ws.close()
} catch (e) {
if (import.meta.env.DEV) console.warn('WebSocket close error', e)
}
this.ws = null
}
}
reset(): void {
this.disconnect()
this.callbacks.clear()
// Clean up browser event handlers
if (this.visibilityChangeHandler) {
document.removeEventListener('visibilitychange', this.visibilityChangeHandler)
this.visibilityChangeHandler = null
}
if (this.onlineHandler) {
window.removeEventListener('online', this.onlineHandler)
this.onlineHandler = null
}
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN
}
}
// Create singleton that persists across HMR
let wsClientInstance: WebSocketClient | null = null
function getWebSocketClient(): WebSocketClient {
if (typeof window === 'undefined') {
// SSR - create new instance
if (!wsClientInstance) {
wsClientInstance = new WebSocketClient()
}
return wsClientInstance
}
// Check if we have a persisted instance from HMR
const existing = (window as unknown as Record<string, unknown>).__archipelago_ws_client
if (existing && existing instanceof WebSocketClient) {
// Check if the WebSocket is still valid
if (existing.isConnected()) {
if (import.meta.env.DEV) console.log('[WebSocket] Using existing connected client from HMR')
wsClientInstance = existing
return existing
}
}
// Create new instance
if (!wsClientInstance) {
wsClientInstance = new WebSocketClient()
if (typeof window !== 'undefined') {
;(window as unknown as Record<string, unknown>).__archipelago_ws_client = wsClientInstance
}
if (import.meta.env.DEV) console.debug('[WebSocket] Created new client instance')
}
return wsClientInstance
}
// Lazy initialization - only create when accessed
let _wsClient: WebSocketClient | null = null
export const wsClient: WebSocketClient = (() => {
if (_wsClient) {
return _wsClient
}
try {
_wsClient = getWebSocketClient()
return _wsClient
} catch (error) {
if (import.meta.env.DEV) console.error('[WebSocket] Error initializing client:', error)
// Fallback to new instance
_wsClient = new WebSocketClient()
return _wsClient
}
})()
// Helper to apply patches to data
export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
// Validate patch is an array before applying
if (!Array.isArray(patch) || patch.length === 0) {
if (import.meta.env.DEV) console.warn('Invalid or empty patch received, returning original data')
return data
}
try {
const result = applyPatch(data, patch as Operation[], false, false)
return result.newDocument as T
} catch (error) {
if (import.meta.env.DEV) console.error('Failed to apply patch:', error, 'Patch:', patch)
return data // Return original data on error
}
}