···
-
cleanupExpiredSessions,
-
getUserSessionsForUser,
import indexHTML from "./pages/index.html";
import settingsHTML from "./pages/settings.html";
// Clean up expired sessions every hour
setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
const server = Bun.serve({
"/settings": settingsHTML,
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 });
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 });
-
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,
-
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 });
-
"/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 });
···
-
const sessionId = getSessionFromRequest(req);
-
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: "Name required" }, { status: 400 });
-
updateUserName(user.id, name);
-
return Response.json({ success: true });
-
{ 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 });
···
console.log(`🪻 Thistle running at http://localhost:${server.port}`);
···
+
import db from "./db/schema";
+
cleanupExpiredSessions,
+
getUserSessionsForUser,
+
import { handleError, ValidationErrors } from "./lib/errors";
+
import { requireAuth } from "./lib/middleware";
+
TranscriptionEventEmitter,
+
type TranscriptionUpdate,
+
} from "./lib/transcription";
import indexHTML from "./pages/index.html";
import settingsHTML from "./pages/settings.html";
+
import transcribeHTML from "./pages/transcribe.html";
+
// Environment variables
+
const WHISPER_SERVICE_URL =
+
process.env.WHISPER_SERVICE_URL || "http://localhost:8000";
+
// Create uploads directory if it doesn't exist
+
await Bun.write("./uploads/.gitkeep", "");
+
// Initialize transcription system
+
const transcriptionEvents = new TranscriptionEventEmitter();
+
const whisperService = new WhisperServiceManager(
// Clean up expired sessions every hour
setInterval(cleanupExpiredSessions, 60 * 60 * 1000);
+
// Sync with Whisper DB on startup
+
await whisperService.syncWithWhisper();
+
// Periodic sync every 5 minutes as backup (SSE handles real-time updates)
+
setInterval(() => whisperService.syncWithWhisper(), 5 * 60 * 1000);
+
// Clean up stale files daily
+
setInterval(() => whisperService.cleanupStaleFiles(), 24 * 60 * 60 * 1000);
const server = Bun.serve({
+
idleTimeout: 120, // 120 seconds for SSE connections
"/settings": settingsHTML,
+
"/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 });
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 });
···
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,
···
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 });
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 });
···
+
const sessionId = getSessionFromRequest(req);
+
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: "Name required" }, { status: 400 });
+
updateUserName(user.id, name);
+
return Response.json({ success: true });
+
{ 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 });
···
+
"/api/transcriptions/:id/stream": {
+
const sessionId = getSessionFromRequest(req);
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
const user = getUserBySession(sessionId);
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
const transcriptionId = req.params.id;
+
const transcription = db
+
.query<{ id: string; user_id: number; status: string }, [string]>(
+
"SELECT id, user_id, status FROM transcriptions WHERE id = ?",
+
if (!transcription || transcription.user_id !== user.id) {
+
{ error: "Transcription not found" },
+
// Event-driven SSE stream (NO POLLING!)
+
const stream = new ReadableStream({
+
const encoder = new TextEncoder();
+
const sendEvent = (data: Partial<TranscriptionUpdate>) => {
+
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
+
// Send initial state from DB
+
transcript: string | null;
+
"SELECT status, progress, transcript FROM transcriptions WHERE id = ?",
+
status: current.status as TranscriptionUpdate["status"],
+
progress: current.progress,
+
transcript: current.transcript || undefined,
+
// If already complete, close immediately
+
current?.status === "completed" ||
+
current?.status === "failed"
+
// Subscribe to EventEmitter for live updates
+
const updateHandler = (data: TranscriptionUpdate) => {
+
console.log(`[SSE to client] Job ${transcriptionId}:`, data);
+
// Only send changed fields to save bandwidth
+
const payload: Partial<TranscriptionUpdate> = {
+
progress: data.progress,
+
if (data.transcript !== undefined) {
+
payload.transcript = data.transcript;
+
if (data.error_message !== undefined) {
+
payload.error_message = data.error_message;
+
// Close stream when done
+
if (data.status === "completed" || data.status === "failed") {
+
transcriptionEvents.off(transcriptionId, updateHandler);
+
transcriptionEvents.on(transcriptionId, updateHandler);
+
// Cleanup on client disconnect
+
transcriptionEvents.off(transcriptionId, updateHandler);
+
return new Response(stream, {
+
"Content-Type": "text/event-stream",
+
"Cache-Control": "no-cache",
+
Connection: "keep-alive",
+
"/api/transcriptions": {
+
const user = requireAuth(req);
+
const transcriptions = db
+
original_filename: string;
+
transcript: string | null;
+
"SELECT id, filename, original_filename, status, progress, transcript, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",
+
jobs: transcriptions.map((t) => ({
+
filename: t.original_filename,
+
transcript: t.transcript,
+
created_at: t.created_at,
+
return handleError(error);
+
const user = requireAuth(req);
+
const formData = await req.formData();
+
const file = formData.get("audio") as File;
+
if (!file) throw ValidationErrors.missingField("audio");
+
if (!file.type.startsWith("audio/")) {
+
throw ValidationErrors.unsupportedFileType(
+
"MP3, WAV, M4A, AAC, OGG, WebM, FLAC",
+
if (file.size > MAX_FILE_SIZE) {
+
throw ValidationErrors.fileTooLarge("25MB");
+
// Generate unique filename
+
const transcriptionId = crypto.randomUUID();
+
const fileExtension = file.name.split(".").pop();
+
const filename = `${transcriptionId}.${fileExtension}`;
+
const uploadDir = "./uploads";
+
await Bun.write(`${uploadDir}/${filename}`, file);
+
// Create database record
+
"INSERT INTO transcriptions (id, user_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?)",
+
[transcriptionId, user.id, filename, file.name, "uploading"],
+
// Start transcription in background
+
whisperService.startTranscription(transcriptionId, filename);
+
message: "Upload successful, transcription started",
+
return handleError(error);
console.log(`🪻 Thistle running at http://localhost:${server.port}`);