chore: dedup dead code + compress utility
Deploy FileDrop / deploy (push) Successful in 43s

- Hapus src/shared/utils/retry.ts (0 imports, dead)
- Hapus src/shared/errors/index.ts (7 classes, 0 imports)
- Hapus drizzle type exports dari schema.ts (tidak dipake)
- Hapus src/config/index.ts, redirect 8 imports langsung ke env.ts
- Ekstrak maybeCompressChunk ke shared/utils/compress.ts
- Hapus duplikasi gzipSync di upload-file.ts + chunked-storage.ts
- Lint clean, build clean
This commit is contained in:
Claude
2026-07-29 15:51:39 +07:00
parent 4c216b1d9f
commit 332853f398
15 changed files with 46 additions and 304 deletions
+3 -33
View File
@@ -1,18 +1,14 @@
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { open } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
import { nanoid } from 'nanoid';
import type { NewFilePart } from '../../domain/entities/file-part';
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
import type { IFileRepository } from '../../domain/ports/file-repository';
import type { ITelegramService } from '../../domain/ports/telegram-service';
import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress';
import { checkFileSize, computeHash, ensureExtension, getFileType } from '../../shared/utils/file';
import type { UploadInput, UploadOutput } from '../dto/upload';
/** Compression algorithm string literal used in chunked storage. */
type ChunkCompressionAlgorithm = 'gzip' | null;
/** Metadata for a single uploaded chunk/part. */
interface UploadedPart {
/** 1-based part number. */
@@ -28,7 +24,7 @@ interface UploadedPart {
/** Stored (post-compression) size in bytes. */
storedSizeBytes: number;
/** Compression algorithm applied, or null if uncompressed. */
compressionAlgorithm: ChunkCompressionAlgorithm;
compressionAlgorithm: CompressionAlgorithm;
/** SHA-256 hash of the original chunk content. */
etag: string;
}
@@ -82,32 +78,6 @@ const asSafeChunkSize = (chunkSizeBytes: number): number => {
return chunkSizeBytes;
};
/**
* Optionally gzip-compresses a chunk if compression is enabled and the chunk
* is large enough to benefit from it.
*
* @param chunk - The raw chunk buffer.
* @param compress - Whether compression is enabled.
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
* @returns The (possibly compressed) buffer and the algorithm used.
*/
const maybeCompressChunk = (
chunk: Buffer,
compress: boolean,
compressionMinSizeBytes: number,
): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => {
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
return { bytes: chunk, compressionAlgorithm: null };
}
const gzipped = gzipSync(chunk);
if (gzipped.byteLength >= chunk.byteLength) {
return { bytes: chunk, compressionAlgorithm: null };
}
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
};
/**
* Reads the first 16 bytes from a file on disk for magic-byte detection.
*
@@ -258,7 +228,7 @@ export function createUploadFileUseCase(deps: UploadFileUseCaseDeps) {
throw new Error('Chunked upload produced no parts');
}
const fileId = randomUUID();
const fileId = nanoid();
const publicId = nanoid();
const newFile = await deps.fileRepo.create({
-3
View File
@@ -1,3 +0,0 @@
import { config } from '../env';
export { config };
+1 -1
View File
@@ -1,5 +1,5 @@
import { serve } from 'bun';
import { config } from './config/index';
import { config } from './env';
import { fileInfoCache } from './infrastructure/cache/index';
import { startBot } from './interfaces/bot/handler';
import { handleS3Request } from './interfaces/http/controllers/s3-controller';
@@ -1,4 +1,3 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
import {
bigint,
boolean,
@@ -63,15 +62,3 @@ export const fileParts = pgTable('file_parts', {
etag: text('etag').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
/** Type representing a file row selected from the database. */
export type File = InferSelectModel<typeof files>;
/** Type representing a file row being inserted into the database. */
export type NewFile = InferInsertModel<typeof files>;
/** Type representing a file part row selected from the database. */
export type FilePart = InferSelectModel<typeof fileParts>;
/** Type representing a file part row being inserted into the database. */
export type NewFilePart = InferInsertModel<typeof fileParts>;
+3 -39
View File
@@ -1,22 +1,16 @@
import { createReadStream } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { nanoid } from 'nanoid';
import type { File as FileEntity } from '../../domain/entities/file';
import type { CompressionAlgorithm, NewFilePart } from '../../domain/entities/file-part';
import type { NewFilePart } from '../../domain/entities/file-part';
import type { IFilePartRepository } from '../../domain/ports/file-part-repository';
import type { IFileRepository } from '../../domain/ports/file-repository';
import type { ITelegramService } from '../../domain/ports/telegram-service';
import { config } from '../../env';
import { createGetObjectResponse, type ObjectPartSource } from '../../interfaces/s3/object-stream';
import type { RangeParseResult } from '../../interfaces/s3/range';
import { type CompressionAlgorithm, maybeCompressChunk } from '../../shared/utils/compress';
import { computeHash } from '../../shared/utils/file';
/**
* Chunk compression algorithm identifier.
* `"gzip"` if gzip compression was applied, `null` for uncompressed.
*/
export type ChunkCompressionAlgorithm = CompressionAlgorithm;
/**
* Metadata about a single uploaded chunk (part) stored in Telegram.
*/
@@ -34,7 +28,7 @@ export interface ChunkedUploadPart {
/** Stored (post-compression) size in bytes */
storedSizeBytes: number;
/** Compression algorithm applied, or null */
compressionAlgorithm: ChunkCompressionAlgorithm;
compressionAlgorithm: CompressionAlgorithm;
/** ETag (SHA-256 hash) of the original chunk */
etag: string;
}
@@ -89,36 +83,6 @@ const asSafeChunkSize = (chunkSizeBytes: number): number => {
return chunkSizeBytes;
};
/**
* Optionally compress a chunk with gzip.
*
* Compression is skipped if:
* - The `compress` flag is false.
* - The chunk is smaller than `compressionMinSizeBytes`.
* - The compressed result is larger than the original.
*
* @param chunk - The raw chunk buffer.
* @param compress - Whether compression is enabled.
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
* @returns The (possibly compressed) bytes and the algorithm used.
*/
const maybeCompressChunk = (
chunk: Buffer,
compress: boolean,
compressionMinSizeBytes: number,
): { bytes: Buffer; compressionAlgorithm: ChunkCompressionAlgorithm } => {
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
return { bytes: chunk, compressionAlgorithm: null };
}
const gzipped = gzipSync(chunk);
if (gzipped.byteLength >= chunk.byteLength) {
return { bytes: chunk, compressionAlgorithm: null };
}
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
};
/**
* Manages chunked storage of large files in Telegram.
*
@@ -4,7 +4,7 @@ import {
createLogoutUseCase,
createMeUseCase,
} from '../../../application/use-cases/authenticate';
import { config } from '../../../config/index';
import { config } from '../../../env';
import {
checkBearerToken,
clearSessionCookie,
@@ -1,8 +1,8 @@
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { config } from '../../../config/index';
import type { File as FileEntity } from '../../../domain/entities/file';
import type { ForwardResult } from '../../../domain/ports/telegram-service';
import { config } from '../../../env';
import {
bucketRepository,
chunkedStorage,
@@ -1,6 +1,6 @@
import { createWriteStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { config } from '../../../config/index';
import { config } from '../../../env';
import { chunkedStorage, fileRepository, uploadBatcher } from '../../../infrastructure/di';
import type { PreparedUpload } from '../../../infrastructure/telegram/upload-batcher';
import logger from '../../../shared/logger/index';
@@ -1,6 +1,6 @@
import { createReadStream } from 'node:fs';
import { nanoid } from 'nanoid';
import { config } from '../../../config/index';
import { config } from '../../../env';
import { bucketRepository, chunkedStorage, fileRepository } from '../../../infrastructure/di';
import { db, files as fileSchema } from '../../../infrastructure/persistence/drizzle/index';
import { botPool } from '../../../infrastructure/telegram/bot-pool';
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { config } from '../../../config/index';
import { config } from '../../../env';
const ADMIN_USERNAME = 'admin';
const SIGNATURE_SEPARATOR = '.';
+1 -1
View File
@@ -1,4 +1,4 @@
import { config } from '../../../config/index';
import { config } from '../../../env';
import logger from '../../../shared/logger/index';
import { extractClientIp } from '../../../shared/utils/ip';
+1 -1
View File
@@ -1,4 +1,4 @@
import { config } from '../../../config/index';
import { config } from '../../../env';
import { handleSwaggerHtml, handleSwaggerJson } from '../../../routes/swagger';
import { isS3Request } from '../../s3/auth';
import { extractS3BucketFromHost } from '../../s3/virtual-host';
-73
View File
@@ -1,73 +0,0 @@
/**
* Base domain error class for all application-specific errors.
* Extends the built-in Error with a fixed name property for reliable
* instance checking across layers.
*/
export class DomainError extends Error {
constructor(msg: string) {
super(msg);
this.name = 'DomainError';
}
}
/**
* Thrown when a requested file cannot be found in storage.
*/
export class FileNotFoundError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'FileNotFoundError';
}
}
/**
* Thrown when a requested bucket does not exist.
*/
export class BucketNotFoundError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'BucketNotFoundError';
}
}
/**
* Thrown when a file exceeds the maximum allowed size for upload.
*/
export class FileTooLargeError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'FileTooLargeError';
}
}
/**
* Thrown when an attempt is made to upload a file that already exists
* (detected by content hash deduplication).
*/
export class DuplicateFileError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'DuplicateFileError';
}
}
/**
* Thrown when authentication fails or a valid session is not present.
*/
export class AuthenticationError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'AuthenticationError';
}
}
/**
* Thrown when input validation fails (e.g. missing required fields,
* invalid format, or constraint violations).
*/
export class ValidationError extends DomainError {
constructor(msg: string) {
super(msg);
this.name = 'ValidationError';
}
}
+32
View File
@@ -0,0 +1,32 @@
/** Compression algorithm for chunked file storage. */
export type CompressionAlgorithm = 'gzip' | null;
/**
* Optionally compress a chunk with gzip.
*
* Compression is skipped if:
* - The `compress` flag is false.
* - The chunk is smaller than `compressionMinSizeBytes`.
* - The compressed result is larger than the original.
*
* @param chunk - The raw chunk buffer.
* @param compress - Whether compression is enabled.
* @param compressionMinSizeBytes - Minimum chunk size to attempt compression.
* @returns The (possibly compressed) bytes and the algorithm used.
*/
export const maybeCompressChunk = (
chunk: Buffer,
compress: boolean,
compressionMinSizeBytes: number,
): { bytes: Buffer; compressionAlgorithm: CompressionAlgorithm } => {
if (!compress || chunk.byteLength < compressionMinSizeBytes) {
return { bytes: chunk, compressionAlgorithm: null };
}
const gzipped = Bun.gzipSync(chunk);
if (gzipped.byteLength >= chunk.byteLength) {
return { bytes: chunk, compressionAlgorithm: null };
}
return { bytes: gzipped, compressionAlgorithm: 'gzip' };
};
-135
View File
@@ -1,135 +0,0 @@
import logger from '../logger/index';
/** Configuration options for retry behaviour. */
interface RetryOptions {
/** Maximum number of retry attempts (default: 3). */
maxRetries?: number;
/** Delay before the first retry in milliseconds (default: 100). */
initialDelayMs?: number;
/** Maximum delay between retries in milliseconds (default: 5000). */
maxDelayMs?: number;
/** Multiplier for exponential backoff (default: 2). */
backoffMultiplier?: number;
/**
* Predicate that determines whether a given error should trigger a retry.
* When omitted, transient network / timeout errors are retried.
*/
shouldRetry?: (error: unknown) => boolean;
}
const DEFAULT_OPTIONS: Required<RetryOptions> = {
maxRetries: 3,
initialDelayMs: 100,
maxDelayMs: 5000,
backoffMultiplier: 2,
shouldRetry: (error: unknown) => {
const errorStr = error instanceof Error ? error.message : String(error);
// Retry on transient errors
return (
errorStr.includes('ECONNREFUSED') ||
errorStr.includes('ETIMEDOUT') ||
errorStr.includes('ENOTFOUND') ||
errorStr.includes('429') ||
errorStr.includes('timeout')
);
},
};
/**
* Executes an async function with exponential backoff retry logic.
*
* The function is retried up to `maxRetries` times. Between attempts the
* delay grows by `backoffMultiplier` (capped at `maxDelayMs`). Only errors
* for which `shouldRetry` returns `true` trigger a retry; all others are
* thrown immediately. When all retries are exhausted the last error is
* thrown.
*
* @param fn - The async function to execute.
* @param options - Optional retry configuration overrides.
* @returns The resolved value of `fn`.
*/
export const withRetry = async <T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> => {
const opts = { ...DEFAULT_OPTIONS, ...options };
let lastError: unknown;
let delay = opts.initialDelayMs;
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await fn();
} catch (error: unknown) {
lastError = error;
const errorStr = error instanceof Error ? error.message : String(error);
if (attempt === opts.maxRetries || !opts.shouldRetry(error)) {
logger.error('Retry exhausted', {
attempt,
maxRetries: opts.maxRetries,
error: errorStr,
});
throw error;
}
logger.warn('Retrying after error', {
attempt,
delay,
error: errorStr,
});
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
}
}
throw lastError;
};
/**
* Wraps an async function with a configurable timeout.
*
* If `fn` does not settle within `timeoutMs` milliseconds the returned
* promise rejects with a timeout error. The underlying `fn` continues
* executing but its result is ignored.
*
* @param fn - The async function to execute.
* @param timeoutMs - Timeout in milliseconds (default: 30000).
* @returns The resolved value of `fn`.
*/
export const withTimeout = async <T>(
fn: () => Promise<T>,
timeoutMs: number = 30000,
): Promise<T> => {
return Promise.race([
fn(),
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Operation timeout after ${timeoutMs}ms`)), timeoutMs),
),
]);
};
/**
* Executes a primary async function and falls back to a secondary function
* if the primary throws.
*
* The fallback function is called only when the primary rejects. If the
* fallback also throws the error propagates to the caller.
*
* @param primary - The primary async function to attempt first.
* @param fallback - The fallback async function invoked on failure.
* @returns The resolved value of `primary` or, on failure, of `fallback`.
*/
export const withFallback = async <T>(
primary: () => Promise<T>,
fallback: () => Promise<T>,
): Promise<T> => {
try {
return await primary();
} catch (error: unknown) {
logger.warn('Primary operation failed, using fallback', {
error: error instanceof Error ? error.message : String(error),
});
return fallback();
}
};