index.html hardcoded the deployment's domain in eight places, so every instance had to carry its own edit of the file — the only thing separating the rezio branch from master. The tags genuinely need absolute URLs. OpenGraph is fetched standalone by crawlers, and Bun's HTML bundler treats a root-relative href as an asset to resolve on disk, failing the build with "Could not resolve: /favicon.ico" — external URLs are the only form it passes through untouched. Bun's HTML import offers no substitution hook, so scripts/gen-index.ts swaps __PUBLIC_URL__ for the value in .env and writes index.gen.html, which the server imports. index.html is the tracked template and is now identical on every deployment; index.gen.html is gitignored. predev/prestart run the generator, and it is idempotent so --watch does not loop. Substituting also fixes the manifest: an absolute URL puts its fetch in CORS mode, which failed whenever the hardcoded domain was not the serving origin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import './servers/bootstrap';
|
|
import type { ServerWebSocket } from 'bun';
|
|
import { serve } from 'bun';
|
|
import { honoServer } from './servers/hono';
|
|
import { verify } from './servers/jwt';
|
|
import { isTokenBlacklisted } from 'officerdb';
|
|
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
|
import { chatWebsocket } from './servers/api/chat/websocket';
|
|
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
|
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
|
|
import { cliampWebsocket } from './servers/api/cliamp/websocket';
|
|
import { cliampAudioWebsocket } from './servers/api/cliamp/audio-ws';
|
|
import { desktopWebsocket } from './servers/api/desktop/websocket';
|
|
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
|
import officerWeb from './apps/officer-web/index.gen.html';
|
|
import { startBrowserRelay } from './servers/api/browser/relay';
|
|
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
|
|
import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report
|
|
import { broadcastEmailNew } from './servers/api/email/email';
|
|
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
|
import { toShellUsername } from './servers/data-path';
|
|
|
|
const { PORT = '5000' } = process.env;
|
|
|
|
// Build static file routes from public/
|
|
const publicRoutes: Record<string, (req: Request) => Response> = {};
|
|
for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) {
|
|
const bunFile = Bun.file(`./public/${file}`);
|
|
publicRoutes[`/${file}`] = () => new Response(bunFile);
|
|
}
|
|
|
|
type WSData = {
|
|
userId: number;
|
|
email: string;
|
|
username: string;
|
|
provider:
|
|
| 'terminal'
|
|
| 'chat'
|
|
| 'task-runner'
|
|
| 'pipeline'
|
|
| 'dev-server'
|
|
| 'cliamp'
|
|
| 'cliamp-audio'
|
|
| 'desktop'
|
|
| 'sidecar';
|
|
sessionId?: string;
|
|
cwd?: string;
|
|
command?: string;
|
|
cols?: number;
|
|
rows?: number;
|
|
files?: string;
|
|
devServerPort?: number;
|
|
devServerSlug?: string;
|
|
wsProxyPath?: string;
|
|
wsToken?: string;
|
|
};
|
|
|
|
// Sidecar registration WebSocket handler
|
|
const sidecarConnections = new Map<ServerWebSocket<WSData>, string>(); // ws → sidecar ID
|
|
|
|
const sidecarWebsocket = {
|
|
open(_ws: ServerWebSocket<WSData>) {
|
|
// Wait for registration message
|
|
},
|
|
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
|
try {
|
|
const data = typeof raw === 'string' ? raw : raw.toString();
|
|
const msg = JSON.parse(data);
|
|
|
|
if (msg.type === 'register') {
|
|
const id = registerSidecar(ws, msg as SidecarRegistration);
|
|
sidecarConnections.set(ws, id);
|
|
ws.send(JSON.stringify({ type: 'registered', id }));
|
|
return;
|
|
}
|
|
|
|
// Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
|
|
if (typeof msg.type === 'string' && msg.type.startsWith('queue:') && msg.id) {
|
|
handleSidecarQueueCommand(ws, msg);
|
|
return;
|
|
}
|
|
|
|
// Email IDLE watcher reporting new mail → push to that user's /email SSE clients.
|
|
if (msg.type === 'email:new' && typeof msg.userEmail === 'string') {
|
|
broadcastEmailNew(msg.userEmail);
|
|
return;
|
|
}
|
|
|
|
const id = sidecarConnections.get(ws);
|
|
if (id) {
|
|
handleSidecarMessage(id, msg);
|
|
}
|
|
} catch {
|
|
// skip malformed messages
|
|
}
|
|
},
|
|
close(ws: ServerWebSocket<WSData>) {
|
|
const id = sidecarConnections.get(ws);
|
|
if (id) {
|
|
unregisterSidecar(id);
|
|
sidecarConnections.delete(ws);
|
|
}
|
|
},
|
|
drain() {},
|
|
};
|
|
|
|
// Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
|
|
async function handleSidecarQueueCommand(ws: ServerWebSocket<WSData>, msg: Record<string, unknown>) {
|
|
const id = msg.id as string;
|
|
try {
|
|
switch (msg.type) {
|
|
case 'queue:enqueue': {
|
|
const job = await queueEnqueue(msg.params as import('./servers/queue/types').EnqueueParams);
|
|
ws.send(JSON.stringify({ type: 'queue:enqueued', id, job }));
|
|
break;
|
|
}
|
|
case 'queue:cancel': {
|
|
const job = await queueCancel(msg.jobId as string);
|
|
ws.send(JSON.stringify({ type: 'queue:cancelled', id, job }));
|
|
break;
|
|
}
|
|
case 'queue:list': {
|
|
const jobs = await queueList();
|
|
ws.send(JSON.stringify({ type: 'queue:list', id, jobs }));
|
|
break;
|
|
}
|
|
case 'queue:get': {
|
|
const job = await queueGet(msg.jobId as string);
|
|
ws.send(JSON.stringify({ type: 'queue:get', id, job }));
|
|
break;
|
|
}
|
|
default:
|
|
ws.send(JSON.stringify({ type: 'queue:error', id, error: `Unknown queue command: ${msg.type}` }));
|
|
}
|
|
} catch (err) {
|
|
ws.send(JSON.stringify({ type: 'queue:error', id, error: err instanceof Error ? err.message : String(err) }));
|
|
}
|
|
}
|
|
|
|
const handlers: Record<string, any> = {
|
|
terminal: terminalWebsocket,
|
|
chat: chatWebsocket,
|
|
'task-runner': taskRunnerWebsocket,
|
|
pipeline: pipelineWebsocket,
|
|
cliamp: cliampWebsocket,
|
|
'cliamp-audio': cliampAudioWebsocket,
|
|
desktop: desktopWebsocket,
|
|
sidecar: sidecarWebsocket,
|
|
};
|
|
|
|
// Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.)
|
|
type UpstreamState = { ws: WebSocket; queue: (string | Buffer)[]; ready: boolean };
|
|
|
|
// Bun hands WS frames over as `string | Buffer`, but the DOM WebSocket.send signature won't accept a
|
|
// Buffer<ArrayBufferLike> (it can't rule out a SharedArrayBuffer backing). A Buffer is a Uint8Array
|
|
// at runtime, so this forwards as-is rather than paying for a copy on every proxied frame.
|
|
const asWsPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
|
|
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
|
|
const devServerUpstreams = new Map<ServerWebSocket<WSData>, UpstreamState>();
|
|
|
|
const devServerWebsocket = {
|
|
async open(ws: ServerWebSocket<WSData>) {
|
|
const { devServerPort, wsProxyPath, wsToken } = ws.data;
|
|
|
|
// Validate JWT (deferred from upgrade which must be synchronous in Bun)
|
|
if (!wsToken) {
|
|
ws.close(4001, 'Unauthorized');
|
|
return;
|
|
}
|
|
try {
|
|
const payload = await verify(wsToken);
|
|
if (!payload) {
|
|
ws.close(4001, 'Unauthorized');
|
|
return;
|
|
}
|
|
if (payload.jti && (await isTokenBlacklisted(payload.jti))) {
|
|
ws.close(4001, 'Unauthorized');
|
|
return;
|
|
}
|
|
} catch {
|
|
ws.close(4001, 'Unauthorized');
|
|
return;
|
|
}
|
|
|
|
const upstream = new WebSocket(`ws://localhost:${devServerPort}${wsProxyPath}`);
|
|
const state: UpstreamState = { ws: upstream, queue: [], ready: false };
|
|
devServerUpstreams.set(ws, state);
|
|
|
|
upstream.addEventListener('open', () => {
|
|
state.ready = true;
|
|
for (const msg of state.queue) upstream.send(asWsPayload(msg));
|
|
state.queue.length = 0;
|
|
});
|
|
|
|
upstream.addEventListener('message', (event) => {
|
|
ws.send(event.data as string | ArrayBuffer);
|
|
});
|
|
|
|
upstream.addEventListener('close', () => {
|
|
devServerUpstreams.delete(ws);
|
|
ws.close();
|
|
});
|
|
|
|
upstream.addEventListener('error', () => {
|
|
devServerUpstreams.delete(ws);
|
|
ws.close();
|
|
});
|
|
},
|
|
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
|
const state = devServerUpstreams.get(ws);
|
|
if (!state) return;
|
|
if (state.ready) {
|
|
state.ws.send(asWsPayload(raw));
|
|
} else {
|
|
state.queue.push(raw);
|
|
}
|
|
},
|
|
close(ws: ServerWebSocket<WSData>) {
|
|
const state = devServerUpstreams.get(ws);
|
|
if (state) {
|
|
state.ws.close();
|
|
devServerUpstreams.delete(ws);
|
|
}
|
|
},
|
|
};
|
|
handlers['dev-server'] = devServerWebsocket;
|
|
|
|
async function upgradeWs(
|
|
req: Request,
|
|
server: any,
|
|
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop',
|
|
) {
|
|
const token = new URL(req.url).searchParams.get('token');
|
|
if (!token) return new Response('Unauthorized', { status: 401 });
|
|
|
|
try {
|
|
const user = await verify(token);
|
|
if (!user) return new Response('Unauthorized', { status: 401 });
|
|
|
|
if (user.jti) {
|
|
if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
|
|
}
|
|
|
|
const url = new URL(req.url);
|
|
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
|
const cwd = url.searchParams.get('cwd') ?? undefined;
|
|
const command = url.searchParams.get('command') ?? undefined;
|
|
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
|
|
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
|
|
const files = url.searchParams.get('files') ?? undefined;
|
|
const ok = server.upgrade(req, {
|
|
data: {
|
|
userId: user.id,
|
|
email: user.email,
|
|
username: toShellUsername(user.username ?? '', user.email),
|
|
provider,
|
|
sessionId,
|
|
cwd,
|
|
command,
|
|
cols,
|
|
rows,
|
|
files,
|
|
},
|
|
});
|
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
|
} catch {
|
|
return new Response('Unauthorized', { status: 401 });
|
|
}
|
|
}
|
|
|
|
function upgradeDevServerWs(req: Request, server: any) {
|
|
const url = new URL(req.url);
|
|
const match = url.pathname.match(/^\/api\/dev-server-proxy\/([^/]+)(\/.*)?$/);
|
|
if (!match) return new Response('Not found', { status: 404 });
|
|
|
|
const proxyId = match[1]!;
|
|
const entry = findEntryByProxyId(proxyId);
|
|
if (!entry) return new Response('No dev server running', { status: 404 });
|
|
|
|
const wsToken = url.searchParams.get('token');
|
|
if (!wsToken) return new Response('Unauthorized', { status: 401 });
|
|
|
|
touchEntry(entry);
|
|
|
|
const wsProxyPath = match[2] || '/';
|
|
const ok = server.upgrade(req, {
|
|
data: {
|
|
userId: 0,
|
|
email: '',
|
|
provider: 'dev-server' as const,
|
|
devServerPort: entry.port,
|
|
devServerSlug: proxyId,
|
|
wsProxyPath,
|
|
wsToken,
|
|
},
|
|
});
|
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
|
}
|
|
|
|
const server = serve({
|
|
port: Number(PORT),
|
|
idleTimeout: 60,
|
|
maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB
|
|
routes: {
|
|
...publicRoutes,
|
|
'/novnc/*': (req) => {
|
|
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
|
return new Response(file);
|
|
},
|
|
'/vendor/*': (req) => {
|
|
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
|
return new Response(file);
|
|
},
|
|
'/api/dev-server-proxy/*': (req, server) => {
|
|
if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server);
|
|
return honoServer.fetch(req, server);
|
|
},
|
|
'/api/sidecar/register': (req: Request, server: any) => {
|
|
const ok = server.upgrade(req, {
|
|
data: { provider: 'sidecar', userId: 0, email: '', username: '' },
|
|
});
|
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
|
},
|
|
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
|
|
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
|
|
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
|
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
|
|
'/api/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'),
|
|
'/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'),
|
|
'/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'),
|
|
'/': officerWeb,
|
|
'/*': officerWeb,
|
|
'/api': honoServer.fetch,
|
|
'/api/*': honoServer.fetch,
|
|
},
|
|
|
|
websocket: {
|
|
open(ws) {
|
|
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
|
|
handlers[provider]!.open(ws as any);
|
|
},
|
|
message(ws, raw) {
|
|
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
|
|
handlers[provider]!.message(ws as any, raw);
|
|
},
|
|
close(ws) {
|
|
const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
|
|
handlers[provider]!.close(ws as any);
|
|
},
|
|
drain() {},
|
|
},
|
|
|
|
development: process.env.NODE_ENV !== 'production' && {
|
|
hmr: true,
|
|
},
|
|
});
|
|
|
|
console.log(`🚀 Server running at ${server.url}`);
|
|
|
|
const BROWSER_RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? '18792');
|
|
try {
|
|
await startBrowserRelay(BROWSER_RELAY_PORT);
|
|
console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`);
|
|
} catch (err) {
|
|
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
|
|
}
|
|
|
|
// Initialize queue engine in API server process
|
|
import {
|
|
initQueue,
|
|
enqueueJob as queueEnqueue,
|
|
cancelJob as queueCancel,
|
|
listAllJobs as queueList,
|
|
readJob as queueGet,
|
|
} from './servers/queue/init';
|
|
initQueue().catch((err) => console.error('[queue] failed to initialize:', err));
|
|
|
|
// Mark any orphaned pipeline jobs from previous server run
|
|
import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager';
|
|
cleanupOnStartup().catch((err) => console.error('[pipeline-jobs] startup cleanup failed:', err));
|
|
|
|
// Ensure PulseAudio is running with virtual sink for cliamp audio streaming
|
|
(async () => {
|
|
const pulseaudio = Bun.which('pulseaudio');
|
|
const pactl = Bun.which('pactl');
|
|
if (!pulseaudio || !pactl) {
|
|
console.log('[cliamp] pulseaudio not installed, skipping audio setup');
|
|
return;
|
|
}
|
|
|
|
// Start PulseAudio daemon if not running
|
|
const check = Bun.spawnSync({ cmd: [pulseaudio, '--check'], stdout: 'ignore', stderr: 'ignore' });
|
|
if (check.exitCode !== 0) {
|
|
const start = Bun.spawnSync({ cmd: [pulseaudio, '--start', '-D'], stdout: 'ignore', stderr: 'ignore' });
|
|
if (start.exitCode !== 0) {
|
|
console.error('[cliamp] failed to start pulseaudio');
|
|
return;
|
|
}
|
|
console.log('[cliamp] pulseaudio started');
|
|
} else {
|
|
console.log('[cliamp] pulseaudio already running');
|
|
}
|
|
|
|
// Load null sink if not already loaded
|
|
const sinks = Bun.spawnSync({ cmd: [pactl, 'list', 'short', 'sinks'], stdout: 'pipe', stderr: 'ignore' });
|
|
const sinkList = sinks.stdout.toString();
|
|
if (!sinkList.includes('virtual_out')) {
|
|
const load = Bun.spawnSync({
|
|
cmd: [
|
|
pactl,
|
|
'load-module',
|
|
'module-null-sink',
|
|
'sink_name=virtual_out',
|
|
'sink_properties=device.description=Virtual_Output',
|
|
],
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
if (load.exitCode !== 0) {
|
|
console.error('[cliamp] failed to load null sink:', load.stderr.toString().trim());
|
|
} else {
|
|
console.log('[cliamp] virtual_out null sink loaded');
|
|
}
|
|
} else {
|
|
console.log('[cliamp] virtual_out sink already exists');
|
|
}
|
|
})();
|
|
|
|
// Pi check/install is handled by bootstrap.ts (imported above)
|