파일을 다운로드할 때 전체 파일을 메모리에 올리는 대신 서비스 워커에서 스트리밍으로 복호화하도록 변경

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 00:48:21 +09:00
1 parent 877cf9c42e
commit 3e52fa7a7c
29 files changed
+629 -467

No files matched your search

+30 -30
View File
@@ -1,50 +1,50 @@
import {
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
storeFileCacheIndex,
deleteFileCacheIndex,
type FileCacheIndex,
} from "#lib/client/storage/indexedDB/index.js";
import { readFile, writeFile, deleteFile } from "#lib/client/storage/opfs.js";
getFileCacheIndex as getAllFileCacheIndexes,
getFileCacheIndexOf,
storeFileCacheIndex,
updateFileCacheRetrievedAt,
} from "#lib/client/storage/indexedDB/cacheIndex.js";
import { createWritable, deleteFile, getFile } from "#lib/client/storage/opfs.js";
const fileCacheIndex = new Map<number, FileCacheIndex>();
// Used by both the pages and the service worker, so the index is always read from IndexedDB
export const prepareFileCache = async () => {
for (const cache of await getFileCacheIndexFromIndexedDB()) {
fileCacheIndex.set(cache.fileId, cache);
}
};
const getPath = (fileId: number) => `/cache/${fileId}`;
export const getFileCacheIndex = () => {
return Array.from(fileCacheIndex.values());
export const getFileCacheIndex = async () => {
return await getAllFileCacheIndexes();
};
// The index is written only after the whole content is written
export const getFileCache = async (fileId: number) => {
const cacheIndex = fileCacheIndex.get(fileId);
if (!cacheIndex) return null;
if (!(await getFileCacheIndexOf(fileId))) return null;
cacheIndex.lastRetrievedAt = new Date();
storeFileCacheIndex(cacheIndex); // Intended
return await readFile(`/cache/${fileId}`);
const file = await getFile(getPath(fileId));
if (file) {
void updateFileCacheRetrievedAt(fileId, new Date());
}
return file;
};
export const storeFileCache = async (fileId: number, fileBuffer: ArrayBuffer) => {
const now = new Date();
await writeFile(`/cache/${fileId}`, fileBuffer);
export const storeFileCache = async (fileId: number, stream: ReadableStream<Uint8Array>) => {
const path = getPath(fileId);
try {
await stream.pipeTo(await createWritable(path));
} catch (e) {
await deleteFile(path).catch(() => {});
throw e;
}
const cacheIndex: FileCacheIndex = {
const now = new Date();
await storeFileCacheIndex({
fileId,
cachedAt: now,
lastRetrievedAt: now,
size: fileBuffer.byteLength,
};
fileCacheIndex.set(fileId, cacheIndex);
await storeFileCacheIndex(cacheIndex);
size: (await getFile(path))?.size ?? 0,
});
};
export const deleteFileCache = async (fileId: number) => {
if (!fileCacheIndex.has(fileId)) return;
fileCacheIndex.delete(fileId);
await deleteFile(`/cache/${fileId}`);
await deleteFileCacheIndex(fileId);
await deleteFile(getPath(fileId));
};
+20
View File
@@ -25,3 +25,23 @@ export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
return new Date(utcDate - offsetMs);
};
const EXIF_SEARCH_SIZE = 1024 * 1024; // 1 MiB
const MAX_FULL_READ_SIZE = 64 * 1024 * 1024; // 64 MiB
const tryExtractExifDateTime = (buffer: ArrayBuffer) => {
try {
return extractExifDateTime(buffer);
} catch {
return undefined;
}
};
// EXIF is usually at the beginning of the file, so the whole file is read only if not found
export const readExifDateTime = async (file: Blob) => {
const dateTime = tryExtractExifDateTime(await file.slice(0, EXIF_SEARCH_SIZE).arrayBuffer());
if (dateTime || file.size <= EXIF_SEARCH_SIZE || file.size > MAX_FULL_READ_SIZE) {
return dateTime;
}
return tryExtractExifDateTime(await file.arrayBuffer());
};
+24 -17
View File
@@ -82,35 +82,42 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
});
};
export const generateThumbnail = async (blob: Blob) => {
let url;
const convertHeicToPng = async (blob: Blob) => {
const { default: heic2any } = await import("heic2any");
return (await heic2any({ blob, toType: "image/png" })) as Blob;
};
// Generates a thumbnail of the image or video at the URL. Returns `null` if not supported.
export const generateThumbnailFromUrl = async (url: string, contentType: string) => {
try {
if (blob.type.startsWith("image/")) {
url = URL.createObjectURL(blob);
if (contentType.startsWith("image/")) {
try {
return await generateImageThumbnail(url);
} catch {
URL.revokeObjectURL(url);
url = undefined;
if (contentType !== "image/heic") return null;
if (blob.type === "image/heic") {
const { default: heic2any } = await import("heic2any");
url = URL.createObjectURL((await heic2any({ blob, toType: "image/png" })) as Blob);
return await generateImageThumbnail(url);
} else {
return null;
// Chromium can't decode HEIC images
const png = URL.createObjectURL(await convertHeicToPng(await (await fetch(url)).blob()));
try {
return await generateImageThumbnail(png);
} finally {
URL.revokeObjectURL(png);
}
}
} else if (blob.type.startsWith("video/")) {
url = URL.createObjectURL(blob);
} else if (contentType.startsWith("video/")) {
return await generateVideoThumbnail(url);
}
return null;
} catch {
return null;
}
};
export const generateThumbnail = async (blob: Blob) => {
const url = URL.createObjectURL(blob);
try {
return await generateThumbnailFromUrl(url, blob.type);
} finally {
if (url) {
URL.revokeObjectURL(url);
}
URL.revokeObjectURL(url);
}
};
+25 -12
View File
@@ -1,8 +1,9 @@
import { DECRYPTED_FILE_URL_PREFIX } from "#lib/shared/serviceWorker.js";
import type {
FileMetadata,
ServiceWorkerMessage,
ServiceWorkerResponse,
import { handleDownloadProgress } from "#lib/client/transfer/download.svelte.js";
import {
DECRYPTED_FILE_URL_PREFIX,
type FileMetadata,
type ServiceWorkerMessage,
type ServiceWorkerResponse,
} from "#lib/shared/serviceWorker.js";
const PREPARE_TIMEOUT_MS = 5000;
@@ -10,17 +11,22 @@ const PREPARE_TIMEOUT_MS = 5000;
// The service worker may be terminated at any time, losing its in-memory metadata.
// Keep a copy here so that it can be re-requested on demand.
const preparedFiles = new Map<number, FileMetadata>();
let isListeningForMetadataRequests = false;
let isListening = false;
export const listenForMetadataRequests = () => {
if (isListeningForMetadataRequests) return;
isListeningForMetadataRequests = true;
export const listenForServiceWorkerMessages = () => {
if (isListening) return;
isListening = true;
navigator.serviceWorker.addEventListener(
"message",
(event: MessageEvent<ServiceWorkerResponse>) => {
if (event.data.type === "decryption-metadata-request") {
event.ports[0]?.postMessage(preparedFiles.get(event.data.fileId) ?? null);
switch (event.data.type) {
case "decryption-metadata-request":
event.ports[0]?.postMessage(preparedFiles.get(event.data.fileId) ?? null);
break;
case "download-progress":
handleDownloadProgress(event.data);
break;
}
},
);
@@ -37,7 +43,7 @@ const getServiceWorker = async () => {
export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => {
preparedFiles.set(id, metadata);
listenForMetadataRequests();
listenForServiceWorkerMessages();
const sw = await getServiceWorker();
return new Promise<void>((resolve, reject) => {
@@ -63,3 +69,10 @@ export const prepareFileDecryption = async (id: number, metadata: FileMetadata)
};
export const getDecryptedFileUrl = (id: number) => `${DECRYPTED_FILE_URL_PREFIX}${id}`;
// Lets the service worker download, decrypt, and save the file
export const saveDecryptedFile = (id: number, filename: string) => {
const link = document.createElement("a");
link.href = `${getDecryptedFileUrl(id)}?download=${encodeURIComponent(filename)}`;
link.click();
};
@@ -28,6 +28,14 @@ export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
});
};
export const getFileCacheIndexOf = async (fileId: number) => {
return await cacheIndex.fileCache.get(fileId);
};
export const updateFileCacheRetrievedAt = async (fileId: number, lastRetrievedAt: Date) => {
await cacheIndex.fileCache.update(fileId, { lastRetrievedAt });
};
export const deleteFileCacheIndex = async (fileId: number) => {
await cacheIndex.fileCache.delete(fileId);
};
+6
View File
@@ -48,6 +48,12 @@ export const writeFile = async (path: string, data: ArrayBuffer) => {
}
};
// Changes are applied atomically when the stream is closed, and discarded if it is aborted
export const createWritable = async (path: string) => {
const { fileHandle } = await getFileHandle(path);
return await fileHandle!.createWritable();
};
export const deleteFile = async (path: string) => {
const { parentHandle, filename } = await getFileHandle(path, false);
if (!parentHandle) return;
+1 -53
View File
@@ -4,6 +4,7 @@ import pLimit from "p-limit";
import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.js";
import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.js";
import { BoundedQueue } from "#lib/utils/concurrency/index.js";
import { createSpeedMeter } from "#lib/utils/speedMeter.js";
interface UploadStats {
progress: number;
@@ -15,59 +16,6 @@ interface EncryptedChunk {
data: ArrayBuffer;
}
const createSpeedMeter = (timeWindow = 3000, minInterval = 200, warmupPeriod = 500) => {
const samples: { t: number; b: number }[] = [];
let lastSpeed = 0;
let startTime: number | null = null;
return (bytesNow?: number) => {
if (bytesNow === undefined) return lastSpeed;
const now = performance.now();
// Initialize start time on first call
if (startTime === null) {
startTime = now;
}
// Check if enough time has passed since the last sample
const lastSample = samples[samples.length - 1];
if (lastSample && now - lastSample.t < minInterval) {
return lastSpeed;
}
samples.push({ t: now, b: bytesNow });
// Remove old samples outside the time window
const cutoff = now - timeWindow;
while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift();
// Need at least 2 samples to calculate speed
if (samples.length < 2) {
return lastSpeed;
}
const first = samples[0]!;
const dt = now - first.t;
const db = bytesNow - first.b;
if (dt >= minInterval) {
const instantSpeed = (db / dt) * 1000;
// Apply EMA for smoother speed transitions
const alpha = 0.3;
const rawSpeed =
lastSpeed === 0 ? instantSpeed : alpha * instantSpeed + (1 - alpha) * lastSpeed;
// Apply warmup ramp to prevent initial overestimation
const elapsed = now - startTime;
const warmupWeight = Math.min(1, elapsed / warmupPeriod);
lastSpeed = rawSpeed * warmupWeight;
}
return lastSpeed;
};
};
const encryptChunkData = async (chunk: Blob, dataKey: CryptoKey): Promise<ArrayBuffer> => {
return await encryptChunk(await chunk.arrayBuffer(), dataKey);
};
+30 -85
View File
@@ -1,34 +1,21 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { decryptChunk, concatenateBuffers } from "#lib/shared/crypto/index.js";
import { ENCRYPTED_CHUNK_SIZE } from "#lib/shared/format/index.js";
import type { DownloadProgressMessage } from "#lib/shared/serviceWorker.js";
import { createSpeedMeter } from "#lib/utils/speedMeter.js";
// Files are downloaded and decrypted by the service worker, which reports the progress
export interface FileDownloadState {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
status: "downloading" | "completed" | "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
type LiveFileDownloadState = FileDownloadState & {
status: "download-pending" | "downloading" | "decryption-pending" | "decrypting";
};
let downloadingFiles: FileDownloadState[] = $state([]);
const speedMeters = new WeakMap<FileDownloadState, ReturnType<typeof createSpeedMeter>>();
export const isFileDownloading = (
status: FileDownloadState["status"],
): status is LiveFileDownloadState["status"] =>
["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
export const isFileDownloading = (status: FileDownloadState["status"]) => {
return status === "downloading";
};
export const getFileDownloadState = (fileId: number) => {
return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
@@ -42,69 +29,27 @@ export const clearDownloadedFiles = () => {
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
};
const requestFileDownload = limitFunction(
async (state: FileDownloadState, id: number) => {
state.status = "downloading";
export const handleDownloadProgress = ({
fileId,
status,
loaded,
total,
}: DownloadProgressMessage) => {
let state = getFileDownloadState(fileId);
if (!state) {
if (status !== "downloading") return;
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
state.progress = progress;
state.rate = rate;
state.estimated = estimated;
},
});
const fileEncrypted: ArrayBuffer = res.data;
state.status = "decryption-pending";
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
state: FileDownloadState,
fileEncrypted: ArrayBuffer,
encryptedChunkSize: number,
dataKey: CryptoKey,
) => {
state.status = "decrypting";
const chunks: ArrayBuffer[] = [];
let offset = 0;
while (offset < fileEncrypted.byteLength) {
const nextOffset = Math.min(offset + encryptedChunkSize, fileEncrypted.byteLength);
chunks.push(await decryptChunk(fileEncrypted.slice(offset, nextOffset), dataKey));
offset = nextOffset;
}
const fileBuffer = concatenateBuffers(...chunks).buffer;
state.status = "decrypted";
state.result = fileBuffer;
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, dataKey: CryptoKey, isLegacy: boolean) => {
downloadingFiles.push({
id,
status: "download-pending",
});
const state = downloadingFiles.at(-1)!;
try {
const fileEncrypted = await requestFileDownload(state, id);
return await decryptFile(
state,
fileEncrypted,
isLegacy ? fileEncrypted.byteLength : ENCRYPTED_CHUNK_SIZE,
dataKey,
);
} catch (e) {
state.status = "error";
throw e;
downloadingFiles.push({ id: fileId, status });
state = downloadingFiles.at(-1)!;
speedMeters.set(state, createSpeedMeter());
}
if (status === "canceled") {
downloadingFiles = downloadingFiles.filter((file) => file !== state);
return;
}
state.status = status;
state.progress = total > 0 ? loaded / total : 1;
state.rate = speedMeters.get(state)?.(loaded);
};
+4 -8
View File
@@ -6,7 +6,7 @@ import {
type HmacSecret,
type MasterKey,
} from "#lib/client/keys/index.js";
import { extractExifDateTime } from "#lib/client/media/exif.js";
import { readExifDateTime } from "#lib/client/media/exif.js";
import { generateThumbnail } from "#lib/client/media/thumbnail.js";
import { uploadBlob } from "#lib/client/transfer/chunkedUpload.js";
import {
@@ -41,9 +41,7 @@ export type LiveFileUploadState = FileUploadState & {
status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
};
const scheduler = new Scheduler<
{ fileId: number; fileBuffer?: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
>();
const scheduler = new Scheduler<{ fileId: number; thumbnailBuffer?: ArrayBuffer } | undefined>();
let uploadingFiles: FileUploadState[] = $state([]);
const isFileUploading = (status: FileUploadState["status"]) =>
@@ -213,7 +211,6 @@ export const uploadFile = async (
return;
}
let fileBuffer;
const fileType = getFileType(file);
const fileMetadata: FileMetadata = {
parentId,
@@ -222,8 +219,7 @@ export const uploadFile = async (
};
if (fileType.startsWith("image/")) {
fileBuffer = await file.arrayBuffer();
fileMetadata.createdAt = extractExifDateTime(fileBuffer);
fileMetadata.createdAt = await readExifDateTime(file);
}
const blob = new Blob([file], { type: fileType });
@@ -241,7 +237,7 @@ export const uploadFile = async (
dataKeyVersion,
);
return { fileId, fileBuffer, thumbnailBuffer };
return { fileId, thumbnailBuffer };
} catch (e) {
state.status = "error";
throw e;