@gricha/perry 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ import { parseClaudeSessionContent } from '../sessions/parser';
12
12
  import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, deleteSession as deleteSessionFromProvider, searchSessions as searchSessionsInContainer, } from '../sessions/agents';
13
13
  import { discoverClaudeCodeModels, discoverHostOpencodeModels, discoverContainerOpencodeModels, } from '../models/discovery';
14
14
  import { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession, } from '../sessions/agents/opencode-storage';
15
+ import { sessionManager } from '../session-manager';
15
16
  const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']);
16
17
  const WorkspacePortsSchema = z.object({
17
18
  ssh: z.number(),
@@ -71,6 +72,9 @@ const SSHKeyInfoSchema = z.object({
71
72
  fingerprint: z.string(),
72
73
  hasPrivateKey: z.boolean(),
73
74
  });
75
+ const TerminalSettingsSchema = z.object({
76
+ preferredShell: z.string().optional(),
77
+ });
74
78
  function mapErrorToORPC(err, defaultMessage) {
75
79
  const message = err instanceof Error ? err.message : defaultMessage;
76
80
  if (message.includes('not found')) {
@@ -314,6 +318,28 @@ export function createRouter(ctx) {
314
318
  const listSSHKeys = os.output(z.array(SSHKeyInfoSchema)).handler(async () => {
315
319
  return discoverSSHKeys();
316
320
  });
321
+ const getTerminalSettings = os
322
+ .output(z.object({
323
+ preferredShell: z.string().optional(),
324
+ detectedShell: z.string().optional(),
325
+ }))
326
+ .handler(async () => {
327
+ const config = ctx.config.get();
328
+ return {
329
+ preferredShell: config.terminal?.preferredShell,
330
+ detectedShell: process.env.SHELL,
331
+ };
332
+ });
333
+ const updateTerminalSettings = os
334
+ .input(TerminalSettingsSchema)
335
+ .output(TerminalSettingsSchema)
336
+ .handler(async ({ input }) => {
337
+ const currentConfig = ctx.config.get();
338
+ const newConfig = { ...currentConfig, terminal: input };
339
+ ctx.config.set(newConfig);
340
+ await saveAgentConfig(newConfig, ctx.configDir);
341
+ return input;
342
+ });
317
343
  const GitHubRepoSchema = z.object({
318
344
  name: z.string(),
319
345
  fullName: z.string(),
@@ -940,6 +966,122 @@ export function createRouter(ctx) {
940
966
  }
941
967
  return { models };
942
968
  });
969
+ const LiveAgentTypeSchema = z.enum(['claude', 'opencode', 'codex']);
970
+ const listLiveSessions = os
971
+ .input(z.object({
972
+ workspaceName: z.string().optional(),
973
+ }))
974
+ .handler(async ({ input }) => {
975
+ const sessions = sessionManager.listActiveSessions(input.workspaceName);
976
+ return sessions.map((s) => ({
977
+ ...s,
978
+ startedAt: s.startedAt.toISOString(),
979
+ lastActivity: s.lastActivity.toISOString(),
980
+ }));
981
+ });
982
+ const getLiveSession = os
983
+ .input(z.object({
984
+ sessionId: z.string(),
985
+ }))
986
+ .handler(async ({ input }) => {
987
+ const session = sessionManager.getSession(input.sessionId);
988
+ if (!session) {
989
+ throw new ORPCError('NOT_FOUND', { message: 'Live session not found' });
990
+ }
991
+ return {
992
+ ...session,
993
+ startedAt: session.startedAt.toISOString(),
994
+ lastActivity: session.lastActivity.toISOString(),
995
+ };
996
+ });
997
+ const getLiveSessionStatus = os
998
+ .input(z.object({
999
+ sessionId: z.string(),
1000
+ }))
1001
+ .handler(async ({ input }) => {
1002
+ const status = sessionManager.getSessionStatus(input.sessionId);
1003
+ if (!status) {
1004
+ throw new ORPCError('NOT_FOUND', { message: 'Live session not found' });
1005
+ }
1006
+ return { status };
1007
+ });
1008
+ const startLiveSession = os
1009
+ .input(z.object({
1010
+ workspaceName: z.string(),
1011
+ agentType: LiveAgentTypeSchema,
1012
+ sessionId: z.string().optional(),
1013
+ agentSessionId: z.string().optional(),
1014
+ model: z.string().optional(),
1015
+ projectPath: z.string().optional(),
1016
+ }))
1017
+ .handler(async ({ input }) => {
1018
+ if (input.workspaceName !== HOST_WORKSPACE_NAME) {
1019
+ const workspace = await ctx.workspaces.get(input.workspaceName);
1020
+ if (!workspace) {
1021
+ throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
1022
+ }
1023
+ if (workspace.status !== 'running') {
1024
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
1025
+ }
1026
+ }
1027
+ else {
1028
+ const config = ctx.config.get();
1029
+ if (!config.allowHostAccess) {
1030
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
1031
+ }
1032
+ }
1033
+ const sessionId = await sessionManager.startSession({
1034
+ workspaceName: input.workspaceName,
1035
+ agentType: input.agentType,
1036
+ sessionId: input.sessionId,
1037
+ agentSessionId: input.agentSessionId,
1038
+ model: input.model,
1039
+ projectPath: input.projectPath,
1040
+ });
1041
+ return { sessionId };
1042
+ });
1043
+ const sendLiveMessage = os
1044
+ .input(z.object({
1045
+ sessionId: z.string(),
1046
+ message: z.string(),
1047
+ }))
1048
+ .handler(async ({ input }) => {
1049
+ const session = sessionManager.getSession(input.sessionId);
1050
+ if (!session) {
1051
+ throw new ORPCError('NOT_FOUND', { message: 'Live session not found' });
1052
+ }
1053
+ await sessionManager.sendMessage(input.sessionId, input.message);
1054
+ return { success: true };
1055
+ });
1056
+ const interruptLiveSession = os
1057
+ .input(z.object({
1058
+ sessionId: z.string(),
1059
+ }))
1060
+ .handler(async ({ input }) => {
1061
+ const session = sessionManager.getSession(input.sessionId);
1062
+ if (!session) {
1063
+ throw new ORPCError('NOT_FOUND', { message: 'Live session not found' });
1064
+ }
1065
+ await sessionManager.interrupt(input.sessionId);
1066
+ return { success: true };
1067
+ });
1068
+ const disposeLiveSession = os
1069
+ .input(z.object({
1070
+ sessionId: z.string(),
1071
+ }))
1072
+ .handler(async ({ input }) => {
1073
+ await sessionManager.disposeSession(input.sessionId);
1074
+ return { success: true };
1075
+ });
1076
+ const getLiveSessionMessages = os
1077
+ .input(z.object({
1078
+ sessionId: z.string(),
1079
+ sinceId: z.number().optional(),
1080
+ }))
1081
+ .handler(async ({ input }) => {
1082
+ const messages = sessionManager.getBufferedMessages(input.sessionId, input.sinceId);
1083
+ return { messages };
1084
+ });
943
1085
  return {
944
1086
  workspaces: {
945
1087
  list: listWorkspaces,
@@ -966,6 +1108,16 @@ export function createRouter(ctx) {
966
1108
  delete: deleteSession,
967
1109
  search: searchSessions,
968
1110
  },
1111
+ live: {
1112
+ list: listLiveSessions,
1113
+ get: getLiveSession,
1114
+ getStatus: getLiveSessionStatus,
1115
+ start: startLiveSession,
1116
+ sendMessage: sendLiveMessage,
1117
+ interrupt: interruptLiveSession,
1118
+ dispose: disposeLiveSession,
1119
+ getMessages: getLiveSessionMessages,
1120
+ },
969
1121
  models: {
970
1122
  list: listModels,
971
1123
  },
@@ -994,6 +1146,10 @@ export function createRouter(ctx) {
994
1146
  update: updateSSHSettings,
995
1147
  listKeys: listSSHKeys,
996
1148
  },
1149
+ terminal: {
1150
+ get: getTerminalSettings,
1151
+ update: updateTerminalSettings,
1152
+ },
997
1153
  },
998
1154
  };
999
1155
  }
package/dist/agent/run.js CHANGED
@@ -1,27 +1,21 @@
1
- import { createServer } from 'http';
2
- import { RPCHandler } from '@orpc/server/node';
1
+ import { RPCHandler } from '@orpc/server/fetch';
3
2
  import { loadAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
4
3
  import { HOST_WORKSPACE_NAME } from '../shared/client-types';
5
4
  import { DEFAULT_AGENT_PORT } from '../shared/constants';
6
5
  import { WorkspaceManager } from '../workspace/manager';
7
6
  import { containerRunning, getContainerName } from '../docker';
8
7
  import { startEagerImagePull, stopEagerImagePull } from '../docker/eager-pull';
9
- import { TerminalWebSocketServer } from '../terminal/websocket';
10
- import { ChatWebSocketServer } from '../chat/websocket';
11
- import { OpencodeWebSocketServer } from '../chat/opencode-websocket';
8
+ import { TerminalHandler } from '../terminal/bun-handler';
9
+ import { LiveChatHandler } from '../session-manager/bun-handler';
12
10
  import { createRouter } from './router';
13
- import { serveStatic } from './static';
11
+ import { serveStaticBun } from './static';
14
12
  import { SessionsCacheManager } from '../sessions/cache';
15
13
  import { ModelCacheManager } from '../models/cache';
16
14
  import { FileWatcher } from './file-watcher';
17
15
  import { getTailscaleStatus, getTailscaleIdentity, startTailscaleServe, stopTailscaleServe, } from '../tailscale';
18
16
  import pkg from '../../package.json';
19
17
  const startTime = Date.now();
20
- function sendJson(res, status, data) {
21
- res.writeHead(status, { 'Content-Type': 'application/json' });
22
- res.end(JSON.stringify(data));
23
- }
24
- function createAgentServer(configDir, config, tailscale) {
18
+ function createAgentServer(configDir, config, port, tailscale) {
25
19
  let currentConfig = config;
26
20
  const workspaces = new WorkspaceManager(configDir, currentConfig);
27
21
  const sessionsCache = new SessionsCacheManager(configDir);
@@ -49,20 +43,24 @@ function createAgentServer(configDir, config, tailscale) {
49
43
  }
50
44
  return containerRunning(getContainerName(name));
51
45
  };
52
- const terminalServer = new TerminalWebSocketServer({
46
+ const getPreferredShell = () => {
47
+ return currentConfig.terminal?.preferredShell || process.env.SHELL;
48
+ };
49
+ const terminalHandler = new TerminalHandler({
53
50
  getContainerName,
54
51
  isWorkspaceRunning,
55
52
  isHostAccessAllowed: () => currentConfig.allowHostAccess === true,
53
+ getPreferredShell,
56
54
  });
57
- const chatServer = new ChatWebSocketServer({
55
+ const liveClaudeHandler = new LiveChatHandler({
58
56
  isWorkspaceRunning,
59
- getConfig: () => currentConfig,
60
57
  isHostAccessAllowed: () => currentConfig.allowHostAccess === true,
58
+ agentType: 'claude',
61
59
  });
62
- const opencodeServer = new OpencodeWebSocketServer({
60
+ const liveOpencodeHandler = new LiveChatHandler({
63
61
  isWorkspaceRunning,
64
62
  isHostAccessAllowed: () => currentConfig.allowHostAccess === true,
65
- getConfig: () => currentConfig,
63
+ agentType: 'opencode',
66
64
  });
67
65
  const triggerAutoSync = () => {
68
66
  syncAllRunning().catch((err) => {
@@ -82,75 +80,133 @@ function createAgentServer(configDir, config, tailscale) {
82
80
  configDir,
83
81
  stateDir: configDir,
84
82
  startTime,
85
- terminalServer,
83
+ terminalServer: terminalHandler,
86
84
  sessionsCache,
87
85
  modelCache,
88
86
  tailscale,
89
87
  triggerAutoSync,
90
88
  });
91
89
  const rpcHandler = new RPCHandler(router);
92
- const server = createServer(async (req, res) => {
93
- const url = new URL(req.url || '/', 'http://localhost');
94
- const method = req.method;
95
- const pathname = url.pathname;
96
- const identity = getTailscaleIdentity(req);
97
- res.setHeader('Access-Control-Allow-Origin', '*');
98
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
99
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
100
- if (method === 'OPTIONS') {
101
- res.writeHead(204);
102
- res.end();
103
- return;
104
- }
105
- try {
90
+ const server = Bun.serve({
91
+ port,
92
+ hostname: '::',
93
+ async fetch(req, server) {
94
+ const url = new URL(req.url);
95
+ const pathname = url.pathname;
96
+ const method = req.method;
97
+ const corsHeaders = {
98
+ 'Access-Control-Allow-Origin': '*',
99
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
100
+ 'Access-Control-Allow-Headers': 'Content-Type',
101
+ };
102
+ if (method === 'OPTIONS') {
103
+ return new Response(null, { status: 204, headers: corsHeaders });
104
+ }
105
+ const terminalMatch = pathname.match(/^\/rpc\/terminal\/([^/]+)$/);
106
+ const liveClaudeMatch = pathname.match(/^\/rpc\/live\/claude\/([^/]+)$/);
107
+ const liveOpencodeMatch = pathname.match(/^\/rpc\/live\/opencode\/([^/]+)$/);
108
+ if (terminalMatch || liveClaudeMatch || liveOpencodeMatch) {
109
+ let type;
110
+ let workspaceName;
111
+ if (terminalMatch) {
112
+ type = 'terminal';
113
+ workspaceName = decodeURIComponent(terminalMatch[1]);
114
+ }
115
+ else if (liveClaudeMatch) {
116
+ type = 'live-claude';
117
+ workspaceName = decodeURIComponent(liveClaudeMatch[1]);
118
+ }
119
+ else {
120
+ type = 'live-opencode';
121
+ workspaceName = decodeURIComponent(liveOpencodeMatch[1]);
122
+ }
123
+ const running = await isWorkspaceRunning(workspaceName);
124
+ if (!running) {
125
+ return new Response('Not Found', { status: 404 });
126
+ }
127
+ const upgraded = server.upgrade(req, {
128
+ data: { type, workspaceName },
129
+ });
130
+ if (upgraded) {
131
+ return undefined;
132
+ }
133
+ return new Response('WebSocket upgrade failed', { status: 400 });
134
+ }
106
135
  if (pathname === '/health' && method === 'GET') {
136
+ const identity = getTailscaleIdentity(req);
107
137
  const response = { status: 'ok', version: pkg.version };
108
138
  if (identity) {
109
139
  response.user = identity.email;
110
140
  }
111
- sendJson(res, 200, response);
112
- return;
141
+ return Response.json(response, { headers: corsHeaders });
113
142
  }
114
143
  if (pathname.startsWith('/rpc')) {
115
- const { matched } = await rpcHandler.handle(req, res, {
144
+ const { matched, response } = await rpcHandler.handle(req, {
116
145
  prefix: '/rpc',
117
146
  });
118
- if (matched)
119
- return;
147
+ if (matched && response) {
148
+ const newHeaders = new Headers(response.headers);
149
+ Object.entries(corsHeaders).forEach(([k, v]) => newHeaders.set(k, v));
150
+ return new Response(response.body, {
151
+ status: response.status,
152
+ statusText: response.statusText,
153
+ headers: newHeaders,
154
+ });
155
+ }
120
156
  }
121
- const served = await serveStatic(req, res, pathname);
122
- if (served)
123
- return;
124
- sendJson(res, 404, { error: 'Not found' });
125
- }
126
- catch (err) {
127
- console.error('Request error:', err);
128
- sendJson(res, 500, { error: 'Internal server error' });
129
- }
130
- });
131
- server.on('upgrade', async (request, socket, head) => {
132
- const url = new URL(request.url || '/', 'http://localhost');
133
- const terminalMatch = url.pathname.match(/^\/rpc\/terminal\/([^/]+)$/);
134
- const chatMatch = url.pathname.match(/^\/rpc\/chat\/([^/]+)$/);
135
- const opencodeMatch = url.pathname.match(/^\/rpc\/opencode\/([^/]+)$/);
136
- if (terminalMatch) {
137
- const workspaceName = decodeURIComponent(terminalMatch[1]);
138
- await terminalServer.handleUpgrade(request, socket, head, workspaceName);
139
- }
140
- else if (chatMatch) {
141
- const workspaceName = decodeURIComponent(chatMatch[1]);
142
- await chatServer.handleUpgrade(request, socket, head, workspaceName);
143
- }
144
- else if (opencodeMatch) {
145
- const workspaceName = decodeURIComponent(opencodeMatch[1]);
146
- await opencodeServer.handleUpgrade(request, socket, head, workspaceName);
147
- }
148
- else {
149
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
150
- socket.destroy();
151
- }
157
+ const staticResponse = await serveStaticBun(pathname);
158
+ if (staticResponse) {
159
+ return staticResponse;
160
+ }
161
+ return Response.json({ error: 'Not found' }, { status: 404, headers: corsHeaders });
162
+ },
163
+ websocket: {
164
+ open(ws) {
165
+ const { type, workspaceName } = ws.data;
166
+ if (type === 'terminal') {
167
+ terminalHandler.handleOpen(ws, workspaceName);
168
+ }
169
+ else if (type === 'live-claude') {
170
+ liveClaudeHandler.handleOpen(ws, workspaceName);
171
+ }
172
+ else if (type === 'live-opencode') {
173
+ liveOpencodeHandler.handleOpen(ws, workspaceName);
174
+ }
175
+ },
176
+ message(ws, message) {
177
+ const { type } = ws.data;
178
+ const data = typeof message === 'string' ? message : message.toString();
179
+ if (type === 'terminal') {
180
+ terminalHandler.handleMessage(ws, data);
181
+ }
182
+ else if (type === 'live-claude') {
183
+ liveClaudeHandler.handleMessage(ws, data);
184
+ }
185
+ else if (type === 'live-opencode') {
186
+ liveOpencodeHandler.handleMessage(ws, data);
187
+ }
188
+ },
189
+ close(ws, code, reason) {
190
+ const { type } = ws.data;
191
+ if (type === 'terminal') {
192
+ terminalHandler.handleClose(ws, code, reason);
193
+ }
194
+ else if (type === 'live-claude') {
195
+ liveClaudeHandler.handleClose(ws, code, reason);
196
+ }
197
+ else if (type === 'live-opencode') {
198
+ liveOpencodeHandler.handleClose(ws, code, reason);
199
+ }
200
+ },
201
+ },
152
202
  });
153
- return { server, terminalServer, chatServer, opencodeServer, fileWatcher };
203
+ return {
204
+ server,
205
+ terminalHandler,
206
+ liveClaudeHandler,
207
+ liveOpencodeHandler,
208
+ fileWatcher,
209
+ };
154
210
  }
155
211
  async function getProcessUsingPort(port) {
156
212
  try {
@@ -214,9 +270,22 @@ export async function startAgent(options = {}) {
214
270
  httpsUrl: tailscaleServeActive ? `https://${tailscale.dnsName}` : undefined,
215
271
  }
216
272
  : undefined;
217
- const { server, terminalServer, chatServer, opencodeServer, fileWatcher } = createAgentServer(configDir, config, tailscaleInfo);
218
- server.on('error', async (err) => {
219
- if (err.code === 'EADDRINUSE') {
273
+ let server;
274
+ let fileWatcher;
275
+ let terminalHandler;
276
+ let liveClaudeHandler;
277
+ let liveOpencodeHandler;
278
+ try {
279
+ const result = createAgentServer(configDir, config, port, tailscaleInfo);
280
+ server = result.server;
281
+ fileWatcher = result.fileWatcher;
282
+ terminalHandler = result.terminalHandler;
283
+ liveClaudeHandler = result.liveClaudeHandler;
284
+ liveOpencodeHandler = result.liveOpencodeHandler;
285
+ }
286
+ catch (err) {
287
+ const error = err;
288
+ if (error.code === 'EADDRINUSE') {
220
289
  console.error(`[agent] Error: Port ${port} is already in use.`);
221
290
  const processInfo = await getProcessUsingPort(port);
222
291
  if (processInfo) {
@@ -225,26 +294,21 @@ export async function startAgent(options = {}) {
225
294
  console.error(`[agent] Try using a different port with: perry agent run --port <port>`);
226
295
  process.exit(1);
227
296
  }
228
- else {
229
- console.error(`[agent] Server error: ${err.message}`);
230
- process.exit(1);
231
- }
232
- });
233
- server.listen(port, '::', () => {
234
- console.log(`[agent] Agent running at http://localhost:${port}`);
235
- if (tailscale.running && tailscale.dnsName) {
236
- const shortName = tailscale.dnsName.split('.')[0];
237
- console.log(`[agent] Tailnet: http://${shortName}:${port}`);
238
- if (tailscaleServeActive) {
239
- console.log(`[agent] Tailnet HTTPS: https://${tailscale.dnsName}`);
240
- }
297
+ throw err;
298
+ }
299
+ console.log(`[agent] Agent running at http://localhost:${port}`);
300
+ if (tailscale.running && tailscale.dnsName) {
301
+ const shortName = tailscale.dnsName.split('.')[0];
302
+ console.log(`[agent] Tailnet: http://${shortName}:${port}`);
303
+ if (tailscaleServeActive) {
304
+ console.log(`[agent] Tailnet HTTPS: https://${tailscale.dnsName}`);
241
305
  }
242
- console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
243
- console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
244
- console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/chat/:name`);
245
- console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/opencode/:name`);
246
- startEagerImagePull();
247
- });
306
+ }
307
+ console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
308
+ console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
309
+ console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/live/claude/:name`);
310
+ console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/live/opencode/:name`);
311
+ startEagerImagePull();
248
312
  let isShuttingDown = false;
249
313
  const shutdown = async () => {
250
314
  if (isShuttingDown) {
@@ -264,15 +328,13 @@ export async function startAgent(options = {}) {
264
328
  console.log('[agent] Stopping Tailscale Serve...');
265
329
  await stopTailscaleServe();
266
330
  }
267
- chatServer.close();
268
- opencodeServer.close();
269
- terminalServer.close();
270
- server.closeAllConnections();
271
- server.close(() => {
272
- clearTimeout(forceExitTimeout);
273
- console.log('[agent] Server closed');
274
- process.exit(0);
275
- });
331
+ liveClaudeHandler.close();
332
+ liveOpencodeHandler.close();
333
+ terminalHandler.close();
334
+ server.stop();
335
+ clearTimeout(forceExitTimeout);
336
+ console.log('[agent] Server closed');
337
+ process.exit(0);
276
338
  };
277
339
  process.on('SIGTERM', shutdown);
278
340
  process.on('SIGINT', shutdown);
@@ -65,3 +65,35 @@ export async function serveStatic(_req, res, pathname) {
65
65
  res.end(content);
66
66
  return true;
67
67
  }
68
+ export async function serveStaticBun(pathname) {
69
+ const webDir = getWebDir();
70
+ const indexPath = path.join(webDir, 'index.html');
71
+ try {
72
+ await fs.access(indexPath);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ const ext = path.extname(pathname).toLowerCase();
78
+ const isAsset = ext && ext !== '.html';
79
+ if (isAsset) {
80
+ const filePath = path.join(webDir, pathname);
81
+ try {
82
+ const file = Bun.file(filePath);
83
+ if (await file.exists()) {
84
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
85
+ return new Response(file, {
86
+ headers: { 'Content-Type': contentType },
87
+ });
88
+ }
89
+ return null;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ const file = Bun.file(indexPath);
96
+ return new Response(file, {
97
+ headers: { 'Content-Type': 'text/html' },
98
+ });
99
+ }