馃 distributed transcription service
thistle.dunkirk.sh
1import db from "./db/schema";
2import {
3 authenticateUser,
4 cleanupExpiredSessions,
5 createSession,
6 createUser,
7 deleteSession,
8 deleteUser,
9 getSession,
10 getSessionFromRequest,
11 getUserBySession,
12 getUserSessionsForUser,
13 updateUserAvatar,
14 updateUserEmail,
15 updateUserName,
16 updateUserPassword,
17} from "./lib/auth";
18import { handleError, ValidationErrors } from "./lib/errors";
19import { requireAuth } from "./lib/middleware";
20import {
21 MAX_FILE_SIZE,
22 TranscriptionEventEmitter,
23 type TranscriptionUpdate,
24 WhisperServiceManager,
25} from "./lib/transcription";
26import indexHTML from "./pages/index.html";
27import settingsHTML from "./pages/settings.html";
28import transcribeHTML from "./pages/transcribe.html";
29
30// Environment variables
31const WHISPER_SERVICE_URL =
32 process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
33
34// Create uploads directory if it doesn't exist
35await Bun.write("./uploads/.gitkeep", "");
36
37// Initialize transcription system
38const transcriptionEvents = new TranscriptionEventEmitter();
39const whisperService = new WhisperServiceManager(
40 WHISPER_SERVICE_URL,
41 db,
42 transcriptionEvents,
43);
44
45// Clean up expired sessions every hour
46setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
47
48// Sync with Whisper DB on startup
49await whisperService.syncWithWhisper();
50
51// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
52setInterval(() => whisperService.syncWithWhisper(), 5 * 60 * 1000);
53
54// Clean up stale files daily
55setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
56
57const server = Bun.serve({
58 port: 3000,
59 idleTimeout: 120, // 120 seconds for SSE connections
60 routes: {
61 "/": indexHTML,
62 "/settings": settingsHTML,
63 "/transcribe": transcribeHTML,
64 "/api/auth/register": {
65 POST: async (req) => {
66 try {
67 const body = await req.json();
68 const { email, password, name } = body;
69 if (!email || !password) {
70 return Response.json(
71 { error: "Email and password required" },
72 { status: 400 },
73 );
74 }
75 if (password.length < 8) {
76 return Response.json(
77 { error: "Password must be at least 8 characters" },
78 { status: 400 },
79 );
80 }
81 const user = await createUser(email, password, name);
82 const ipAddress =
83 req.headers.get("x-forwarded-for") ??
84 req.headers.get("x-real-ip") ??
85 "unknown";
86 const userAgent = req.headers.get("user-agent") ?? "unknown";
87 const sessionId = createSession(user.id, ipAddress, userAgent);
88 return Response.json(
89 { user: { id: user.id, email: user.email } },
90 {
91 headers: {
92 "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
93 },
94 },
95 );
96 } catch (err: unknown) {
97 const error = err as { message?: string };
98 if (error.message?.includes("UNIQUE constraint failed")) {
99 return Response.json(
100 { error: "Email already registered" },
101 { status: 400 },
102 );
103 }
104 return Response.json(
105 { error: "Registration failed" },
106 { status: 500 },
107 );
108 }
109 },
110 },
111 "/api/auth/login": {
112 POST: async (req) => {
113 try {
114 const body = await req.json();
115 const { email, password } = body;
116 if (!email || !password) {
117 return Response.json(
118 { error: "Email and password required" },
119 { status: 400 },
120 );
121 }
122 const user = await authenticateUser(email, password);
123 if (!user) {
124 return Response.json(
125 { error: "Invalid email or password" },
126 { status: 401 },
127 );
128 }
129 const ipAddress =
130 req.headers.get("x-forwarded-for") ??
131 req.headers.get("x-real-ip") ??
132 "unknown";
133 const userAgent = req.headers.get("user-agent") ?? "unknown";
134 const sessionId = createSession(user.id, ipAddress, userAgent);
135 return Response.json(
136 { user: { id: user.id, email: user.email } },
137 {
138 headers: {
139 "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
140 },
141 },
142 );
143 } catch {
144 return Response.json({ error: "Login failed" }, { status: 500 });
145 }
146 },
147 },
148 "/api/auth/logout": {
149 POST: async (req) => {
150 const sessionId = getSessionFromRequest(req);
151 if (sessionId) {
152 deleteSession(sessionId);
153 }
154 return Response.json(
155 { success: true },
156 {
157 headers: {
158 "Set-Cookie":
159 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
160 },
161 },
162 );
163 },
164 },
165 "/api/auth/me": {
166 GET: (req) => {
167 const sessionId = getSessionFromRequest(req);
168 if (!sessionId) {
169 return Response.json({ error: "Not authenticated" }, { status: 401 });
170 }
171 const user = getUserBySession(sessionId);
172 if (!user) {
173 return Response.json({ error: "Invalid session" }, { status: 401 });
174 }
175 return Response.json({
176 email: user.email,
177 name: user.name,
178 avatar: user.avatar,
179 created_at: user.created_at,
180 });
181 },
182 },
183 "/api/sessions": {
184 GET: (req) => {
185 const sessionId = getSessionFromRequest(req);
186 if (!sessionId) {
187 return Response.json({ error: "Not authenticated" }, { status: 401 });
188 }
189 const user = getUserBySession(sessionId);
190 if (!user) {
191 return Response.json({ error: "Invalid session" }, { status: 401 });
192 }
193 const sessions = getUserSessionsForUser(user.id);
194 return Response.json({
195 sessions: sessions.map((s) => ({
196 id: s.id,
197 ip_address: s.ip_address,
198 user_agent: s.user_agent,
199 created_at: s.created_at,
200 expires_at: s.expires_at,
201 })),
202 });
203 },
204 DELETE: async (req) => {
205 const currentSessionId = getSessionFromRequest(req);
206 if (!currentSessionId) {
207 return Response.json({ error: "Not authenticated" }, { status: 401 });
208 }
209 const user = getUserBySession(currentSessionId);
210 if (!user) {
211 return Response.json({ error: "Invalid session" }, { status: 401 });
212 }
213 const body = await req.json();
214 const targetSessionId = body.sessionId;
215 if (!targetSessionId) {
216 return Response.json(
217 { error: "Session ID required" },
218 { status: 400 },
219 );
220 }
221 // Verify the session belongs to the user
222 const targetSession = getSession(targetSessionId);
223 if (!targetSession || targetSession.user_id !== user.id) {
224 return Response.json({ error: "Session not found" }, { status: 404 });
225 }
226 deleteSession(targetSessionId);
227 return Response.json({ success: true });
228 },
229 },
230 "/api/user": {
231 DELETE: (req) => {
232 const sessionId = getSessionFromRequest(req);
233 if (!sessionId) {
234 return Response.json({ error: "Not authenticated" }, { status: 401 });
235 }
236 const user = getUserBySession(sessionId);
237 if (!user) {
238 return Response.json({ error: "Invalid session" }, { status: 401 });
239 }
240 deleteUser(user.id);
241 return Response.json(
242 { success: true },
243 {
244 headers: {
245 "Set-Cookie":
246 "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
247 },
248 },
249 );
250 },
251 },
252 "/api/user/email": {
253 PUT: async (req) => {
254 const sessionId = getSessionFromRequest(req);
255 if (!sessionId) {
256 return Response.json({ error: "Not authenticated" }, { status: 401 });
257 }
258 const user = getUserBySession(sessionId);
259 if (!user) {
260 return Response.json({ error: "Invalid session" }, { status: 401 });
261 }
262 const body = await req.json();
263 const { email } = body;
264 if (!email) {
265 return Response.json({ error: "Email required" }, { status: 400 });
266 }
267 try {
268 updateUserEmail(user.id, email);
269 return Response.json({ success: true });
270 } catch (err: unknown) {
271 const error = err as { message?: string };
272 if (error.message?.includes("UNIQUE constraint failed")) {
273 return Response.json(
274 { error: "Email already in use" },
275 { status: 400 },
276 );
277 }
278 return Response.json(
279 { error: "Failed to update email" },
280 { status: 500 },
281 );
282 }
283 },
284 },
285 "/api/user/password": {
286 PUT: async (req) => {
287 const sessionId = getSessionFromRequest(req);
288 if (!sessionId) {
289 return Response.json({ error: "Not authenticated" }, { status: 401 });
290 }
291 const user = getUserBySession(sessionId);
292 if (!user) {
293 return Response.json({ error: "Invalid session" }, { status: 401 });
294 }
295 const body = await req.json();
296 const { password } = body;
297 if (!password) {
298 return Response.json({ error: "Password required" }, { status: 400 });
299 }
300 if (password.length < 8) {
301 return Response.json(
302 { error: "Password must be at least 8 characters" },
303 { status: 400 },
304 );
305 }
306 try {
307 await updateUserPassword(user.id, password);
308 return Response.json({ success: true });
309 } catch {
310 return Response.json(
311 { error: "Failed to update password" },
312 { status: 500 },
313 );
314 }
315 },
316 },
317 "/api/user/name": {
318 PUT: async (req) => {
319 const sessionId = getSessionFromRequest(req);
320 if (!sessionId) {
321 return Response.json({ error: "Not authenticated" }, { status: 401 });
322 }
323 const user = getUserBySession(sessionId);
324 if (!user) {
325 return Response.json({ error: "Invalid session" }, { status: 401 });
326 }
327 const body = await req.json();
328 const { name } = body;
329 if (!name) {
330 return Response.json({ error: "Name required" }, { status: 400 });
331 }
332 try {
333 updateUserName(user.id, name);
334 return Response.json({ success: true });
335 } catch {
336 return Response.json(
337 { error: "Failed to update name" },
338 { status: 500 },
339 );
340 }
341 },
342 },
343 "/api/user/avatar": {
344 PUT: async (req) => {
345 const sessionId = getSessionFromRequest(req);
346 if (!sessionId) {
347 return Response.json({ error: "Not authenticated" }, { status: 401 });
348 }
349 const user = getUserBySession(sessionId);
350 if (!user) {
351 return Response.json({ error: "Invalid session" }, { status: 401 });
352 }
353 const body = await req.json();
354 const { avatar } = body;
355 if (!avatar) {
356 return Response.json({ error: "Avatar required" }, { status: 400 });
357 }
358 try {
359 updateUserAvatar(user.id, avatar);
360 return Response.json({ success: true });
361 } catch {
362 return Response.json(
363 { error: "Failed to update avatar" },
364 { status: 500 },
365 );
366 }
367 },
368 },
369 "/api/transcriptions/:id/stream": {
370 GET: (req) => {
371 const sessionId = getSessionFromRequest(req);
372 if (!sessionId) {
373 return Response.json({ error: "Not authenticated" }, { status: 401 });
374 }
375 const user = getUserBySession(sessionId);
376 if (!user) {
377 return Response.json({ error: "Invalid session" }, { status: 401 });
378 }
379 const transcriptionId = req.params.id;
380 // Verify ownership
381 const transcription = db
382 .query<{ id: string; user_id: number; status: string }, [string]>(
383 "SELECT id, user_id, status FROM transcriptions WHERE id = ?",
384 )
385 .get(transcriptionId);
386 if (!transcription || transcription.user_id !== user.id) {
387 return Response.json(
388 { error: "Transcription not found" },
389 { status: 404 },
390 );
391 }
392 // Event-driven SSE stream (NO POLLING!)
393 const stream = new ReadableStream({
394 start(controller) {
395 const encoder = new TextEncoder();
396
397 const sendEvent = (data: Partial<TranscriptionUpdate>) => {
398 controller.enqueue(
399 encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
400 );
401 };
402 // Send initial state from DB
403 const current = db
404 .query<
405 {
406 status: string;
407 progress: number;
408 transcript: string | null;
409 },
410 [string]
411 >(
412 "SELECT status, progress, transcript FROM transcriptions WHERE id = ?",
413 )
414 .get(transcriptionId);
415 if (current) {
416 sendEvent({
417 status: current.status as TranscriptionUpdate["status"],
418 progress: current.progress,
419 transcript: current.transcript || undefined,
420 });
421 }
422 // If already complete, close immediately
423 if (
424 current?.status === "completed" ||
425 current?.status === "failed"
426 ) {
427 controller.close();
428 return;
429 }
430 // Subscribe to EventEmitter for live updates
431 const updateHandler = (data: TranscriptionUpdate) => {
432 console.log(`[SSE to client] Job ${transcriptionId}:`, data);
433 // Only send changed fields to save bandwidth
434 const payload: Partial<TranscriptionUpdate> = {
435 status: data.status,
436 progress: data.progress,
437 };
438
439 if (data.transcript !== undefined) {
440 payload.transcript = data.transcript;
441 }
442 if (data.error_message !== undefined) {
443 payload.error_message = data.error_message;
444 }
445
446 sendEvent(payload);
447
448 // Close stream when done
449 if (data.status === "completed" || data.status === "failed") {
450 transcriptionEvents.off(transcriptionId, updateHandler);
451 controller.close();
452 }
453 };
454 transcriptionEvents.on(transcriptionId, updateHandler);
455 // Cleanup on client disconnect
456 return () => {
457 transcriptionEvents.off(transcriptionId, updateHandler);
458 };
459 },
460 });
461 return new Response(stream, {
462 headers: {
463 "Content-Type": "text/event-stream",
464 "Cache-Control": "no-cache",
465 Connection: "keep-alive",
466 },
467 });
468 },
469 },
470 "/api/transcriptions": {
471 GET: (req) => {
472 try {
473 const user = requireAuth(req);
474
475 const transcriptions = db
476 .query<
477 {
478 id: string;
479 filename: string;
480 original_filename: string;
481 status: string;
482 progress: number;
483 transcript: string | null;
484 created_at: number;
485 },
486 [number]
487 >(
488 "SELECT id, filename, original_filename, status, progress, transcript, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
489 )
490 .all(user.id);
491
492 return Response.json({
493 jobs: transcriptions.map((t) => ({
494 id: t.id,
495 filename: t.original_filename,
496 status: t.status,
497 progress: t.progress,
498 transcript: t.transcript,
499 created_at: t.created_at,
500 })),
501 });
502 } catch (error) {
503 return handleError(error);
504 }
505 },
506 POST: async (req) => {
507 try {
508 const user = requireAuth(req);
509
510 const formData = await req.formData();
511 const file = formData.get("audio") as File;
512
513 if (!file) throw ValidationErrors.missingField("audio");
514
515 if (!file.type.startsWith("audio/")) {
516 throw ValidationErrors.unsupportedFileType(
517 "MP3, WAV, M4A, AAC, OGG, WebM, FLAC",
518 );
519 }
520
521 if (file.size > MAX_FILE_SIZE) {
522 throw ValidationErrors.fileTooLarge("25MB");
523 }
524
525 // Generate unique filename
526 const transcriptionId = crypto.randomUUID();
527 const fileExtension = file.name.split(".").pop();
528 const filename = `${transcriptionId}.${fileExtension}`;
529
530 // Save file to disk
531 const uploadDir = "./uploads";
532 await Bun.write(`${uploadDir}/${filename}`, file);
533
534 // Create database record
535 db.run(
536 "INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)",
537 [transcriptionId, user.id, filename, file.name, "uploading"],
538 );
539
540 // Start transcription in background
541 whisperService.startTranscription(transcriptionId, filename);
542
543 return Response.json({
544 id: transcriptionId,
545 message: "Upload successful, transcription started",
546 });
547 } catch (error) {
548 return handleError(error);
549 }
550 },
551 },
552 },
553 development: {
554 hmr: true,
555 console: true,
556 },
557});
558console.log(`馃 Thistle running at http://localhost:${server.port}`);