feat: create shared utilities layer with JSDoc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,387 @@
|
|||||||
|
import { unlink } from 'node:fs/promises';
|
||||||
|
import logger from '../../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts an error message from an unknown value.
|
||||||
|
*
|
||||||
|
* @param error - The error value (caught exception, rejection reason, etc.).
|
||||||
|
* @returns The error message string.
|
||||||
|
*/
|
||||||
|
export const getErrorMessage = (error: unknown): string => {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously removes a temporary file from disk, logging a warning
|
||||||
|
* instead of throwing when the operation fails.
|
||||||
|
*
|
||||||
|
* @param tempPath - Absolute path to the temporary file.
|
||||||
|
*/
|
||||||
|
export const cleanupTempFile = async (tempPath: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await unlink(tempPath);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to cleanup temp file', { tempPath, error: getErrorMessage(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Metadata describing a stored file record. */
|
||||||
|
interface FileMetadata {
|
||||||
|
/** Public-facing unique identifier. */
|
||||||
|
publicId: string;
|
||||||
|
/** Telegram file identifier. */
|
||||||
|
telegramFileId: string;
|
||||||
|
/** Telegram file unique identifier (stable across chats). */
|
||||||
|
telegramFileUniqueId: string;
|
||||||
|
/** ID of the Telegram chat where the file is stored. */
|
||||||
|
storageChatId: number;
|
||||||
|
/** Message ID within the storage chat. */
|
||||||
|
storageMessageId: number;
|
||||||
|
/** Original file name. */
|
||||||
|
fileName: string;
|
||||||
|
/** MIME type of the file. */
|
||||||
|
mimeType: string;
|
||||||
|
/** File size in bytes. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** Telegram-inferred file type (document, photo, video, etc.). */
|
||||||
|
fileType: string;
|
||||||
|
/** Telegram user ID of the uploader. */
|
||||||
|
uploaderId: number;
|
||||||
|
/** Timestamp when the record was created. */
|
||||||
|
createdAt: Date | string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-file-type size limits in bytes. */
|
||||||
|
const FILE_TYPES: Record<string, number> = {
|
||||||
|
document: 2 * 1024 * 1024 * 1024, // 2GB
|
||||||
|
photo: 10 * 1024 * 1024, // 10MB
|
||||||
|
video: 2 * 1024 * 1024 * 1024, // 2GB
|
||||||
|
audio: 200 * 1024 * 1024, // 200MB
|
||||||
|
voice: 200 * 1024 * 1024, // 200MB
|
||||||
|
animation: 2 * 1024 * 1024 * 1024, // 2GB
|
||||||
|
sticker: 10 * 1024 * 1024, // 10MB
|
||||||
|
video_note: 2 * 1024 * 1024 * 1024, // 2GB
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the Telegram file type from a MIME type and optional caption.
|
||||||
|
*
|
||||||
|
* The returned string matches one of the keys in `FILE_TYPES` (document,
|
||||||
|
* photo, video, audio, voice, animation, sticker, video_note).
|
||||||
|
*
|
||||||
|
* @param mime - The MIME type string (may be null).
|
||||||
|
* @param caption - Optional caption text that may hint at the file type.
|
||||||
|
* @returns The inferred Telegram file type.
|
||||||
|
*/
|
||||||
|
export const getFileType = (mime: string | null, caption?: string): string => {
|
||||||
|
const mimeUpper = mime?.split('/')[0]?.toLowerCase();
|
||||||
|
const captionLower = caption?.toLowerCase();
|
||||||
|
|
||||||
|
if (mime?.toLowerCase() === 'image/webp' || captionLower?.includes('sticker')) return 'sticker';
|
||||||
|
if (captionLower?.includes('video_note')) return 'video_note';
|
||||||
|
if (mimeUpper === 'video') return 'video';
|
||||||
|
if (mimeUpper === 'audio') return 'audio';
|
||||||
|
if (mimeUpper === 'document') return 'document';
|
||||||
|
if (mimeUpper === 'image') return captionLower?.includes('gif') ? 'animation' : 'photo';
|
||||||
|
if (captionLower?.includes('voice')) return 'voice';
|
||||||
|
if (captionLower?.includes('animation')) return 'animation';
|
||||||
|
|
||||||
|
return mimeUpper === 'application' ? 'application' : 'document';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a file's size is within the allowed limit for its type.
|
||||||
|
*
|
||||||
|
* @param sizeBytes - File size in bytes.
|
||||||
|
* @param fileType - One of the recognised Telegram file type keys.
|
||||||
|
* @returns `true` if the file size is within bounds, `false` otherwise.
|
||||||
|
*/
|
||||||
|
export const checkFileSize = (sizeBytes: number, fileType: string): boolean => {
|
||||||
|
const limit = FILE_TYPES[fileType] || FILE_TYPES.document;
|
||||||
|
return sizeBytes <= limit;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures a file name has a proper extension based on its content.
|
||||||
|
*
|
||||||
|
* Magic-bytes (PDF, PNG, JPEG, GIF) are detected from the buffer first; if
|
||||||
|
* no magic matches, the optional detected MIME type is consulted.
|
||||||
|
*
|
||||||
|
* @param fileName - The original file name (may lack an extension).
|
||||||
|
* @param buffer - At least the first few bytes of file content.
|
||||||
|
* @param detectedMime - Optional MIME type from an external detector.
|
||||||
|
* @returns An object with the potentially-corrected file name and MIME type.
|
||||||
|
*/
|
||||||
|
export const ensureExtension = (
|
||||||
|
fileName: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
detectedMime?: string,
|
||||||
|
): { fileName: string; mimeType: string } => {
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
'application/pdf': 'pdf',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'text/plain': 'txt',
|
||||||
|
'application/zip': 'zip',
|
||||||
|
};
|
||||||
|
|
||||||
|
let ext: string | null = null;
|
||||||
|
if (buffer.subarray(0, 4).toString() === '%PDF') {
|
||||||
|
ext = 'pdf';
|
||||||
|
} else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
||||||
|
ext = 'png';
|
||||||
|
} else if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
ext = 'jpg';
|
||||||
|
} else if (buffer.subarray(0, 4).toString() === 'GIF8') {
|
||||||
|
ext = 'gif';
|
||||||
|
} else if (detectedMime) {
|
||||||
|
ext = mimeMap[detectedMime.toLowerCase()] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalFileName = fileName;
|
||||||
|
const hasExtension = fileName.includes('.') && fileName.split('.').pop()!.length >= 2;
|
||||||
|
if (!hasExtension && ext) {
|
||||||
|
finalFileName = `${fileName}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = ext
|
||||||
|
? Object.keys(mimeMap).find((k) => mimeMap[k] === ext) ||
|
||||||
|
detectedMime ||
|
||||||
|
'application/octet-stream'
|
||||||
|
: detectedMime || 'application/octet-stream';
|
||||||
|
|
||||||
|
return { fileName: finalFileName, mimeType };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Duck-typed object that may carry HTTP-like headers. */
|
||||||
|
type HeaderMapRequest = {
|
||||||
|
headers?:
|
||||||
|
| {
|
||||||
|
get?: (name: string) => string | null;
|
||||||
|
}
|
||||||
|
| Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Duck-typed Telegram file-like object. */
|
||||||
|
type FileLike = {
|
||||||
|
/** File name, if available. */
|
||||||
|
fileName?: string;
|
||||||
|
/** MIME type, if available. */
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Duck-typed Telegram message object that may contain file attachments. */
|
||||||
|
type MessageLike = {
|
||||||
|
document?: FileLike;
|
||||||
|
photo?: FileLike[];
|
||||||
|
audio?: FileLike;
|
||||||
|
voice?: FileLike;
|
||||||
|
animation?: FileLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely reads a header value from a request-like object, supporting both
|
||||||
|
* the Fetch API `Headers#get` interface and plain records.
|
||||||
|
*
|
||||||
|
* @param request - An object with an optional `headers` property.
|
||||||
|
* @param name - The header name (case-insensitive for `get()`).
|
||||||
|
* @returns The header value, or `undefined` if not present.
|
||||||
|
*/
|
||||||
|
const getHeader = (request: HeaderMapRequest | null, name: string): string | undefined => {
|
||||||
|
const headers = request?.headers;
|
||||||
|
if (!headers) return undefined;
|
||||||
|
|
||||||
|
const get = 'get' in headers ? headers.get : undefined;
|
||||||
|
if (typeof get === 'function') return get(name) || undefined;
|
||||||
|
|
||||||
|
return (headers as Record<string, string>)[name];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the file name from a Telegram message or an `x-file-name` request
|
||||||
|
* header.
|
||||||
|
*
|
||||||
|
* The request header takes precedence when present.
|
||||||
|
*
|
||||||
|
* @param msg - A duck-typed Telegram message object.
|
||||||
|
* @param request - An optional request-like object for header inspection.
|
||||||
|
* @returns The extracted file name, or `'file'` if none was found.
|
||||||
|
*/
|
||||||
|
export const extractFileName = (msg: MessageLike, request: HeaderMapRequest | null): string => {
|
||||||
|
const headerFileName = getHeader(request, 'x-file-name');
|
||||||
|
if (headerFileName) return headerFileName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
msg.document?.fileName ||
|
||||||
|
msg.photo?.slice(-1)[0]?.fileName ||
|
||||||
|
msg.audio?.fileName ||
|
||||||
|
msg.voice?.fileName ||
|
||||||
|
msg.animation?.fileName ||
|
||||||
|
'file'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the MIME type from a Telegram message or an `x-mime-type` request
|
||||||
|
* header.
|
||||||
|
*
|
||||||
|
* The request header takes precedence when present.
|
||||||
|
*
|
||||||
|
* @param msg - A duck-typed Telegram message object.
|
||||||
|
* @param request - An optional request-like object for header inspection.
|
||||||
|
* @returns The extracted MIME type, or `'application/octet-stream'` as
|
||||||
|
* fallback.
|
||||||
|
*/
|
||||||
|
export const extractMimeType = (msg: MessageLike, request: HeaderMapRequest | null): string => {
|
||||||
|
const headerMimeType = getHeader(request, 'x-mime-type');
|
||||||
|
if (headerMimeType) return headerMimeType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
msg.document?.mimeType ||
|
||||||
|
msg.photo?.slice(-1)[0]?.mimeType ||
|
||||||
|
msg.audio?.mimeType ||
|
||||||
|
msg.voice?.mimeType ||
|
||||||
|
msg.animation?.mimeType ||
|
||||||
|
'application/octet-stream'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the SHA-256 hex digest of a buffer.
|
||||||
|
*
|
||||||
|
* @param buffer - The input data.
|
||||||
|
* @returns The 64-character hex-encoded SHA-256 hash.
|
||||||
|
*/
|
||||||
|
export const computeHash = (buffer: Buffer): string => {
|
||||||
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
|
hasher.update(buffer);
|
||||||
|
return hasher.digest('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Telegram API file object (subset of the full file object). */
|
||||||
|
export interface TelegramMessageFile {
|
||||||
|
/** Unique file identifier. */
|
||||||
|
file_id: string;
|
||||||
|
/** Unique file identifier that is stable across different Telegram chats. */
|
||||||
|
file_unique_id: string;
|
||||||
|
/** File size in bytes, if available. */
|
||||||
|
file_size?: number;
|
||||||
|
/** MIME type, if available. */
|
||||||
|
mime_type?: string;
|
||||||
|
/** Original file name, if available. */
|
||||||
|
file_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Telegram API message object that may carry media attachments. */
|
||||||
|
export interface TelegramMediaMessage {
|
||||||
|
/** Message identifier within the chat. */
|
||||||
|
message_id: number;
|
||||||
|
document?: TelegramMessageFile;
|
||||||
|
photo?: TelegramMessageFile[];
|
||||||
|
video?: TelegramMessageFile;
|
||||||
|
audio?: TelegramMessageFile;
|
||||||
|
voice?: TelegramMessageFile;
|
||||||
|
animation?: TelegramMessageFile;
|
||||||
|
sticker?: TelegramMessageFile;
|
||||||
|
video_note?: TelegramMessageFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the relevant TelegramMessageFile from a media message based on the
|
||||||
|
* detected file type.
|
||||||
|
*
|
||||||
|
* For photos the last (largest) entry in the photo array is returned.
|
||||||
|
*
|
||||||
|
* @param msg - The Telegram media message.
|
||||||
|
* @param fileType - The detected file type (photo, document, video, etc.).
|
||||||
|
* @returns The matching file descriptor.
|
||||||
|
*/
|
||||||
|
export const extractFileFromMessage = (
|
||||||
|
msg: TelegramMediaMessage,
|
||||||
|
fileType: string,
|
||||||
|
): TelegramMessageFile => {
|
||||||
|
if (fileType === 'photo') return msg.photo?.slice(-1)[0] as TelegramMessageFile;
|
||||||
|
if (fileType === 'sticker') return msg.sticker as TelegramMessageFile;
|
||||||
|
return msg[fileType as keyof TelegramMediaMessage] as TelegramMessageFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the file type from a Telegram media message by inspecting which
|
||||||
|
* media fields are populated.
|
||||||
|
*
|
||||||
|
* The first populated field in the order document, photo, video, audio,
|
||||||
|
* voice, animation, sticker, video_note determines the type.
|
||||||
|
*
|
||||||
|
* @param msg - The Telegram media message.
|
||||||
|
* @returns The detected file type string.
|
||||||
|
*/
|
||||||
|
export const detectFileType = (msg: TelegramMediaMessage): string => {
|
||||||
|
if (msg.document) return 'document';
|
||||||
|
if (msg.photo) return 'photo';
|
||||||
|
if (msg.video) return 'video';
|
||||||
|
if (msg.audio) return 'audio';
|
||||||
|
if (msg.voice) return 'voice';
|
||||||
|
if (msg.animation) return 'animation';
|
||||||
|
if (msg.sticker) return 'sticker';
|
||||||
|
if (msg.video_note) return 'video_note';
|
||||||
|
return 'document';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the maximum allowed file size in bytes for the given file type.
|
||||||
|
*
|
||||||
|
* @param fileType - One of the recognised Telegram file type keys.
|
||||||
|
* @returns The size limit in bytes.
|
||||||
|
*/
|
||||||
|
export const getFileSizeLimit = (fileType: string): number =>
|
||||||
|
FILE_TYPES[fileType] || FILE_TYPES.document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a `createdAt` value into an ISO-8601 string.
|
||||||
|
*
|
||||||
|
* Accepts a Date instance, a date string, or a Unix timestamp (number).
|
||||||
|
*
|
||||||
|
* @param createdAt - The timestamp value to format.
|
||||||
|
* @returns The ISO-8601 string representation.
|
||||||
|
*/
|
||||||
|
export const formatCreatedAt = (createdAt: Date | string | number): string => {
|
||||||
|
return createdAt instanceof Date ? createdAt.toISOString() : new Date(createdAt).toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Public shape of a file in the upload API response. */
|
||||||
|
export interface UploadResponse {
|
||||||
|
/** Public unique identifier. */
|
||||||
|
public_id: string;
|
||||||
|
/** Original file name. */
|
||||||
|
file_name: string;
|
||||||
|
/** MIME type. */
|
||||||
|
mime_type: string;
|
||||||
|
/** File size in bytes. */
|
||||||
|
size_bytes: number;
|
||||||
|
/** Telegram file type. */
|
||||||
|
file_type: string;
|
||||||
|
/** ISO-8601 creation timestamp. */
|
||||||
|
created_at: string;
|
||||||
|
/** Public download URL. */
|
||||||
|
download_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an API response object from stored file metadata.
|
||||||
|
*
|
||||||
|
* @param file - The file metadata record.
|
||||||
|
* @param baseUrl - The server's base URL used to construct the download link.
|
||||||
|
* @returns A plain response object suitable for JSON serialisation.
|
||||||
|
*/
|
||||||
|
export const buildUploadResponse = (file: FileMetadata, baseUrl: string): UploadResponse => {
|
||||||
|
return {
|
||||||
|
public_id: file.publicId,
|
||||||
|
file_name: file.fileName,
|
||||||
|
mime_type: file.mimeType,
|
||||||
|
size_bytes: file.sizeBytes,
|
||||||
|
file_type: file.fileType,
|
||||||
|
created_at: formatCreatedAt(file.createdAt),
|
||||||
|
download_url: `${baseUrl}/f/${file.publicId}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { config } from '../../env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the client IP address from a Request object.
|
||||||
|
*
|
||||||
|
* When the server is behind a trusted proxy (config.trustProxy is true), this
|
||||||
|
* function respects the X-Forwarded-For and X-Real-IP headers. Otherwise it
|
||||||
|
* always returns 127.0.0.1.
|
||||||
|
*
|
||||||
|
* @param req - The incoming HTTP request.
|
||||||
|
* @returns The client IP address as a string.
|
||||||
|
*/
|
||||||
|
export const extractClientIp = (req: Request): string => {
|
||||||
|
if (!config.trustProxy) return '127.0.0.1';
|
||||||
|
|
||||||
|
const forwardedFor = req.headers.get('x-forwarded-for');
|
||||||
|
if (forwardedFor) {
|
||||||
|
const firstIp = forwardedFor.split(',')[0]?.trim();
|
||||||
|
if (firstIp) return firstIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = req.headers.get('x-real-ip')?.trim();
|
||||||
|
if (realIp) return realIp;
|
||||||
|
|
||||||
|
return '127.0.0.1';
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import logger from '../../utils/logger';
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { once } from 'node:events';
|
||||||
|
import { createReadStream, createWriteStream } from 'node:fs';
|
||||||
|
import { open, stat } from 'node:fs/promises';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import { finished } from 'node:stream/promises';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
/** Describes a single file to include in a new ZIP archive. */
|
||||||
|
export type ZipInputFile = {
|
||||||
|
/** Absolute path to the file on disk. */
|
||||||
|
tempPath: string;
|
||||||
|
/** Original file name (used to derive the ZIP entry name). */
|
||||||
|
fileName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Metadata for one entry in a created ZIP archive. */
|
||||||
|
export type ZipEntry = {
|
||||||
|
/** Original file name as passed to `ZipInputFile`. */
|
||||||
|
fileName: string;
|
||||||
|
/** Sanitised entry name within the archive. */
|
||||||
|
entryName: string;
|
||||||
|
/** CRC-32 checksum of the uncompressed data. */
|
||||||
|
crc32: number;
|
||||||
|
/** Size of the entry when compressed (stored size). */
|
||||||
|
compressedSize: number;
|
||||||
|
/** Size of the uncompressed data. */
|
||||||
|
uncompressedSize: number;
|
||||||
|
/** Byte offset of the local file header in the archive. */
|
||||||
|
localHeaderOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Result returned after creating a ZIP archive. */
|
||||||
|
export type CreatedZip = {
|
||||||
|
/** Absolute path to the temporary ZIP file on disk. */
|
||||||
|
tempPath: string;
|
||||||
|
/** Total size of the archive in bytes. */
|
||||||
|
sizeBytes: number;
|
||||||
|
/** SHA-256 hex digest of the entire archive content. */
|
||||||
|
fileHash: string;
|
||||||
|
/** Metadata for every entry in the archive. */
|
||||||
|
entries: ZipEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pre-computed CRC-32 lookup table (ISO 3309 / IEEE 802.3 polynomial). */
|
||||||
|
const CRC32_TABLE = new Uint32Array(256).map((_, index) => {
|
||||||
|
let value = index;
|
||||||
|
for (let bit = 0; bit < 8; bit++) {
|
||||||
|
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||||
|
}
|
||||||
|
return value >>> 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a running CRC-32 checksum with the bytes of a buffer.
|
||||||
|
*
|
||||||
|
* @param crc - Current CRC-32 value (typically starts at `0xFFFFFFFF`).
|
||||||
|
* @param chunk - Buffer of bytes to incorporate.
|
||||||
|
* @returns The updated CRC-32 value.
|
||||||
|
*/
|
||||||
|
const updateCrc32 = (crc: number, chunk: Buffer): number => {
|
||||||
|
let value = crc;
|
||||||
|
for (const byte of chunk) {
|
||||||
|
value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||||
|
}
|
||||||
|
return value >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a JavaScript Date into the MS-DOS date/time format used by ZIP
|
||||||
|
* local file headers.
|
||||||
|
*
|
||||||
|
* @param date - The date to convert (defaults to the current time).
|
||||||
|
* @returns An object with separate `time` and `date` bit-fields.
|
||||||
|
*/
|
||||||
|
const dosDateTime = (date = new Date()): { date: number; time: number } => {
|
||||||
|
const year = Math.max(date.getFullYear(), 1980);
|
||||||
|
return {
|
||||||
|
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
|
||||||
|
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a 16-bit unsigned integer as a little-endian buffer.
|
||||||
|
*
|
||||||
|
* @param value - The integer to write (only the lower 16 bits are used).
|
||||||
|
* @returns A 2-byte buffer.
|
||||||
|
*/
|
||||||
|
const writeUInt16 = (value: number): Buffer<ArrayBuffer> => {
|
||||||
|
const buffer = Buffer.allocUnsafe(2);
|
||||||
|
buffer.writeUInt16LE(value & 0xffff, 0);
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a 32-bit unsigned integer as a little-endian buffer.
|
||||||
|
*
|
||||||
|
* @param value - The integer to write (interpreted as unsigned).
|
||||||
|
* @returns A 4-byte buffer.
|
||||||
|
*/
|
||||||
|
const writeUInt32 = (value: number): Buffer<ArrayBuffer> => {
|
||||||
|
const buffer = Buffer.allocUnsafe(4);
|
||||||
|
buffer.writeUInt32LE(value >>> 0, 0);
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a chunk of data to a writable stream, waiting for the drain event
|
||||||
|
* if the internal buffer is full (back-pressure handling).
|
||||||
|
*
|
||||||
|
* @param writer - The writable stream (e.g. `createWriteStream` result).
|
||||||
|
* @param chunk - The buffer to write.
|
||||||
|
*/
|
||||||
|
const writeChunk = async (
|
||||||
|
writer: ReturnType<typeof createWriteStream>,
|
||||||
|
chunk: Buffer,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!writer.write(chunk)) {
|
||||||
|
await once(writer, 'drain');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finishes a writable stream and waits for it to close.
|
||||||
|
*
|
||||||
|
* @param writer - The writable stream to end.
|
||||||
|
*/
|
||||||
|
const finishWriter = async (writer: ReturnType<typeof createWriteStream>): Promise<void> => {
|
||||||
|
writer.end();
|
||||||
|
await finished(writer);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitises a file name for use as a ZIP entry name.
|
||||||
|
*
|
||||||
|
* Strips directory components, replaces path separators with underscores,
|
||||||
|
* collapses consecutive dots, and ensures uniqueness against the supplied
|
||||||
|
* set of already-used names by appending a numeric suffix when necessary.
|
||||||
|
*
|
||||||
|
* @param fileName - The raw file name to sanitise.
|
||||||
|
* @param usedNames - A set of entry names already claimed; may be mutated.
|
||||||
|
* @returns A unique, safe ZIP entry name.
|
||||||
|
*/
|
||||||
|
export const sanitizeZipEntryName = (fileName: string, usedNames = new Set<string>()): string => {
|
||||||
|
const cleaned = basename(fileName)
|
||||||
|
.replace(/[\\/]+/g, '_')
|
||||||
|
.replace(/\.\.+/g, '.')
|
||||||
|
.trim();
|
||||||
|
const fallback = cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : 'file';
|
||||||
|
const dotIndex = fallback.lastIndexOf('.');
|
||||||
|
const baseName = dotIndex > 0 ? fallback.slice(0, dotIndex) : fallback;
|
||||||
|
const extension = dotIndex > 0 ? fallback.slice(dotIndex) : '';
|
||||||
|
let candidate = fallback;
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
while (usedNames.has(candidate)) {
|
||||||
|
candidate = `${baseName}-${counter}${extension}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
usedNames.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the CRC-32 checksum of a file on disk by streaming its
|
||||||
|
* contents through the lookup-table algorithm.
|
||||||
|
*
|
||||||
|
* @param tempPath - Absolute path to the file.
|
||||||
|
* @returns The CRC-32 value as an unsigned 32-bit integer.
|
||||||
|
*/
|
||||||
|
const calculateFileCrc32 = async (tempPath: string): Promise<number> => {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
const reader = createReadStream(tempPath);
|
||||||
|
for await (const chunk of reader) {
|
||||||
|
crc = updateCrc32(crc, chunk as Buffer);
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ZIP archive (stored-only, no compression) from a list of input
|
||||||
|
* files and writes it to a temporary path.
|
||||||
|
*
|
||||||
|
* The archive uses the standard ZIP format with local file headers, a
|
||||||
|
* central directory, and an end-of-central-directory record. Each entry is
|
||||||
|
* stored uncompressed (method 0). The entire archive is SHA-256 hashed
|
||||||
|
* during writing.
|
||||||
|
*
|
||||||
|
* @param files - Array of file descriptors to include in the archive.
|
||||||
|
* @returns Metadata describing the created archive.
|
||||||
|
*/
|
||||||
|
export const createZip = async (files: ZipInputFile[]): Promise<CreatedZip> => {
|
||||||
|
const tempPath = `/tmp/filedrop-${nanoid()}.zip`;
|
||||||
|
const writer = createWriteStream(tempPath);
|
||||||
|
const hasher = new Bun.CryptoHasher('sha256');
|
||||||
|
const entries: ZipEntry[] = [];
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
const writeHashed = async (chunk: Buffer): Promise<void> => {
|
||||||
|
hasher.update(chunk);
|
||||||
|
await writeChunk(writer, chunk);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const entryName = sanitizeZipEntryName(file.fileName, usedNames);
|
||||||
|
const nameBuffer = Buffer.from(entryName);
|
||||||
|
const fileStats = await stat(file.tempPath);
|
||||||
|
const { date, time } = dosDateTime();
|
||||||
|
const localHeaderOffset = offset;
|
||||||
|
const crc32 = await calculateFileCrc32(file.tempPath);
|
||||||
|
|
||||||
|
const localHeader = Buffer.concat([
|
||||||
|
writeUInt32(0x04034b50),
|
||||||
|
writeUInt16(20),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(time),
|
||||||
|
writeUInt16(date),
|
||||||
|
writeUInt32(crc32),
|
||||||
|
writeUInt32(fileStats.size),
|
||||||
|
writeUInt32(fileStats.size),
|
||||||
|
writeUInt16(nameBuffer.byteLength),
|
||||||
|
writeUInt16(0),
|
||||||
|
nameBuffer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await writeHashed(localHeader);
|
||||||
|
const reader = createReadStream(file.tempPath);
|
||||||
|
for await (const chunk of reader) {
|
||||||
|
await writeHashed(chunk as Buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
fileName: file.fileName,
|
||||||
|
entryName,
|
||||||
|
crc32,
|
||||||
|
compressedSize: fileStats.size,
|
||||||
|
uncompressedSize: fileStats.size,
|
||||||
|
localHeaderOffset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralDirectoryOffset = offset;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const nameBuffer = Buffer.from(entry.entryName);
|
||||||
|
const { date, time } = dosDateTime();
|
||||||
|
await writeHashed(
|
||||||
|
Buffer.concat([
|
||||||
|
writeUInt32(0x02014b50),
|
||||||
|
writeUInt16(20),
|
||||||
|
writeUInt16(20),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(time),
|
||||||
|
writeUInt16(date),
|
||||||
|
writeUInt32(entry.crc32),
|
||||||
|
writeUInt32(entry.compressedSize),
|
||||||
|
writeUInt32(entry.uncompressedSize),
|
||||||
|
writeUInt16(nameBuffer.byteLength),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt32(0),
|
||||||
|
writeUInt32(entry.localHeaderOffset),
|
||||||
|
nameBuffer,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralDirectorySize = offset - centralDirectoryOffset;
|
||||||
|
await writeHashed(
|
||||||
|
Buffer.concat([
|
||||||
|
writeUInt32(0x06054b50),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(0),
|
||||||
|
writeUInt16(entries.length),
|
||||||
|
writeUInt16(entries.length),
|
||||||
|
writeUInt32(centralDirectorySize),
|
||||||
|
writeUInt32(centralDirectoryOffset),
|
||||||
|
writeUInt16(0),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await finishWriter(writer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tempPath,
|
||||||
|
sizeBytes: offset,
|
||||||
|
fileHash: hasher.digest('hex'),
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
writer.destroy();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a single entry from an in-memory ZIP buffer.
|
||||||
|
*
|
||||||
|
* Only stored (uncompressed) entries are supported — entries compressed
|
||||||
|
* with any method return `null`.
|
||||||
|
*
|
||||||
|
* @param zipBuffer - The full ZIP archive as a buffer.
|
||||||
|
* @param entryName - The exact entry name to extract.
|
||||||
|
* @returns The entry's data as a buffer, or `null` if not found or
|
||||||
|
* compressed.
|
||||||
|
*/
|
||||||
|
export const extractZipEntry = async (
|
||||||
|
zipBuffer: Buffer,
|
||||||
|
entryName: string,
|
||||||
|
): Promise<Buffer | null> => {
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset + 30 <= zipBuffer.byteLength) {
|
||||||
|
const signature = zipBuffer.readUInt32LE(offset);
|
||||||
|
if (signature !== 0x04034b50) break;
|
||||||
|
|
||||||
|
const compressionMethod = zipBuffer.readUInt16LE(offset + 8);
|
||||||
|
const compressedSize = zipBuffer.readUInt32LE(offset + 18);
|
||||||
|
const fileNameLength = zipBuffer.readUInt16LE(offset + 26);
|
||||||
|
const extraLength = zipBuffer.readUInt16LE(offset + 28);
|
||||||
|
const nameStart = offset + 30;
|
||||||
|
const nameEnd = nameStart + fileNameLength;
|
||||||
|
const dataStart = nameEnd + extraLength;
|
||||||
|
const dataEnd = dataStart + compressedSize;
|
||||||
|
const currentName = zipBuffer.subarray(nameStart, nameEnd).toString();
|
||||||
|
|
||||||
|
if (currentName === entryName) {
|
||||||
|
if (compressionMethod !== 0) return null;
|
||||||
|
return zipBuffer.subarray(dataStart, dataEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = dataEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Byte-range location of a stored entry within a ZIP archive on disk. */
|
||||||
|
export type LocatedZipEntry = {
|
||||||
|
/** Byte offset where the entry data begins. */
|
||||||
|
start: number;
|
||||||
|
/** Length of the entry data in bytes. */
|
||||||
|
length: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locates a stored (uncompressed) entry within a ZIP archive on disk
|
||||||
|
* without reading the entire file into memory.
|
||||||
|
*
|
||||||
|
* Scans local file headers sequentially until the matching entry is found
|
||||||
|
* or the end of valid headers is reached.
|
||||||
|
*
|
||||||
|
* @param zipPath - Absolute path to the ZIP file on disk.
|
||||||
|
* @param entryName - The exact entry name to locate.
|
||||||
|
* @returns The byte range of the entry, or `null` if not found or
|
||||||
|
* compressed.
|
||||||
|
*/
|
||||||
|
export const locateZipEntry = async (
|
||||||
|
zipPath: string,
|
||||||
|
entryName: string,
|
||||||
|
): Promise<LocatedZipEntry | null> => {
|
||||||
|
const handle = await open(zipPath, 'r');
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const header = Buffer.alloc(30);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { bytesRead } = await handle.read(header, 0, header.byteLength, offset);
|
||||||
|
if (bytesRead < header.byteLength) return null;
|
||||||
|
|
||||||
|
const signature = header.readUInt32LE(0);
|
||||||
|
if (signature !== 0x04034b50) return null;
|
||||||
|
|
||||||
|
const compressionMethod = header.readUInt16LE(8);
|
||||||
|
const compressedSize = header.readUInt32LE(18);
|
||||||
|
const fileNameLength = header.readUInt16LE(26);
|
||||||
|
const extraLength = header.readUInt16LE(28);
|
||||||
|
const nameBuffer = Buffer.alloc(fileNameLength);
|
||||||
|
const nameOffset = offset + 30;
|
||||||
|
await handle.read(nameBuffer, 0, fileNameLength, nameOffset);
|
||||||
|
|
||||||
|
const dataStart = nameOffset + fileNameLength + extraLength;
|
||||||
|
if (nameBuffer.toString() === entryName) {
|
||||||
|
if (compressionMethod !== 0) return null;
|
||||||
|
return { start: dataStart, length: compressedSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = dataStart + compressedSize;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user