mid coding commit

This commit is contained in:
zazawowow
2026-01-24 22:59:20 +00:00
parent 64cc3bc7fb
commit 731cd67cfb
2228 changed files with 135554 additions and 18 deletions

View File

@@ -0,0 +1,181 @@
// RPC Client for connecting to Archipelago backend
export interface RPCOptions {
method: string
params?: any
timeout?: number
}
export interface RPCResponse<T> {
result?: T
error?: {
code: number
message: string
data?: any
}
}
class RPCClient {
private baseUrl: string
constructor(baseUrl: string = '/rpc/v1') {
this.baseUrl = baseUrl
}
async call<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 30000 } = options
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(this.baseUrl, {
method: 'POST',
credentials: 'include', // Important for session cookies
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ method, params }),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data: RPCResponse<T> = await response.json()
if (data.error) {
throw new Error(data.error.message || 'RPC Error')
}
return data.result as T
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new Error('Request timeout')
}
throw error
}
throw new Error('Unknown error occurred')
}
}
// Convenience methods
async login(password: string): Promise<void> {
return this.call({
method: 'auth.login',
params: {
password,
metadata: {
// Add any metadata needed
},
},
})
}
async logout(): Promise<void> {
return this.call({
method: 'auth.logout',
params: {},
})
}
async echo(message: string): Promise<string> {
return this.call({
method: 'server.echo',
params: { message },
})
}
async getSystemTime(): Promise<{ now: string; uptime: number }> {
return this.call({
method: 'server.time',
params: {},
})
}
async getMetrics(): Promise<any> {
return this.call({
method: 'server.metrics',
params: {},
})
}
async updateServer(marketplaceUrl: string): Promise<'updating' | 'no-updates'> {
return this.call({
method: 'server.update',
params: { 'marketplace-url': marketplaceUrl },
})
}
async restartServer(): Promise<void> {
return this.call({
method: 'server.restart',
params: {},
})
}
async shutdownServer(): Promise<void> {
return this.call({
method: 'server.shutdown',
params: {},
})
}
async installPackage(id: string, marketplaceUrl: string, version: string): Promise<string> {
return this.call({
method: 'package.install',
params: { id, 'marketplace-url': marketplaceUrl, version },
})
}
async uninstallPackage(id: string): Promise<void> {
return this.call({
method: 'package.uninstall',
params: { id },
})
}
async startPackage(id: string): Promise<void> {
return this.call({
method: 'package.start',
params: { id },
})
}
async stopPackage(id: string): Promise<void> {
return this.call({
method: 'package.stop',
params: { id },
})
}
async restartPackage(id: string): Promise<void> {
return this.call({
method: 'package.restart',
params: { id },
})
}
async getMarketplace(url: string): Promise<any> {
return this.call({
method: 'marketplace.get',
params: { url },
})
}
async sideloadPackage(manifest: any, icon: string): Promise<string> {
return this.call({
method: 'package.sideload',
params: { manifest, icon },
timeout: 120000, // 2 minutes for upload
})
}
}
export const rpcClient = new RPCClient()

View File

