Migrate from Node.js built-ins to Bun-native APIs (#1)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
2026-03-04 12:49:38 -05:00
committed by GitHub
co-authored by Claude
parent 36afbcb8c0
commit 83456c6a21
15 changed files with 99 additions and 106 deletions
+6 -3
View File
@@ -1,6 +1,5 @@
"use server";
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
@@ -56,7 +55,9 @@ export async function saveWebhookConnection(
};
}
const token = crypto.randomBytes(32).toString("hex");
const token = Buffer.from(
crypto.getRandomValues(new Uint8Array(32)),
).toString("hex");
const now = new Date();
const connection = db
@@ -103,7 +104,9 @@ export async function regenerateWebhookToken(provider: "plex" | "jellyfin") {
throw new Error("Invalid provider");
}
const newToken = crypto.randomBytes(32).toString("hex");
const newToken = Buffer.from(
crypto.getRandomValues(new Uint8Array(32)),
).toString("hex");
const connection = db
.update(webhookConnections)
@@ -19,17 +19,17 @@ async function getAdminSession() {
export async function createBackupAction(): Promise<BackupInfo> {
await getAdminSession();
return createBackup();
return await createBackup();
}
export async function listBackupsAction(): Promise<BackupInfo[]> {
await getAdminSession();
return listBackups();
return await listBackups();
}
export async function deleteBackupAction(filename: string): Promise<void> {
await getAdminSession();
deleteBackup(filename);
await deleteBackup(filename);
}
export async function setScheduledBackupAction(
+1 -1
View File
@@ -57,7 +57,7 @@ export default async function SettingsPage() {
? getSetting("registrationOpen") === "true"
: false;
const backups = isAdmin ? listBackups() : [];
const backups = isAdmin ? await listBackups() : [];
const scheduledBackupsEnabled = isAdmin
? getSetting("scheduledBackups") === "true"
: false;
+4 -6
View File
@@ -1,5 +1,3 @@
import { statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { headers } from "next/headers";
import { type NextRequest, NextResponse } from "next/server";
@@ -26,20 +24,20 @@ export async function GET(
return NextResponse.json({ error: "Invalid filename" }, { status: 400 });
}
const backupPath = getBackupPath(safe);
const backupPath = await getBackupPath(safe);
if (!backupPath) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const buffer = await readFile(backupPath);
const stat = statSync(backupPath);
const file = Bun.file(backupPath);
const buffer = await file.arrayBuffer();
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
"Content-Type": "application/x-sqlite3",
"Content-Disposition": `attachment; filename="${safe}"`,
"Content-Length": String(stat.size),
"Content-Length": String(file.size),
},
});
}
+1 -1
View File
@@ -27,7 +27,7 @@ export async function POST(req: Request) {
const buffer = Buffer.from(await file.arrayBuffer());
try {
restoreFromBackup(buffer);
await restoreFromBackup(buffer);
return NextResponse.json({ success: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Restore failed";
+1 -4
View File
@@ -30,7 +30,6 @@
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0",
"uuid": "13.0.0",
"vaul": "1.1.2",
"zod": "4.3.6",
},
@@ -1475,7 +1474,7 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
@@ -1525,8 +1524,6 @@
"@azure/identity/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
+2 -2
View File
@@ -12,12 +12,12 @@ export async function register() {
"@/lib/services/image-cache"
);
if (imageCacheEnabled()) {
ensureImageDirs();
await ensureImageDirs();
}
// Ensure backup directory exists
const { ensureBackupDir } = await import("@/lib/services/backup");
ensureBackupDir();
await ensureBackupDir();
// Run database migrations on startup
const { runMigrations } = await import("@/lib/db/migrate");
+1 -2
View File
@@ -2,7 +2,6 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { admin, genericOAuth } from "better-auth/plugins";
import { v4 as uuid } from "uuid";
import {
isOidcAutoRegisterEnabled,
isOidcConfigured,
@@ -64,7 +63,7 @@ export const auth = betterAuth({
plugins: [admin(), ...oidcPlugin],
advanced: {
database: {
generateId: () => uuid(),
generateId: () => Bun.randomUUIDv7(),
},
},
hooks: {
+3 -3
View File
@@ -220,12 +220,12 @@ async function scheduledBackupJob() {
return;
}
ensureBackupDir();
createBackup();
await ensureBackupDir();
await createBackup();
const maxStr = getSetting("maxBackupRetention");
const max = maxStr ? Number.parseInt(maxStr, 10) : 7;
pruneBackups(max);
await pruneBackups(max);
}
export function startJobs() {
+1 -2
View File
@@ -6,13 +6,12 @@ import {
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
import { v4 as uuid } from "uuid";
// Helper for UUID primary keys
const uuidPk = () =>
text("id")
.primaryKey()
.$defaultFn(() => uuid());
.$defaultFn(() => Bun.randomUUIDv7());
// ─── Better Auth tables ──────────────────────────────────────────────
+50 -56
View File
@@ -1,14 +1,5 @@
import { Database } from "bun:sqlite";
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { readFile } from "node:fs/promises";
import { mkdir, readdir } from "node:fs/promises";
import path from "node:path";
import { format } from "date-fns";
import { sql } from "drizzle-orm";
@@ -31,10 +22,8 @@ export interface BackupInfo {
createdAt: string;
}
export function ensureBackupDir() {
if (!existsSync(BACKUP_DIR)) {
mkdirSync(BACKUP_DIR, { recursive: true });
}
export async function ensureBackupDir() {
await mkdir(BACKUP_DIR, { recursive: true });
}
function isValidBackupFilename(filename: string): boolean {
@@ -46,8 +35,10 @@ function isValidBackupFilename(filename: string): boolean {
);
}
export function createBackup(prefix = "sofa-backup"): BackupInfo {
ensureBackupDir();
export async function createBackup(
prefix = "sofa-backup",
): Promise<BackupInfo> {
await ensureBackupDir();
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmss");
const filename = `${prefix}-${timestamp}.db`;
@@ -56,74 +47,74 @@ export function createBackup(prefix = "sofa-backup"): BackupInfo {
// VACUUM INTO atomically creates a clean, self-contained copy (safe for WAL mode)
db.run(sql.raw(`VACUUM INTO '${dest.replace(/'/g, "''")}'`));
const stat = statSync(dest);
log.info(`Created backup: ${filename} (${stat.size} bytes)`);
const s = await Bun.file(dest).stat();
log.info(`Created backup: ${filename} (${s.size} bytes)`);
return {
filename,
sizeBytes: stat.size,
createdAt: stat.mtime.toISOString(),
sizeBytes: s.size,
createdAt: s.mtime.toISOString(),
};
}
export function listBackups(): BackupInfo[] {
ensureBackupDir();
export async function listBackups(): Promise<BackupInfo[]> {
await ensureBackupDir();
const files = readdirSync(BACKUP_DIR).filter(
const files = (await readdir(BACKUP_DIR)).filter(
(f) => BACKUP_PATTERN.test(f) || PRE_RESTORE_PATTERN.test(f),
);
return files
.map((filename) => {
const stat = statSync(path.join(BACKUP_DIR, filename));
return {
filename,
sizeBytes: stat.size,
createdAt: stat.mtime.toISOString(),
};
})
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
const results: BackupInfo[] = [];
for (const filename of files) {
const s = await Bun.file(path.join(BACKUP_DIR, filename)).stat();
results.push({
filename,
sizeBytes: s.size,
createdAt: s.mtime.toISOString(),
});
}
return results.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
export function deleteBackup(filename: string): void {
export async function deleteBackup(filename: string): Promise<void> {
if (!isValidBackupFilename(filename)) {
throw new Error("Invalid backup filename");
}
const filePath = path.join(BACKUP_DIR, filename);
if (!existsSync(filePath)) {
if (!(await Bun.file(filePath).exists())) {
throw new Error("Backup not found");
}
unlinkSync(filePath);
await Bun.file(filePath).delete();
log.info(`Deleted backup: ${filename}`);
}
export function getBackupPath(filename: string): string | null {
export async function getBackupPath(filename: string): Promise<string | null> {
if (!isValidBackupFilename(filename)) return null;
const filePath = path.join(BACKUP_DIR, filename);
return existsSync(filePath) ? filePath : null;
return (await Bun.file(filePath).exists()) ? filePath : null;
}
export async function readBackupFile(filename: string): Promise<Buffer | null> {
const filePath = getBackupPath(filename);
const filePath = await getBackupPath(filename);
if (!filePath) return null;
return readFile(filePath);
return Buffer.from(await Bun.file(filePath).arrayBuffer());
}
export function restoreFromBackup(buffer: Buffer): void {
ensureBackupDir();
export async function restoreFromBackup(buffer: Buffer): Promise<void> {
await ensureBackupDir();
const timestamp = format(new Date(), "yyyy-MM-dd-HHmmss");
const tempPath = path.join(BACKUP_DIR, `_restore-temp-${timestamp}.db`);
try {
// Write uploaded file to temp location
writeFileSync(tempPath, buffer);
await Bun.write(tempPath, buffer);
// Validate it's a real SQLite database
const testDb = new Database(tempPath, { readonly: true });
@@ -150,34 +141,37 @@ export function restoreFromBackup(buffer: Buffer): void {
// Create safety backup before replacing
log.info("Creating pre-restore safety backup...");
createBackup("pre-restore");
await createBackup("pre-restore");
// Replace the database
log.info("Replacing database...");
closeDatabase();
copyFileSync(tempPath, DATABASE_URL);
await Bun.write(DATABASE_URL, Bun.file(tempPath));
// Clean up WAL/SHM files from the old database
const walPath = `${DATABASE_URL}-wal`;
const shmPath = `${DATABASE_URL}-shm`;
if (existsSync(walPath)) unlinkSync(walPath);
if (existsSync(shmPath)) unlinkSync(shmPath);
const walFile = Bun.file(`${DATABASE_URL}-wal`);
const shmFile = Bun.file(`${DATABASE_URL}-shm`);
if (await walFile.exists()) await walFile.delete();
if (await shmFile.exists()) await shmFile.delete();
log.info("Database restored successfully");
} finally {
// Clean up temp file
if (existsSync(tempPath)) unlinkSync(tempPath);
const tempFile = Bun.file(tempPath);
if (await tempFile.exists()) await tempFile.delete();
}
}
export function pruneBackups(maxKeep: number): void {
const backups = listBackups().filter((b) => BACKUP_PATTERN.test(b.filename));
export async function pruneBackups(maxKeep: number): Promise<void> {
const backups = (await listBackups()).filter((b) =>
BACKUP_PATTERN.test(b.filename),
);
if (backups.length <= maxKeep) return;
const toDelete = backups.slice(maxKeep);
for (const backup of toDelete) {
deleteBackup(backup.filename);
await deleteBackup(backup.filename);
}
log.info(`Pruned ${toDelete.length} old backup(s), kept ${maxKeep}`);
+1 -1
View File
@@ -32,7 +32,7 @@ export async function extractAndStoreColors(
let source: string;
const filename = path.basename(posterPath);
if (imageCacheEnabled()) {
if (!isImageCached("posters", filename)) {
if (!(await isImageCached("posters", filename))) {
await downloadAndCacheImage(posterPath, "posters");
}
source = getLocalImagePath("posters", filename);
+9 -2
View File
@@ -63,6 +63,13 @@ export interface HistoryBucket {
count: number;
}
function toLocalDateBucket(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function getWatchHistory(
userId: string,
table: "movies" | "episodes",
@@ -89,7 +96,7 @@ export function getWatchHistory(
buckets = Array.from({ length: 7 }, (_, i) => {
const d = new Date(now);
d.setDate(now.getDate() - 6 + i);
return d.toISOString().slice(0, 10);
return toLocalDateBucket(d);
});
break;
}
@@ -98,7 +105,7 @@ export function getWatchHistory(
buckets = Array.from({ length: 30 }, (_, i) => {
const d = new Date(now);
d.setDate(now.getDate() - 29 + i);
return d.toISOString().slice(0, 10);
return toLocalDateBucket(d);
});
break;
}
+16 -19
View File
@@ -1,5 +1,4 @@
import { existsSync, mkdirSync } from "node:fs";
import { readFile, rename, writeFile } from "node:fs/promises";
import { mkdir, rename } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
@@ -26,20 +25,17 @@ export function imageCacheEnabled(): boolean {
return process.env.IMAGE_CACHE_ENABLED !== "false";
}
export function ensureImageDirs() {
export async function ensureImageDirs() {
for (const category of Object.keys(CATEGORY_SIZES)) {
const dir = path.join(CACHE_DIR, category);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
await mkdir(path.join(CACHE_DIR, category), { recursive: true });
}
}
export function isImageCached(
export async function isImageCached(
category: ImageCategory,
filename: string,
): boolean {
return existsSync(getLocalImagePath(category, filename));
): Promise<boolean> {
return Bun.file(getLocalImagePath(category, filename)).exists();
}
export function getLocalImagePath(
@@ -54,8 +50,9 @@ export async function readCachedImage(
filename: string,
): Promise<Buffer | null> {
const filePath = getLocalImagePath(category, filename);
if (!existsSync(filePath)) return null;
return readFile(filePath);
const file = Bun.file(filePath);
if (!(await file.exists())) return null;
return Buffer.from(await file.arrayBuffer());
}
export async function downloadAndCacheImage(
@@ -74,7 +71,7 @@ export async function downloadAndCacheImage(
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
try {
await writeFile(tmpPath, buffer);
await Bun.write(tmpPath, buffer);
await rename(tmpPath, finalPath);
} catch {
// Best-effort cleanup
@@ -113,7 +110,7 @@ export async function fetchAndMaybeCache(
// Fire-and-forget save to disk
const finalPath = getLocalImagePath(category, filename);
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
writeFile(tmpPath, buffer)
Bun.write(tmpPath, buffer)
.then(() => rename(tmpPath, finalPath))
.catch(() => {});
@@ -128,13 +125,13 @@ export async function cacheImagesForTitle(titleId: string) {
if (
title.posterPath &&
!isImageCached("posters", path.basename(title.posterPath))
!(await isImageCached("posters", path.basename(title.posterPath)))
) {
tasks.push(downloadAndCacheImage(title.posterPath, "posters"));
}
if (
title.backdropPath &&
!isImageCached("backdrops", path.basename(title.backdropPath))
!(await isImageCached("backdrops", path.basename(title.backdropPath)))
) {
tasks.push(downloadAndCacheImage(title.backdropPath, "backdrops"));
}
@@ -149,7 +146,7 @@ export async function cacheImagesForTitle(titleId: string) {
for (const s of allSeasons) {
if (
s.posterPath &&
!isImageCached("posters", path.basename(s.posterPath))
!(await isImageCached("posters", path.basename(s.posterPath)))
) {
tasks.push(downloadAndCacheImage(s.posterPath, "posters"));
}
@@ -177,7 +174,7 @@ export async function cacheEpisodeStills(titleId: string) {
for (const ep of eps) {
if (
ep.stillPath &&
!isImageCached("stills", path.basename(ep.stillPath))
!(await isImageCached("stills", path.basename(ep.stillPath)))
) {
tasks.push(downloadAndCacheImage(ep.stillPath, "stills"));
}
@@ -198,7 +195,7 @@ export async function cacheProviderLogos(titleId: string) {
for (const offer of offers) {
if (offer.logoPath) {
const basename = path.basename(offer.logoPath);
if (!seen.has(basename) && !isImageCached("logos", basename)) {
if (!seen.has(basename) && !(await isImageCached("logos", basename))) {
seen.add(basename);
tasks.push(downloadAndCacheImage(offer.logoPath, "logos"));
}
-1
View File
@@ -40,7 +40,6 @@
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0",
"uuid": "13.0.0",
"vaul": "1.1.2",
"zod": "4.3.6"
},