| import { timingSafeEqual } from 'node:crypto'; |
| import { readFile, stat } from 'node:fs/promises'; |
| import { join, resolve } from 'node:path'; |
|
|
| import { Hono } from 'hono'; |
|
|
| import { KIMI_CODE_HOME } from './config'; |
| import { serveWebAsset, type WebAsset } from './lib/web-asset'; |
| import { blobsRoute } from './routes/blobs'; |
| import { contextRoute } from './routes/context'; |
| import { cronRoute } from './routes/cron'; |
| import { importsRoute } from './routes/imports'; |
| import { logsRoute } from './routes/logs'; |
| import { sessionDetailRoute } from './routes/session-detail'; |
| import { sessionsRoute } from './routes/sessions'; |
| import { subagentsRoute } from './routes/subagents'; |
| import { tasksRoute } from './routes/tasks'; |
| import { wireRoute } from './routes/wire'; |
|
|
| |
| |
| async function resolvePublicDir(): Promise<string | null> { |
| try { |
| const here = import.meta.dirname; |
| const candidate = resolve(here, 'public'); |
| const s = await stat(candidate); |
| if (s.isDirectory()) return candidate; |
| } catch { |
| |
| } |
| return null; |
| } |
|
|
| const STATIC_EXT_MIME: Record<string, string> = { |
| '.html': 'text/html; charset=utf-8', |
| '.js': 'application/javascript; charset=utf-8', |
| '.mjs': 'application/javascript; charset=utf-8', |
| '.css': 'text/css; charset=utf-8', |
| '.json': 'application/json; charset=utf-8', |
| '.svg': 'image/svg+xml', |
| '.png': 'image/png', |
| '.jpg': 'image/jpeg', |
| '.jpeg': 'image/jpeg', |
| '.gif': 'image/gif', |
| '.ico': 'image/x-icon', |
| '.woff': 'font/woff', |
| '.woff2': 'font/woff2', |
| '.ttf': 'font/ttf', |
| '.map': 'application/json; charset=utf-8', |
| }; |
|
|
| function mimeFor(path: string): string { |
| const i = path.lastIndexOf('.'); |
| if (i < 0) return 'application/octet-stream'; |
| const ext = path.slice(i).toLowerCase(); |
| return STATIC_EXT_MIME[ext] ?? 'application/octet-stream'; |
| } |
|
|
| export interface CreateAppOptions { |
| readonly authToken?: string; |
| readonly homeDir?: string; |
| |
| |
| readonly webAsset?: WebAsset; |
| } |
|
|
| function bearerToken(value: string | undefined): string | null { |
| if (value === undefined) return null; |
| const match = /^Bearer\s+(.+)$/i.exec(value); |
| return match?.[1]?.trim() ?? null; |
| } |
|
|
| function tokenMatches(actual: string, expected: string): boolean { |
| const actualBytes = Buffer.from(actual); |
| const expectedBytes = Buffer.from(expected); |
| return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes); |
| } |
|
|
| |
| export async function createApp(options: CreateAppOptions = {}): Promise<Hono> { |
| const app = new Hono(); |
|
|
| |
| const api = new Hono(); |
| const authToken = options.authToken; |
| const home = options.homeDir ?? KIMI_CODE_HOME; |
| if (authToken !== undefined && authToken.length > 0) { |
| api.use('*', async (c, next) => { |
| const token = bearerToken(c.req.header('authorization')); |
| if (token !== null && tokenMatches(token, authToken)) { |
| await next(); |
| return; |
| } |
| c.header('www-authenticate', 'Bearer realm="kimi-vis"'); |
| return c.json({ error: 'unauthorized', code: 'UNAUTHORIZED' }, 401); |
| }); |
| } |
| api.route('/sessions', sessionsRoute(home)); |
| api.route('/sessions', sessionDetailRoute(home)); |
| api.route('/sessions', wireRoute(home)); |
| api.route('/sessions', subagentsRoute(home)); |
| api.route('/sessions', blobsRoute(home)); |
| api.route('/sessions', tasksRoute(home)); |
| api.route('/sessions', cronRoute(home)); |
| api.route('/sessions', logsRoute(home)); |
| api.route('/imports', importsRoute(home)); |
| |
| |
| |
| api.route('/sessions', contextRoute(home)); |
|
|
| app.route('/api', api); |
|
|
| |
| if (options.webAsset !== undefined) { |
| |
| const asset = options.webAsset; |
| app.get('*', (c) => { |
| const pathname = new URL(c.req.url).pathname; |
| if (pathname.startsWith('/api')) { |
| |
| return c.json({ error: `api route not found: ${pathname}`, code: 'NOT_FOUND' }, 404); |
| } |
| return serveWebAsset(asset); |
| }); |
| } else { |
| |
| const publicDir = await resolvePublicDir(); |
| if (publicDir !== null) { |
| app.get('*', async (c) => { |
| const url = new URL(c.req.url); |
| let pathname = decodeURIComponent(url.pathname); |
| if (pathname.startsWith('/api')) { |
| |
| return c.json({ error: `api route not found: ${pathname}`, code: 'NOT_FOUND' }, 404); |
| } |
| if (pathname === '/' || pathname === '') pathname = '/index.html'; |
| const resolved = resolve(publicDir, `.${pathname}`); |
| if (!resolved.startsWith(publicDir)) { |
| return c.text('forbidden', 403); |
| } |
| try { |
| const s = await stat(resolved); |
| if (s.isFile()) { |
| const buf = await readFile(resolved); |
| const body = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); |
| return new Response(body, { |
| headers: { 'content-type': mimeFor(resolved) }, |
| }); |
| } |
| } catch { |
| |
| } |
| |
| |
| try { |
| const indexHtml = await readFile(join(publicDir, 'index.html')); |
| const body = new Uint8Array(indexHtml.buffer, indexHtml.byteOffset, indexHtml.byteLength); |
| return new Response(body, { |
| headers: { 'content-type': 'text/html; charset=utf-8' }, |
| }); |
| } catch { |
| return c.text('not found', 404); |
| } |
| }); |
| } |
| } |
|
|
| return app; |
| } |
|
|