@@ -0,0 +1,276 @@
// WebSocket handler for real-time updates
import type { Update, PatchOperation } from '../types/api'
import { applyPatch } from 'fast-json-patch'
type WebSocketCallback = (update: Update) => void
export class WebSocketClient {
private ws: WebSocket | null = null
private callbacks: Set<WebSocketCallback> = new Set()
private reconnectAttempts = 0
private maxReconnectAttempts = 10
private reconnectDelay = 1000
private shouldReconnect = true
private url: string
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private isConnecting = false
constructor(url: string = '/ws/db') {
this.url = url
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
// If already connected, resolve immediately
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] Already connected, skipping')
resolve()
return
}
// If connecting, wait for it
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
console.log('[WebSocket] Already connecting, waiting...')
const checkInterval = setInterval(() => {
if (this.ws) {
if (this.ws.readyState === WebSocket.OPEN) {
clearInterval(checkInterval)
resolve()
} else if (this.ws.readyState === WebSocket.CLOSED) {
clearInterval(checkInterval)
// Connection failed, will be handled by onclose
reject(new Error('Connection closed during connect'))
}
} else {
clearInterval(checkInterval)
reject(new Error('WebSocket was cleared'))
}
}, 100)
// Timeout after 5 seconds
setTimeout(() => {
clearInterval(checkInterval)
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
reject(new Error('Connection timeout'))
}
}, 5000)
return
}
// Close existing connection if any (but don't prevent reconnection)
if (this.ws) {
const oldWs = this.ws
this.ws = null
// Temporarily disable reconnection to prevent loop
const wasReconnecting = this.shouldReconnect
this.shouldReconnect = false
oldWs.onclose = null // Remove close handler
oldWs.close()
// Restore reconnection flag after a moment
setTimeout(() => {
this.shouldReconnect = wasReconnecting
}, 100)
}
// Reset shouldReconnect flag when explicitly connecting
this.shouldReconnect = true
// Reset reconnect attempts only if we're explicitly connecting (not auto-reconnecting)
// This allows reconnection attempts to continue
// 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}`
console.log('[WebSocket] Connecting to:', wsUrl)
this.ws = new WebSocket(wsUrl)
// Timeout handler in case connection hangs
const connectionTimeout = setTimeout(() => {
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
console.warn('WebSocket connection timeout, retrying...')
this.ws.close()
reject(new Error('Connection timeout'))
}
}, 3000) // 3 second timeout
this.ws.onopen = () => {
clearTimeout(connectionTimeout)
this.isConnecting = false
this.reconnectAttempts = 0
console.log('[WebSocket] Connected successfully')
resolve()
}
this.ws.onerror = (error) => {
clearTimeout(connectionTimeout)
this.isConnecting = false
console.error('[WebSocket] Connection error:', error)
// Don't reject immediately - let onclose handle reconnection
// This prevents errors from blocking reconnection
}
this.ws.onmessage = (event) => {
try {
const update: Update = JSON.parse(event.data)
this.callbacks.forEach((callback) => callback(update))
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
this.ws.onclose = (event) => {
clearTimeout(connectionTimeout)
this.isConnecting = false
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
// Clear the WebSocket reference
this.ws = null
// Don't reconnect if we explicitly disconnected
if (!this.shouldReconnect) {
console.log('[WebSocket] Reconnection disabled')
return
}
// Always try to reconnect unless we've exceeded max attempts
// Code 1001 (Going Away) happens on HMR reloads - reconnect IMMEDIATELY
if (this.reconnectAttempts < this.maxReconnectAttempts) {
// Immediate reconnection for HMR (code 1001) - no delay
const isHMR = event.code === 1001 || event.code === 1006
const delay = isHMR ? 0 : (this.reconnectAttempts === 0 ? 100 : Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code}, HMR: ${isHMR})`)
// 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
}
// Don't increment attempts for HMR disconnects - they're expected
if (!isHMR) {
this.reconnectAttempts++
}
console.log('[WebSocket] Attempting reconnection...')
this.connect().catch((err) => {
console.error('[WebSocket] Reconnection failed:', err)
// onclose will be called again and will retry
})
}
if (delay === 0) {
// Immediate reconnection for HMR
doReconnect()
} else {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
doReconnect()
}, delay)
}
} else {
console.warn('[WebSocket] Max reconnection attempts reached')
this.shouldReconnect = false
}
}
})
}
subscribe(callback: WebSocketCallback): () => void {
this.callbacks.add(callback)
return () => {
this.callbacks.delete(callback)
}
}
disconnect(): void {
this.shouldReconnect = false
this.reconnectAttempts = 0
this.isConnecting = false
// 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) {
// Ignore errors
}
this.ws = null
}
}
reset(): void {
this.disconnect()
this.callbacks.clear()
}
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
return new WebSocketClient()
}
// Check if we have a persisted instance from HMR
if ((window as any).__archipelago_ws_client && (window as any).__archipelago_ws_client.ws) {
const existing = (window as any).__archipelago_ws_client
// Check if the WebSocket is still valid
if (existing.ws && existing.ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] Using existing connected client from HMR')
return existing
}
}
// Create new instance
if (!wsClientInstance) {
wsClientInstance = new WebSocketClient()
(window as any).__archipelago_ws_client = wsClientInstance
console.log('[WebSocket] Created new client instance')
}
return wsClientInstance
}
export const wsClient = getWebSocketClient()
// 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) {
console.warn('Invalid or empty patch received, returning original data')
return data
}
try {
const result = applyPatch(data, patch as any, false, false)
return result.newDocument as T
} catch (error) {
console.error('Failed to apply patch:', error, 'Patch:', patch)
return data // Return original data on error
}
}