Archipelago — open-source initial import
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user