release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user