2026-07-08 20:55:28 +02:00
|
|
|
/**
|
|
|
|
|
* `PdfByteStore` — in-memory store for uploaded PDF bytes, keyed by
|
|
|
|
|
* `DocumentId`.
|
|
|
|
|
*
|
|
|
|
|
* BROWSER SURFACE — not part of the headless ingest core. This mints and
|
|
|
|
|
* revokes `blob:` URLs and is meant to back an interactive upload/viewer
|
|
|
|
|
* session. Headless callers should use `src/pdf/` directly and never import
|
|
|
|
|
* this module.
|
|
|
|
|
*
|
|
|
|
|
* Uploaded PDFs are stored in memory only. Bytes survive within a tab
|
|
|
|
|
* session; reloading the page loses them unless the session was exported.
|
|
|
|
|
*
|
|
|
|
|
* One store instance per active session. The session-management layer is
|
|
|
|
|
* responsible for swapping the active store when the user switches sessions.
|
|
|
|
|
* The store also owns a small registry of issued `blob:` URLs so it can
|
|
|
|
|
* revoke them on delete/clear — no cross-cutting cleanup at the app layer.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { DocumentId } from "@citation-evidence/engine/shared";
|
|
|
|
|
|
|
|
|
|
export interface PdfByteRecord {
|
|
|
|
|
readonly bytes: Uint8Array;
|
|
|
|
|
/** A `blob:` URL the viewer can consume directly. */
|
|
|
|
|
readonly blobUrl: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PdfByteStore {
|
2026-07-09 09:58:41 +02:00
|
|
|
put(
|
|
|
|
|
documentId: DocumentId,
|
|
|
|
|
bytes: Uint8Array,
|
|
|
|
|
contentType?: string,
|
|
|
|
|
): PdfByteRecord;
|
2026-07-08 20:55:28 +02:00
|
|
|
get(documentId: DocumentId): PdfByteRecord | null;
|
|
|
|
|
has(documentId: DocumentId): boolean;
|
|
|
|
|
delete(documentId: DocumentId): boolean;
|
|
|
|
|
list(): readonly DocumentId[];
|
|
|
|
|
/** Revoke every blob URL and clear the store. */
|
|
|
|
|
clear(): void;
|
|
|
|
|
/** Total bytes currently held — useful for UI dashboards. */
|
|
|
|
|
size(): number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CreatePdfByteStoreOptions {
|
|
|
|
|
/**
|
|
|
|
|
* Mint a URL for the given bytes. Defaults to `URL.createObjectURL` in
|
|
|
|
|
* environments that have it; tests can inject a deterministic stub.
|
|
|
|
|
*/
|
|
|
|
|
readonly createObjectURL?: (blob: Blob) => string;
|
|
|
|
|
/** Revoke a URL previously minted by `createObjectURL`. */
|
|
|
|
|
readonly revokeObjectURL?: (url: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createPdfByteStore(
|
|
|
|
|
options: CreatePdfByteStoreOptions = {},
|
|
|
|
|
): PdfByteStore {
|
|
|
|
|
const createUrl =
|
|
|
|
|
options.createObjectURL ??
|
|
|
|
|
((blob: Blob) => {
|
|
|
|
|
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
|
|
|
|
|
throw new Error(
|
|
|
|
|
"createPdfByteStore: URL.createObjectURL not available — inject a stub via options",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return URL.createObjectURL(blob);
|
|
|
|
|
});
|
|
|
|
|
const revokeUrl =
|
|
|
|
|
options.revokeObjectURL ??
|
|
|
|
|
((url: string) => {
|
|
|
|
|
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const records = new Map<DocumentId, PdfByteRecord>();
|
|
|
|
|
|
|
|
|
|
return {
|
2026-07-09 09:58:41 +02:00
|
|
|
put(documentId, bytes, contentType = "application/pdf") {
|
2026-07-08 20:55:28 +02:00
|
|
|
// Replace previous record (revoking the prior URL) if any.
|
|
|
|
|
const prior = records.get(documentId);
|
|
|
|
|
if (prior) revokeUrl(prior.blobUrl);
|
|
|
|
|
// Cast: Blob() does accept Uint8Array at runtime, but TS narrows the
|
|
|
|
|
// buffer type to ArrayBufferLike (could be SharedArrayBuffer) and
|
|
|
|
|
// refuses without help. The bytes here always come from a fresh
|
|
|
|
|
// arrayBuffer() call, so a regular ArrayBuffer is guaranteed.
|
|
|
|
|
const blob = new Blob([bytes as unknown as ArrayBuffer], {
|
2026-07-09 09:58:41 +02:00
|
|
|
type: contentType,
|
2026-07-08 20:55:28 +02:00
|
|
|
});
|
|
|
|
|
const blobUrl = createUrl(blob);
|
|
|
|
|
const record: PdfByteRecord = { bytes, blobUrl };
|
|
|
|
|
records.set(documentId, record);
|
|
|
|
|
return record;
|
|
|
|
|
},
|
|
|
|
|
get(documentId) {
|
|
|
|
|
return records.get(documentId) ?? null;
|
|
|
|
|
},
|
|
|
|
|
has(documentId) {
|
|
|
|
|
return records.has(documentId);
|
|
|
|
|
},
|
|
|
|
|
delete(documentId) {
|
|
|
|
|
const record = records.get(documentId);
|
|
|
|
|
if (!record) return false;
|
|
|
|
|
revokeUrl(record.blobUrl);
|
|
|
|
|
records.delete(documentId);
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
list() {
|
|
|
|
|
return [...records.keys()];
|
|
|
|
|
},
|
|
|
|
|
clear() {
|
|
|
|
|
for (const record of records.values()) revokeUrl(record.blobUrl);
|
|
|
|
|
records.clear();
|
|
|
|
|
},
|
|
|
|
|
size() {
|
|
|
|
|
let total = 0;
|
|
|
|
|
for (const r of records.values()) total += r.bytes.byteLength;
|
|
|
|
|
return total;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|