···
1
+
import db from "./db/schema";
3
-
cleanupExpiredSessions,
9
-
getSessionFromRequest,
11
-
getUserSessionsForUser,
4
+
cleanupExpiredSessions,
10
+
getSessionFromRequest,
12
+
getUserSessionsForUser,
18
+
import { handleError, ValidationErrors } from "./lib/errors";
19
+
import { requireAuth } from "./lib/middleware";
22
+
TranscriptionEventEmitter,
23
+
type TranscriptionUpdate,
24
+
WhisperServiceManager,
25
+
} from "./lib/transcription";
import indexHTML from "./pages/index.html";
import settingsHTML from "./pages/settings.html";
28
+
import transcribeHTML from "./pages/transcribe.html";
30
+
// Environment variables
31
+
const WHISPER_SERVICE_URL =
32
+
process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
34
+
// Create uploads directory if it doesn't exist
35
+
await Bun.write("./uploads/.gitkeep", "");
37
+
// Initialize transcription system
38
+
const transcriptionEvents = new TranscriptionEventEmitter();
39
+
const whisperService = new WhisperServiceManager(
40
+
WHISPER_SERVICE_URL,
42
+
transcriptionEvents,
// Clean up expired sessions every hour
setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
48
+
// Sync with Whisper DB on startup
49
+
await whisperService.syncWithWhisper();
51
+
// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
52
+
setInterval(() => whisperService.syncWithWhisper(), 5 * 60 * 1000);
54
+
// Clean up stale files daily
55
+
setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
const server = Bun.serve({
59
+
idleTimeout: 120, // 120 seconds for SSE connections
"/settings": settingsHTML,
63
+
"/transcribe": transcribeHTML,
const body = await req.json();
const { email, password, name } = body;
if (!email || !password) {
{ error: "Email and password required" },
if (password.length < 8) {
{ error: "Password must be at least 8 characters" },
const user = await createUser(email, password, name);
req.headers.get("x-forwarded-for") ??
···
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
{ user: { id: user.id, email: user.email } },
···
const body = await req.json();
const { email, password } = body;
if (!email || !password) {
{ error: "Email and password required" },
const user = await authenticateUser(email, password);
{ error: "Invalid email or password" },
req.headers.get("x-forwarded-for") ??
req.headers.get("x-real-ip") ??
const userAgent = req.headers.get("user-agent") ?? "unknown";
const sessionId = createSession(user.id, ipAddress, userAgent);
{ user: { id: user.id, email: user.email } },
···
return Response.json({ error: "Login failed" }, { status: 500 });
149
+
POST: async (req) => {
const sessionId = getSessionFromRequest(req);
deleteSession(sessionId);
···
return Response.json({ error: "Not authenticated" }, { status: 401 });
const user = getUserBySession(sessionId);
return Response.json({ error: "Invalid session" }, { status: 401 });
155
-
created_at: user.created_at,
···
return Response.json({ error: "Not authenticated" }, { status: 401 });
const user = getUserBySession(sessionId);
return Response.json({ error: "Invalid session" }, { status: 401 });
const sessions = getUserSessionsForUser(user.id);
sessions: sessions.map((s) => ({
···
user_agent: s.user_agent,
created_at: s.created_at,
expires_at: s.expires_at,
179
-
is_current: s.id === sessionId,
···
return Response.json({ error: "Not authenticated" }, { status: 401 });
const user = getUserBySession(currentSessionId);
return Response.json({ error: "Invalid session" }, { status: 401 });
const body = await req.json();
const targetSessionId = body.sessionId;
{ error: "Session ID required" },
// Verify the session belongs to the user
const targetSession = getSession(targetSessionId);
if (!targetSession || targetSession.user_id !== user.id) {
return Response.json({ error: "Session not found" }, { status: 404 });
deleteSession(targetSessionId);
return Response.json({ success: true });
215
-
"/api/auth/delete-account": {
const sessionId = getSessionFromRequest(req);
return Response.json({ error: "Not authenticated" }, { status: 401 });
const user = getUserBySession(sessionId);
return Response.json({ error: "Invalid session" }, { status: 401 });
···
return Response.json({ error: "Not authenticated" }, { status: 401 });
const user = getUserBySession(sessionId);
return Response.json({ error: "Invalid session" }, { status: 401 });
const body = await req.json();
return Response.json({ error: "Email required" }, { status: 400 });
updateUserEmail(user.id, email);
return Response.json({ success: true });
···
return Response.json({ error: "Not authenticated" }, { status: 401 });
const user = getUserBySession(sessionId);
return Response.json({ error: "Invalid session" }, { status: 401 });
const body = await req.json();
const { password } = body;
return Response.json({ error: "Password required" }, { status: 400 });
if (password.length < 8) {
{ error: "Password must be at least 8 characters" },
await updateUserPassword(user.id, password);
return Response.json({ success: true });
···
315
-
PUT: async (req) => {
316
-
const sessionId = getSessionFromRequest(req);
318
-
return Response.json({ error: "Not authenticated" }, { status: 401 });
321
-
const user = getUserBySession(sessionId);
323
-
return Response.json({ error: "Invalid session" }, { status: 401 });
326
-
const body = await req.json();
327
-
const { name } = body;
330
-
return Response.json({ error: "Name required" }, { status: 400 });
334
-
updateUserName(user.id, name);
335
-
return Response.json({ success: true });
337
-
return Response.json(
338
-
{ error: "Failed to update name" },
317
+
PUT: async (req) => {
318
+
const sessionId = getSessionFromRequest(req);
320
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
322
+
const user = getUserBySession(sessionId);
324
+
return Response.json({ error: "Invalid session" }, { status: 401 });
326
+
const body = await req.json();
327
+
const { name } = body;
329
+
return Response.json({ error: "Name required" }, { status: 400 });
332
+
updateUserName(user.id, name);
333
+
return Response.json({ success: true });
335
+
return Response.json(
336
+
{ error: "Failed to update name" },
···
return Response.json({ error: "Not authenticated" }, { status: 401 });
const user = getUserBySession(sessionId);
return Response.json({ error: "Invalid session" }, { status: 401 });
const body = await req.json();
return Response.json({ error: "Avatar required" }, { status: 400 });
updateUserAvatar(user.id, avatar);
return Response.json({ success: true });
···
368
+
"/api/transcriptions/:id/stream": {
370
+
const sessionId = getSessionFromRequest(req);
372
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
374
+
const user = getUserBySession(sessionId);
376
+
return Response.json({ error: "Invalid session" }, { status: 401 });
378
+
const transcriptionId = req.params.id;
379
+
// Verify ownership
380
+
const transcription = db
381
+
.query<{ id: string; user_id: number; status: string }, [string]>(
382
+
"SELECT id, user_id, status FROM transcriptions WHERE id = ?",
384
+
.get(transcriptionId);
385
+
if (!transcription || transcription.user_id !== user.id) {
386
+
return Response.json(
387
+
{ error: "Transcription not found" },
391
+
// Event-driven SSE stream (NO POLLING!)
392
+
const stream = new ReadableStream({
393
+
start(controller) {
394
+
const encoder = new TextEncoder();
396
+
const sendEvent = (data: Partial<TranscriptionUpdate>) => {
397
+
controller.enqueue(
398
+
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
401
+
// Send initial state from DB
407
+
transcript: string | null;
411
+
"SELECT status, progress, transcript FROM transcriptions WHERE id = ?",
413
+
.get(transcriptionId);
416
+
status: current.status as TranscriptionUpdate["status"],
417
+
progress: current.progress,
418
+
transcript: current.transcript || undefined,
421
+
// If already complete, close immediately
423
+
current?.status === "completed" ||
424
+
current?.status === "failed"
426
+
controller.close();
429
+
// Subscribe to EventEmitter for live updates
430
+
const updateHandler = (data: TranscriptionUpdate) => {
431
+
console.log(`[SSE to client] Job ${transcriptionId}:`, data);
432
+
// Only send changed fields to save bandwidth
433
+
const payload: Partial<TranscriptionUpdate> = {
434
+
status: data.status,
435
+
progress: data.progress,
438
+
if (data.transcript !== undefined) {
439
+
payload.transcript = data.transcript;
441
+
if (data.error_message !== undefined) {
442
+
payload.error_message = data.error_message;
445
+
sendEvent(payload);
447
+
// Close stream when done
448
+
if (data.status === "completed" || data.status === "failed") {
449
+
transcriptionEvents.off(transcriptionId, updateHandler);
450
+
controller.close();
453
+
transcriptionEvents.on(transcriptionId, updateHandler);
454
+
// Cleanup on client disconnect
456
+
transcriptionEvents.off(transcriptionId, updateHandler);
460
+
return new Response(stream, {
462
+
"Content-Type": "text/event-stream",
463
+
"Cache-Control": "no-cache",
464
+
Connection: "keep-alive",
469
+
"/api/transcriptions": {
472
+
const user = requireAuth(req);
474
+
const transcriptions = db
479
+
original_filename: string;
482
+
transcript: string | null;
483
+
created_at: number;
487
+
"SELECT id, filename, original_filename, status, progress, transcript, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
491
+
return Response.json({
492
+
jobs: transcriptions.map((t) => ({
494
+
filename: t.original_filename,
496
+
progress: t.progress,
497
+
transcript: t.transcript,
498
+
created_at: t.created_at,
502
+
return handleError(error);
505
+
POST: async (req) => {
507
+
const user = requireAuth(req);
509
+
const formData = await req.formData();
510
+
const file = formData.get("audio") as File;
512
+
if (!file) throw ValidationErrors.missingField("audio");
514
+
if (!file.type.startsWith("audio/")) {
515
+
throw ValidationErrors.unsupportedFileType(
516
+
"MP3, WAV, M4A, AAC, OGG, WebM, FLAC",
520
+
if (file.size > MAX_FILE_SIZE) {
521
+
throw ValidationErrors.fileTooLarge("25MB");
524
+
// Generate unique filename
525
+
const transcriptionId = crypto.randomUUID();
526
+
const fileExtension = file.name.split(".").pop();
527
+
const filename = `${transcriptionId}.${fileExtension}`;
529
+
// Save file to disk
530
+
const uploadDir = "./uploads";
531
+
await Bun.write(`${uploadDir}/${filename}`, file);
533
+
// Create database record
535
+
"INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)",
536
+
[transcriptionId, user.id, filename, file.name, "uploading"],
539
+
// Start transcription in background
540
+
whisperService.startTranscription(transcriptionId, filename);
542
+
return Response.json({
543
+
id: transcriptionId,
544
+
message: "Upload successful, transcription started",
547
+
return handleError(error);
console.log(`🪻 Thistle running at http://localhost:${server.port}`);