feat: create shared utilities layer with JSDoc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-28 17:43:18 +07:00
parent 44e887c7cd
commit e7657453d5
4 changed files with 949 additions and 0 deletions
+387
View File
@@ -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}`,
};
};
+26
View File
@@ -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';
};
+135
View File
@@ -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();
}
};
+401
View File
@@ -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();
}
};