Files
WRNexusJS/packages/uploader/src/resumable.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

247 lines
8.2 KiB
TypeScript

import type { StorageDriver } from "./driver.ts";
import { accepts, contentTypeOf, extForType, extOf } from "./mime.ts";
import { UploadError, type UploadedFile } from "./upload.ts";
export interface ResumableUploadSession {
id: string;
key: string;
name: string;
type: string;
size: number;
chunkSize: number;
totalChunks: number;
createdAt: number;
expiresAt: number;
chunks: Record<number, Uint8Array>;
digests: Record<number, string>;
}
export interface ResumableSessionStore {
get(id: string): Promise<ResumableUploadSession | null>;
put(session: ResumableUploadSession): Promise<void>;
delete(id: string): Promise<void>;
list(): Promise<ResumableUploadSession[]>;
}
export function memoryResumableSessionStore(): ResumableSessionStore {
const sessions = new Map<string, ResumableUploadSession>();
return {
async get(id) {
return sessions.get(id) ?? null;
},
async put(session) {
sessions.set(session.id, session);
},
async delete(id) {
sessions.delete(id);
},
async list() {
return [...sessions.values()];
},
};
}
export interface ResumableUploadManagerOptions {
driver: StorageDriver;
sessions?: ResumableSessionStore;
maxBytes?: number;
chunkSize?: number;
maxSessions?: number;
ttlMs?: number;
accept?: string[];
prefix?: string;
publicUrl?: (key: string) => string | null;
now?: () => number;
}
export interface CreateResumableUpload {
name: string;
type?: string;
size: number;
chunkSize?: number;
}
export interface ResumableChunkResult {
receivedChunks: number;
totalChunks: number;
complete: boolean;
file?: UploadedFile;
}
export interface ResumableUploadManager {
create(input: CreateResumableUpload): Promise<ResumableUploadSession>;
uploadChunk(
id: string,
index: number,
data: Uint8Array,
sha256?: string,
): Promise<ResumableChunkResult>;
status(
id: string,
): Promise<{ received: number[]; totalChunks: number; expiresAt: number } | null>;
cancel(id: string): Promise<boolean>;
prune(): Promise<number>;
}
function safePrefix(prefix: string): string {
const value = prefix.replace(/^\/+|\/+$/g, "");
if (
value &&
(!/^[A-Za-z0-9._/-]+$/.test(value) ||
value.split("/").some((part) => !part || part === "." || part === ".."))
) {
throw new UploadError("unsafe upload prefix", 400);
}
return value;
}
async function digest(data: Uint8Array): Promise<string> {
return Array.from(
new Uint8Array(await crypto.subtle.digest("SHA-256", data as BufferSource)),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
}
export function createResumableUploadManager(
options: ResumableUploadManagerOptions,
): ResumableUploadManager {
const sessions = options.sessions ?? memoryResumableSessionStore();
const maxBytes = options.maxBytes ?? 100 * 1024 * 1024;
const defaultChunkSize = options.chunkSize ?? 1024 * 1024;
const maxSessions = options.maxSessions ?? 1000;
const ttlMs = options.ttlMs ?? 24 * 60 * 60_000;
if (!Number.isInteger(maxBytes) || maxBytes < 1)
throw new RangeError("resumable maxBytes must be positive");
if (!Number.isInteger(defaultChunkSize) || defaultChunkSize < 1)
throw new RangeError("resumable chunkSize must be positive");
if (!Number.isInteger(maxSessions) || maxSessions < 1)
throw new RangeError("resumable maxSessions must be positive");
if (!Number.isFinite(ttlMs) || ttlMs <= 0)
throw new RangeError("resumable ttlMs must be positive");
const prefix = safePrefix(options.prefix ?? "resumable");
const now = options.now ?? Date.now;
const finalizing = new Map<string, Promise<UploadedFile>>();
const manager: ResumableUploadManager = {
async create(input) {
await manager.prune();
if (!Number.isInteger(input.size) || input.size < 1 || input.size > maxBytes)
throw new UploadError(`invalid upload size (max ${maxBytes} bytes)`, 413);
const type = input.type || contentTypeOf(input.name);
if (!accepts(options.accept, { type, name: input.name }))
throw new UploadError(`file type not allowed: ${type}`, 415);
if ((await sessions.list()).length >= maxSessions)
throw new UploadError("too many active resumable uploads", 429);
const chunkSize = input.chunkSize ?? defaultChunkSize;
if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > maxBytes)
throw new UploadError("invalid resumable chunk size", 400);
const id = crypto.randomUUID();
const extension = extOf(input.name) || extForType(type);
const filename = extension ? `${id}.${extension}` : id;
const timestamp = now();
const session: ResumableUploadSession = {
id,
key: prefix ? `${prefix}/${filename}` : filename,
name: input.name.split(/[\\/]/).pop()?.slice(0, 255) || "file",
type,
size: input.size,
chunkSize,
totalChunks: Math.ceil(input.size / chunkSize),
createdAt: timestamp,
expiresAt: timestamp + ttlMs,
chunks: {},
digests: {},
};
await sessions.put(session);
return session;
},
async uploadChunk(id, index, data, expectedDigest) {
const session = await sessions.get(id);
if (!session || session.expiresAt <= now())
throw new UploadError("upload session not found or expired", 404);
if (!Number.isInteger(index) || index < 0 || index >= session.totalChunks)
throw new UploadError("invalid chunk index", 400);
const expectedSize =
index === session.totalChunks - 1
? session.size - session.chunkSize * (session.totalChunks - 1)
: session.chunkSize;
if (data.byteLength !== expectedSize)
throw new UploadError(`invalid chunk size (expected ${expectedSize})`, 400);
const actualDigest = await digest(data);
if (expectedDigest && expectedDigest.toLowerCase() !== actualDigest)
throw new UploadError("chunk checksum mismatch", 422);
if (session.digests[index] && session.digests[index] !== actualDigest)
throw new UploadError("chunk already uploaded with different content", 409);
session.chunks[index] ??= data.slice();
session.digests[index] = actualDigest;
await sessions.put(session);
const receivedChunks = Object.keys(session.chunks).length;
if (receivedChunks !== session.totalChunks) {
return { receivedChunks, totalChunks: session.totalChunks, complete: false };
}
let completion = finalizing.get(id);
if (!completion) {
completion = (async () => {
const output = new Uint8Array(session.size);
let offset = 0;
for (let chunk = 0; chunk < session.totalChunks; chunk++) {
const value = session.chunks[chunk];
if (!value) throw new UploadError("upload is missing a chunk", 409);
output.set(value, offset);
offset += value.byteLength;
}
await options.driver.put(session.key, output, {
contentType: session.type,
filename: session.name,
});
await sessions.delete(id);
return {
key: session.key,
url: options.publicUrl?.(session.key) ?? null,
name: session.name,
type: session.type,
size: session.size,
};
})().finally(() => finalizing.delete(id));
finalizing.set(id, completion);
}
return {
receivedChunks,
totalChunks: session.totalChunks,
complete: true,
file: await completion,
};
},
async status(id) {
const session = await sessions.get(id);
return session
? {
received: Object.keys(session.chunks)
.map(Number)
.sort((a, b) => a - b),
totalChunks: session.totalChunks,
expiresAt: session.expiresAt,
}
: null;
},
async cancel(id) {
if (!(await sessions.get(id))) return false;
await sessions.delete(id);
return true;
},
async prune() {
let removed = 0;
for (const session of await sessions.list()) {
if (session.expiresAt <= now()) {
await sessions.delete(session.id);
removed++;
}
}
return removed;
},
};
return manager;
}