@xgspz/cli-ui 0.0.0-stage → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/README.md +512 -2
- package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
- package/dist/cli-ui-index-C34qFb4u.js +11209 -0
- package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
- package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
- package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +932 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +577 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1103 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +505 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +619 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +485 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +335 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1949 -0
- package/src/operator.css +1949 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { notifyCallback } from "../core/callbacks";
|
|
2
|
+
import { FileSyncCache, fileSyncScopeKey, type FileSyncScope } from "./fileSyncCache";
|
|
3
|
+
import { FileSyncProcessor } from "./fileSyncProcessor";
|
|
4
|
+
import { createFileWorkQueue } from "./fileSyncQueue";
|
|
5
|
+
|
|
6
|
+
export const FILE_SYNC_MAX = 100 * 1024 * 1024;
|
|
7
|
+
export interface FileSyncManifest { protocol: 1; namespace: string; revision: string; size: number; hash: string; chunks: { id: string; size: number }[] }
|
|
8
|
+
export interface FileSyncTransport {
|
|
9
|
+
/** null means 304 after the transport checked the authenticated namespace. */
|
|
10
|
+
manifest(jobId: string, path: string, options?: { signal?: AbortSignal; revision?: string }): Promise<FileSyncManifest | null>;
|
|
11
|
+
chunks(jobId: string, input: { path: string; revision: string; ids: string[] }, options?: { signal?: AbortSignal }): Promise<Uint8Array<ArrayBuffer>>;
|
|
12
|
+
}
|
|
13
|
+
export class FileSyncError extends Error {
|
|
14
|
+
constructor(message: string, readonly status: number, readonly code: string, readonly retryAfterMs?: number) { super(message); this.name = "FileSyncError"; }
|
|
15
|
+
}
|
|
16
|
+
export interface FileSyncProgress { phase: "manifest" | "chunks" | "verify"; loadedBytes: number; totalBytes: number; downloadedBytes: number; reusedBytes: number }
|
|
17
|
+
export interface FileSyncOptions { signal?: AbortSignal; onProgress?: (progress: FileSyncProgress) => void; decode?: boolean; range?: { start?: number; maxBytes: number; tail?: boolean }; pendingTimeoutMs?: number; /** Require a manifest request started after this explicit refresh. */ fresh?: boolean; /** Remember only successfully viewed ranges, never downloads. */ preview?: boolean }
|
|
18
|
+
export interface FilePreviewRefresh { changed: string[]; error?: Error; denied?: Array<{ path: string; message: string }> }
|
|
19
|
+
export interface SyncedFile {
|
|
20
|
+
readonly bytes: Uint8Array<ArrayBuffer>;
|
|
21
|
+
revision: string; namespace: string; hash: string; size: number; start: number; end: number; truncated: boolean;
|
|
22
|
+
text?: string; encoding?: string; downloadedBytes: number; reusedBytes: number; cacheMode: "persistent" | "session";
|
|
23
|
+
}
|
|
24
|
+
const hex = /^[a-f0-9]{64}$/;
|
|
25
|
+
export function validateFileSyncManifest(value: unknown): FileSyncManifest {
|
|
26
|
+
const m = value as FileSyncManifest;
|
|
27
|
+
if (!m || m.protocol !== 1 || typeof m.namespace !== "string" || !m.namespace || m.namespace.length > 1024 || typeof m.revision !== "string" || typeof m.hash !== "string" || !hex.test(m.revision) || !hex.test(m.hash)
|
|
28
|
+
|| !Number.isSafeInteger(m.size) || m.size < 0 || m.size > FILE_SYNC_MAX || !Array.isArray(m.chunks) || m.chunks.length > 10000) throw new FileSyncError("文件修订清单无效或超过100MiB", 502, "invalid_manifest");
|
|
29
|
+
let total = 0; const sizes = new Map<string, number>();
|
|
30
|
+
for (const chunk of m.chunks) {
|
|
31
|
+
if (!chunk || typeof chunk.id !== "string" || !hex.test(chunk.id) || !Number.isSafeInteger(chunk.size) || chunk.size <= 0 || chunk.size > 256 * 1024 || (sizes.has(chunk.id) && sizes.get(chunk.id) !== chunk.size)) throw new FileSyncError("文件分块清单无效", 502, "invalid_manifest");
|
|
32
|
+
total += chunk.size; sizes.set(chunk.id, chunk.size);
|
|
33
|
+
}
|
|
34
|
+
if (total !== m.size) throw new FileSyncError("文件分块长度与清单不一致", 502, "invalid_manifest");
|
|
35
|
+
return { protocol: 1, namespace: m.namespace, revision: m.revision, size: m.size, hash: m.hash, chunks: m.chunks.map(chunk => ({ id: chunk.id, size: chunk.size })) };
|
|
36
|
+
}
|
|
37
|
+
const aborted = () => new DOMException("文件同步已取消", "AbortError");
|
|
38
|
+
const check = (signal: AbortSignal) => { if (signal.aborted) throw aborted(); };
|
|
39
|
+
function untilCancelled<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
40
|
+
if (!signal) return promise;
|
|
41
|
+
if (signal.aborted) return Promise.reject(aborted());
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const cancel = () => { signal.removeEventListener("abort", cancel); reject(aborted()); };
|
|
44
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
45
|
+
promise.then(value => { signal.removeEventListener("abort", cancel); resolve(value); }, error => { signal.removeEventListener("abort", cancel); reject(error); });
|
|
46
|
+
if (signal.aborted) cancel();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const statusOf = (error: unknown) => error && typeof error === "object" && "status" in error ? Number(error.status) : 0;
|
|
50
|
+
async function retryPending<T>(work: () => Promise<T>, signal: AbortSignal, timeoutMs = 180_000): Promise<T> {
|
|
51
|
+
const deadline = Date.now() + (Number.isFinite(timeoutMs) && timeoutMs >= 0 ? Math.min(timeoutMs, 180_000) : 180_000);
|
|
52
|
+
let pending = 0, failures = 0;
|
|
53
|
+
for (;;) {
|
|
54
|
+
check(signal);
|
|
55
|
+
try { return await work(); } catch (error) {
|
|
56
|
+
check(signal);
|
|
57
|
+
const status = statusOf(error), code = (error as FileSyncError)?.code;
|
|
58
|
+
const permanent = error instanceof Error && error.name === "AbortError"
|
|
59
|
+
|| ["client_queue_full", "client_queue_timeout", "integrity", "invalid_manifest", "invalid_response", "response_too_large", "unsupported", "file_sync_unsupported"].includes(code);
|
|
60
|
+
const retryable = !status || status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
61
|
+
if (permanent || (status !== 202 && (!retryable || ++failures > 3)) || Date.now() >= deadline) throw error;
|
|
62
|
+
const backoff = Math.min(8000, 500 * 2 ** (status === 202 ? pending++ : failures - 1));
|
|
63
|
+
const hint = Number((error as FileSyncError)?.retryAfterMs ?? (status === 202 ? 1000 : 0));
|
|
64
|
+
// Retry-After is a lower bound. Jitter spreads a burst without retrying before the server is ready.
|
|
65
|
+
const delay = Math.max(Number.isFinite(hint) && hint > 0 ? hint : 0, backoff) + Math.random() * Math.min(1000, backoff * .25);
|
|
66
|
+
if (delay > deadline - Date.now()) throw error;
|
|
67
|
+
await new Promise<void>((resolve, reject) => {
|
|
68
|
+
const cancel = () => { clearTimeout(timer); reject(aborted()); };
|
|
69
|
+
const timer = setTimeout(() => { signal.removeEventListener("abort", cancel); resolve(); }, delay);
|
|
70
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
71
|
+
if (signal.aborted) cancel();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
type Shared<T> = { controller: AbortController; promise: Promise<T>; consumers: Set<object>; progress: Map<object, (value: FileSyncProgress) => void> };
|
|
77
|
+
function lease<T>(map: Map<string, Shared<T>>, key: string, work: (signal: AbortSignal, notify: (value: FileSyncProgress) => void) => Promise<T>, signal?: AbortSignal, progress?: (value: FileSyncProgress) => void): Promise<T> {
|
|
78
|
+
if (signal?.aborted) return Promise.reject(aborted());
|
|
79
|
+
let entry = map.get(key);
|
|
80
|
+
if (!entry) {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const created = { controller, consumers: new Set<object>(), progress: new Map<object, (value: FileSyncProgress) => void>(), promise: null! as Promise<T> };
|
|
83
|
+
created.promise = Promise.resolve().then(() => work(controller.signal, value => { for (const callback of [...created.progress.values()]) notifyCallback(callback, value); })).finally(() => { if (map.get(key) === created) map.delete(key); });
|
|
84
|
+
void created.promise.catch(() => undefined); map.set(key, created); entry = created;
|
|
85
|
+
}
|
|
86
|
+
const owned = entry, consumer = {}; owned.consumers.add(consumer); if (progress) owned.progress.set(consumer, progress);
|
|
87
|
+
return new Promise<T>((resolve, reject) => {
|
|
88
|
+
let active = true;
|
|
89
|
+
const release = () => { active = false; signal?.removeEventListener("abort", cancel); owned.consumers.delete(consumer); owned.progress.delete(consumer); };
|
|
90
|
+
const cancel = () => { if (!active) return; release(); reject(aborted()); if (!owned.consumers.size) { owned.controller.abort(); if (map.get(key) === owned) map.delete(key); } };
|
|
91
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
92
|
+
owned.promise.then(value => { if (active) { release(); resolve(value); } }, error => { if (active) { release(); reject(error); } });
|
|
93
|
+
if (signal?.aborted) cancel();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Per-transport coordinator: every caller authorizes a manifest before any cached bytes are read. */
|
|
98
|
+
export function createFileSynchronizer(transport: FileSyncTransport, cache = new FileSyncCache()) {
|
|
99
|
+
const last = new Map<string, FileSyncManifest>(), manifests = new Map<string, Shared<FileSyncManifest>>(), files = new Map<string, Shared<SyncedFile>>(), chunks = new Map<string, Shared<Uint8Array<ArrayBuffer>>>();
|
|
100
|
+
type Preview = { namespace: string; revision: string; range?: FileSyncOptions["range"]; stale?: boolean };
|
|
101
|
+
const previews = new Map<string, Preview>(), previewReads = new Map<string, object>(), previewListeners = new Map<string, Set<() => void>>();
|
|
102
|
+
const manifestQueue = createFileWorkQueue(3), chunkQueue = createFileWorkQueue(3);
|
|
103
|
+
const assemblyQueue = createFileWorkQueue(2), refreshQueue = createFileWorkQueue(1);
|
|
104
|
+
const retireNamespace = (namespace: string) => {
|
|
105
|
+
for (const entries of [files, chunks]) for (const [key, entry] of entries) if (JSON.parse(JSON.parse(key)[0])[0] === namespace) { entry.controller.abort(); entries.delete(key); }
|
|
106
|
+
};
|
|
107
|
+
const retireFile = (scope: FileSyncScope) => {
|
|
108
|
+
const identity = fileSyncScopeKey(scope);
|
|
109
|
+
for (const entries of [files, chunks]) for (const [key, entry] of entries) if (JSON.parse(key)[0] === identity) { entry.controller.abort(); entries.delete(key); }
|
|
110
|
+
};
|
|
111
|
+
type QueuedChunk = { identity: string; jobId: string; path: string; revision: string; id: string; size: number; signal: AbortSignal; timeout?: number; resolve: (bytes: Uint8Array<ArrayBuffer>) => void; reject: (error: unknown) => void };
|
|
112
|
+
let queued: QueuedChunk[] = [], flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
+
const flush = () => {
|
|
114
|
+
flushTimer = undefined; const pending = queued; queued = [];
|
|
115
|
+
while (pending.length) {
|
|
116
|
+
const first = pending.shift()!;
|
|
117
|
+
if (first.signal.aborted) { first.reject(aborted()); continue; }
|
|
118
|
+
const batch = [first]; let size = first.size;
|
|
119
|
+
for (let i = 0; i < pending.length;) {
|
|
120
|
+
const item = pending[i];
|
|
121
|
+
if (item.identity === first.identity && item.revision === first.revision && size + item.size <= 1024 * 1024) { pending.splice(i, 1); if (item.signal.aborted) item.reject(aborted()); else { batch.push(item); size += item.size; } } else i++;
|
|
122
|
+
}
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const cancel = () => { if (batch.every(item => item.signal.aborted)) controller.abort(); };
|
|
125
|
+
for (const item of batch) item.signal.addEventListener("abort", cancel);
|
|
126
|
+
void retryPending(() => chunkQueue.run(() => transport.chunks(first.jobId, { path: first.path, revision: first.revision, ids: batch.map(item => item.id) }, { signal: controller.signal }), controller.signal), controller.signal, first.timeout).then(bytes => {
|
|
127
|
+
if (bytes.length !== size) throw new FileSyncError("文件块批次长度错误", 502, "integrity");
|
|
128
|
+
let offset = 0; for (const item of batch) { const data = bytes.slice(offset, offset + item.size); offset += item.size; if (item.signal.aborted) item.reject(aborted()); else item.resolve(data); }
|
|
129
|
+
}).catch(error => { for (const item of batch) item.reject(error); }).finally(() => { for (const item of batch) item.signal.removeEventListener("abort", cancel); });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const fetchChunk = (item: Omit<QueuedChunk, "resolve" | "reject">) => new Promise<Uint8Array<ArrayBuffer>>((resolve, reject) => {
|
|
133
|
+
queued.push({ ...item, resolve, reject }); if (flushTimer === undefined) flushTimer = setTimeout(flush, 12);
|
|
134
|
+
});
|
|
135
|
+
const manifestFor = async (jobId: string, path: string, signal?: AbortSignal, timeout?: number, fresh = false) => {
|
|
136
|
+
const resource = JSON.stringify([jobId, path]);
|
|
137
|
+
// A round-end check must start after that event. Sharing an older in-flight
|
|
138
|
+
// check can miss the final edit indefinitely when there is no polling.
|
|
139
|
+
const earlier = fresh ? manifests.get(resource) : undefined;
|
|
140
|
+
if (earlier) await untilCancelled(earlier.promise.catch(() => undefined), signal);
|
|
141
|
+
return lease(manifests, resource, async inner => {
|
|
142
|
+
const previous = last.get(resource);
|
|
143
|
+
try {
|
|
144
|
+
const response = await retryPending(() => manifestQueue.run(() => transport.manifest(jobId, path, { signal: inner, revision: previous?.revision }), inner), inner, timeout); check(inner);
|
|
145
|
+
const manifest = response ? validateFileSyncManifest(response) : previous;
|
|
146
|
+
if (!manifest) throw new FileSyncError("没有可复用的授权修订清单", 502, "invalid_manifest");
|
|
147
|
+
last.delete(resource); last.set(resource, manifest); while (last.size > 40) last.delete(last.keys().next().value!);
|
|
148
|
+
return manifest;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const status = statusOf(error);
|
|
151
|
+
if ([401, 403, 404].includes(status)) {
|
|
152
|
+
last.delete(resource);
|
|
153
|
+
if (previous) {
|
|
154
|
+
if (status === 404 || (status === 403 && (error as FileSyncError).code === "file_forbidden")) { const scope = { namespace: previous.namespace, taskId: jobId, path }; retireFile(scope); await cache.purgeFile(scope); }
|
|
155
|
+
else { retireNamespace(previous.namespace); await cache.purgeNamespace(previous.namespace); }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}, signal);
|
|
161
|
+
};
|
|
162
|
+
const read = async (jobId: string, path: string, options: FileSyncOptions = {}, initialManifest?: FileSyncManifest): Promise<SyncedFile> => {
|
|
163
|
+
if (!path || path.length > 4096 || path.includes("\0")) throw new FileSyncError("文件路径无效", 400, "invalid_path");
|
|
164
|
+
if (options.range && (!Number.isSafeInteger(options.range.maxBytes) || options.range.maxBytes < 0 || !Number.isSafeInteger(options.range.start ?? 0) || (options.range.start ?? 0) < 0)) throw new FileSyncError("文件读取范围无效", 400, "invalid_range");
|
|
165
|
+
for (let attempt = 0; ; attempt++) {
|
|
166
|
+
notifyCallback(options.onProgress, { phase: "manifest", loadedBytes: 0, totalBytes: 0, downloadedBytes: 0, reusedBytes: 0 });
|
|
167
|
+
const manifest = attempt === 0 && initialManifest ? initialManifest : await manifestFor(jobId, path, options.signal, options.pendingTimeoutMs, options.fresh || attempt > 0);
|
|
168
|
+
const start = options.range?.tail ? Math.max(0, manifest.size - options.range.maxBytes) : Math.min(manifest.size, options.range?.start ?? 0);
|
|
169
|
+
const end = options.range ? Math.min(manifest.size, start + options.range.maxBytes) : manifest.size;
|
|
170
|
+
const scope: FileSyncScope = { namespace: manifest.namespace, taskId: jobId, path };
|
|
171
|
+
if (options.signal?.aborted) throw aborted();
|
|
172
|
+
await untilCancelled(cache.authorize(scope, manifest.revision), options.signal);
|
|
173
|
+
if (options.signal?.aborted) throw aborted();
|
|
174
|
+
const identity = fileSyncScopeKey(scope), key = JSON.stringify([identity, manifest.revision, start, end, options.decode !== false]);
|
|
175
|
+
try {
|
|
176
|
+
return await lease(files, key, (outerSignal, progress) => assemblyQueue.run(async () => {
|
|
177
|
+
const controller = new AbortController(), cancel = () => controller.abort();
|
|
178
|
+
outerSignal.addEventListener("abort", cancel, { once: true }); if (outerSignal.aborted) cancel();
|
|
179
|
+
const signal = controller.signal;
|
|
180
|
+
const processor = new FileSyncProcessor(signal);
|
|
181
|
+
let downloadedBytes = 0, reusedBytes = 0, loadedBytes = 0;
|
|
182
|
+
const report = (phase: FileSyncProgress["phase"]) => progress({ phase, loadedBytes, totalBytes: end - start, downloadedBytes, reusedBytes });
|
|
183
|
+
let offset = 0;
|
|
184
|
+
const selected = manifest.chunks.map(chunk => { const value = { ...chunk, offset }; offset += chunk.size; return value; }).filter(chunk => chunk.offset < end && chunk.offset + chunk.size > start);
|
|
185
|
+
const release = cache.retain(scope, new Set(selected.map(chunk => chunk.id)));
|
|
186
|
+
const coverStart = selected[0]?.offset ?? start, coverSize = selected.reduce((sum, chunk) => sum + chunk.size, 0);
|
|
187
|
+
const network = async (chunk: { id: string; size: number }) => {
|
|
188
|
+
const bytes = await lease(chunks, JSON.stringify([identity, manifest.revision, chunk.id]), inner => fetchChunk({ identity, jobId, path, revision: manifest.revision, id: chunk.id, size: chunk.size, signal: inner, timeout: options.pendingTimeoutMs }), signal);
|
|
189
|
+
check(signal); if (bytes.byteLength !== chunk.size) throw new FileSyncError("文件块长度错误", 502, "integrity");
|
|
190
|
+
const verified = await processor.verify(bytes.slice(), chunk.id); check(signal); downloadedBytes += verified.byteLength;
|
|
191
|
+
await cache.putBlock(scope, chunk.id, verified); check(signal); return verified;
|
|
192
|
+
};
|
|
193
|
+
const read = async (chunk: { id: string; size: number }) => {
|
|
194
|
+
const cached = await cache.getBlock(scope, chunk.id); check(signal);
|
|
195
|
+
if (cached?.byteLength === chunk.size) return { bytes: cached, cached: true };
|
|
196
|
+
if (cached) await cache.removeBlock(scope, chunk.id);
|
|
197
|
+
return { bytes: await network(chunk), cached: false };
|
|
198
|
+
};
|
|
199
|
+
const prefetched = new Map<number, ReturnType<typeof read>>();
|
|
200
|
+
const prefetch = (index: number) => { let bytes = 0; for (let i = index; i < Math.min(selected.length, index + 48); i++) { bytes += selected[i].size; if (bytes > 3 * 1024 * 1024) break; if (!prefetched.has(i)) { const promise = read(selected[i]); void promise.catch(() => undefined); prefetched.set(i, promise); } } };
|
|
201
|
+
try {
|
|
202
|
+
await processor.start(coverSize); prefetch(0);
|
|
203
|
+
for (let index = 0; index < selected.length; index++) {
|
|
204
|
+
check(signal); const chunk = selected[index]; const data = await prefetched.get(index)!; prefetched.delete(index);
|
|
205
|
+
try { await processor.append(data.bytes, chunk.id); if (data.cached) reusedBytes += chunk.size; }
|
|
206
|
+
catch (error) {
|
|
207
|
+
check(signal); if (!data.cached) throw error;
|
|
208
|
+
// Several prefetched positions may hold the same old corrupt
|
|
209
|
+
// block. Reuse a repair already committed by an earlier position.
|
|
210
|
+
const repaired = await cache.getBlock(scope, chunk.id); check(signal);
|
|
211
|
+
let reused = false;
|
|
212
|
+
if (repaired?.byteLength === chunk.size) try { await processor.append(repaired, chunk.id); reused = true; reusedBytes += chunk.size; } catch { check(signal); }
|
|
213
|
+
if (!reused) { await cache.removeBlock(scope, chunk.id); await processor.append(await network(chunk), chunk.id); }
|
|
214
|
+
}
|
|
215
|
+
loadedBytes = Math.min(end - start, chunk.offset + chunk.size - start); report("chunks"); prefetch(index + 1);
|
|
216
|
+
}
|
|
217
|
+
report("verify");
|
|
218
|
+
const complete = start === 0 && end === manifest.size;
|
|
219
|
+
const assembled = await processor.finish(complete ? manifest.hash : null, options.decode !== false, { start: start - coverStart, length: end - start, fromMiddle: start > 0 });
|
|
220
|
+
check(signal); const latest = last.get(JSON.stringify([jobId, path]));
|
|
221
|
+
if (complete && latest?.namespace === manifest.namespace && latest.revision === manifest.revision) await cache.putManifest(scope, manifest); check(signal);
|
|
222
|
+
return { ...assembled, namespace: manifest.namespace, revision: manifest.revision, hash: manifest.hash, size: manifest.size, start, end, truncated: !complete, downloadedBytes, reusedBytes, cacheMode: cache.mode };
|
|
223
|
+
} finally { controller.abort(); outerSignal.removeEventListener("abort", cancel); processor.dispose(); release(); }
|
|
224
|
+
}, outerSignal), options.signal, options.onProgress);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (statusOf(error) !== 410 || attempt >= 2 || options.signal?.aborted) throw error;
|
|
227
|
+
last.delete(JSON.stringify([jobId, path]));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const sync = async (jobId: string, path: string, options: FileSyncOptions = {}): Promise<SyncedFile> => {
|
|
232
|
+
const resource = JSON.stringify([jobId, path]), token = {};
|
|
233
|
+
if (options.preview) previewReads.set(resource, token);
|
|
234
|
+
try {
|
|
235
|
+
const result = await read(jobId, path, options);
|
|
236
|
+
if (options.preview && previewReads.get(resource) === token && !options.signal?.aborted) {
|
|
237
|
+
const previous = previews.get(resource);
|
|
238
|
+
const range = options.range ? { start: options.range.start ?? 0, maxBytes: options.range.maxBytes, tail: !!options.range.tail } : undefined;
|
|
239
|
+
// A preview can finish behind a metadata baseline or a newer background refresh.
|
|
240
|
+
// Normal revision-only updates must not create a watch -> preview -> watch cycle.
|
|
241
|
+
const latest = last.get(resource), behindLatest = latest && (latest.namespace !== result.namespace || latest.revision !== result.revision);
|
|
242
|
+
const alreadyRefreshed = behindLatest && previous?.namespace === latest.namespace && previous.revision === latest.revision && JSON.stringify(previous.range) === JSON.stringify(range);
|
|
243
|
+
if (alreadyRefreshed) {
|
|
244
|
+
// Keep the latest verified revision, but request one corrective event
|
|
245
|
+
// because this caller is about to display an older snapshot.
|
|
246
|
+
previews.set(resource, { ...previous, stale: true });
|
|
247
|
+
} else {
|
|
248
|
+
previews.delete(resource); previews.set(resource, { namespace: result.namespace, revision: result.revision, range });
|
|
249
|
+
while (previews.size > 40) previews.delete(previews.keys().next().value!);
|
|
250
|
+
}
|
|
251
|
+
if (!previous || behindLatest || previous.namespace !== result.namespace || JSON.stringify(previous.range) !== JSON.stringify(range)) {
|
|
252
|
+
for (const callback of [...previewListeners.get(jobId) ?? []]) notifyCallback(callback, undefined);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
} finally { if (previewReads.get(resource) === token) previewReads.delete(resource); }
|
|
257
|
+
};
|
|
258
|
+
const refreshPreviews = (jobId: string, paths: Iterable<string>, signal?: AbortSignal): Promise<FilePreviewRefresh> => {
|
|
259
|
+
const requested = [...new Set(paths)];
|
|
260
|
+
// Serialize background assembly across tasks as well: a chunk request limit alone does not
|
|
261
|
+
// prevent several 100MiB workers allocating full buffers at the same time.
|
|
262
|
+
const work = async (): Promise<FilePreviewRefresh> => {
|
|
263
|
+
const changed = new Set<string>(); let error: Error | undefined, hasRecoverableError = false, retryAfterMs = 0;
|
|
264
|
+
const denied: NonNullable<FilePreviewRefresh["denied"]> = [];
|
|
265
|
+
for (const path of requested) {
|
|
266
|
+
if (signal?.aborted) throw aborted();
|
|
267
|
+
const resource = JSON.stringify([jobId, path]), previous = previews.get(resource);
|
|
268
|
+
if (!previous) continue; // Restored/unread tabs are still demand-loaded on activation.
|
|
269
|
+
try {
|
|
270
|
+
const manifest = await manifestFor(jobId, path, signal, undefined, true);
|
|
271
|
+
if (manifest.namespace === previous.namespace && manifest.revision === previous.revision && !previous.stale) continue;
|
|
272
|
+
changed.add(path);
|
|
273
|
+
const result = await read(jobId, path, { signal, decode: false, range: previous.range }, manifest);
|
|
274
|
+
const latest = last.get(resource), current = previews.get(resource);
|
|
275
|
+
if (current?.namespace === previous.namespace && current.revision === previous.revision && JSON.stringify(current.range) === JSON.stringify(previous.range)
|
|
276
|
+
&& latest?.namespace === result.namespace && latest.revision === result.revision) {
|
|
277
|
+
previews.set(resource, { ...current, namespace: result.namespace, revision: result.revision, stale: false });
|
|
278
|
+
}
|
|
279
|
+
} catch (failure) {
|
|
280
|
+
const status = statusOf(failure);
|
|
281
|
+
if (signal?.aborted || (failure instanceof Error && failure.name === "AbortError") || status === 401) throw failure;
|
|
282
|
+
if (status === 403) {
|
|
283
|
+
if ((failure as FileSyncError).code !== "file_forbidden") throw failure;
|
|
284
|
+
const scope = { namespace: last.get(resource)?.namespace ?? previous.namespace, taskId: jobId, path };
|
|
285
|
+
last.delete(resource); previews.delete(resource); retireFile(scope); await cache.purgeFile(scope);
|
|
286
|
+
changed.delete(path); denied.push({ path, message: failure instanceof Error ? failure.message : String(failure) });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (status === 404) {
|
|
290
|
+
changed.add(path);
|
|
291
|
+
if (previews.get(resource) === previous) {
|
|
292
|
+
previews.delete(resource);
|
|
293
|
+
// Deletion may arrive from a chunk request after manifest authorization too.
|
|
294
|
+
const scope = { namespace: last.get(resource)?.namespace ?? previous.namespace, taskId: jobId, path };
|
|
295
|
+
last.delete(resource); retireFile(scope); await cache.purgeFile(scope);
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
const candidate = failure instanceof Error ? failure : new Error(String(failure));
|
|
299
|
+
const code = (failure as FileSyncError)?.code;
|
|
300
|
+
// Match event-driven workspace recovery: a permanent failure of one
|
|
301
|
+
// file must not hide a later file's recoverable failure.
|
|
302
|
+
const recoverable = !["invalid_manifest", "invalid_response", "integrity", "response_too_large", "client_queue_timeout"].includes(code)
|
|
303
|
+
&& (!status || [408, 429, 500, 502, 503, 504].includes(status));
|
|
304
|
+
if (!error || (recoverable && !hasRecoverableError)) error = candidate;
|
|
305
|
+
if (recoverable) {
|
|
306
|
+
hasRecoverableError = true;
|
|
307
|
+
const hint = (failure as FileSyncError)?.retryAfterMs;
|
|
308
|
+
if (typeof hint === "number" && Number.isFinite(hint) && hint >= 0) retryAfterMs = Math.max(retryAfterMs, hint);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Preserve the original errors: consumers may share them with other reads.
|
|
314
|
+
const combined = error && hasRecoverableError && retryAfterMs > 0 ? Object.assign(new Error(error.message), error, { name: error.name, retryAfterMs }) : error;
|
|
315
|
+
return { changed: [...changed], ...(combined ? { error: combined } : {}), ...(denied.length ? { denied } : {}) };
|
|
316
|
+
};
|
|
317
|
+
return refreshQueue.run(work, signal);
|
|
318
|
+
};
|
|
319
|
+
const onPreviewScope = (jobId: string, callback: () => void) => {
|
|
320
|
+
let listeners = previewListeners.get(jobId); if (!listeners) { listeners = new Set(); previewListeners.set(jobId, listeners); }
|
|
321
|
+
listeners.add(callback);
|
|
322
|
+
return () => { listeners.delete(callback); if (!listeners.size) previewListeners.delete(jobId); };
|
|
323
|
+
};
|
|
324
|
+
return { sync, refreshPreviews, onPreviewScope, cache, clearNamespace: (namespace: string) => {
|
|
325
|
+
for (const entry of manifests.values()) entry.controller.abort(); manifests.clear();
|
|
326
|
+
retireNamespace(namespace);
|
|
327
|
+
for (const [key, manifest] of last) if (manifest.namespace === namespace) last.delete(key);
|
|
328
|
+
for (const [key, preview] of previews) if (preview.namespace === namespace) previews.delete(key);
|
|
329
|
+
return cache.purgeNamespace(namespace);
|
|
330
|
+
} };
|
|
331
|
+
}
|
|
332
|
+
const synchronizers = new WeakMap<FileSyncTransport, ReturnType<typeof createFileSynchronizer>>();
|
|
333
|
+
function synchronizerFor(transport: FileSyncTransport) {
|
|
334
|
+
let sync = synchronizers.get(transport); if (!sync) { sync = createFileSynchronizer(transport); synchronizers.set(transport, sync); }
|
|
335
|
+
return sync;
|
|
336
|
+
}
|
|
337
|
+
export function syncFile(transport: FileSyncTransport, jobId: string, path: string, options: FileSyncOptions = {}) {
|
|
338
|
+
return synchronizerFor(transport).sync(jobId, path, options);
|
|
339
|
+
}
|
|
340
|
+
/** Refresh only ranges that were actually previewed, one file at a time. */
|
|
341
|
+
export function refreshFilePreviews(transport: FileSyncTransport, jobId: string, paths: Iterable<string>, signal?: AbortSignal) {
|
|
342
|
+
return synchronizerFor(transport).refreshPreviews(jobId, paths, signal);
|
|
343
|
+
}
|
|
344
|
+
export function onFilePreviewScope(transport: FileSyncTransport, jobId: string, callback: () => void) {
|
|
345
|
+
return synchronizerFor(transport).onPreviewScope(jobId, callback);
|
|
346
|
+
}
|
|
347
|
+
/** Hosts can call this on an explicit account logout; a viewer unmount keeps reusable cache. */
|
|
348
|
+
export function clearFileSyncNamespace(transport: FileSyncTransport, namespace: string) {
|
|
349
|
+
const synchronizer = synchronizers.get(transport);
|
|
350
|
+
return synchronizer ? synchronizer.clearNamespace(namespace) : new FileSyncCache().purgeNamespace(namespace);
|
|
351
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { FileSyncManifest } from "./fileSync";
|
|
2
|
+
|
|
3
|
+
export interface FileSyncScope { namespace: string; taskId: string; path: string }
|
|
4
|
+
export const fileSyncScopeKey = (scope: FileSyncScope) => JSON.stringify([scope.namespace, scope.taskId, scope.path]);
|
|
5
|
+
type CachedFile = { key: string; namespace: string; bytes: number; touched: number; manifest?: FileSyncManifest; authorizedRevision?: string };
|
|
6
|
+
type CachedBlock = { key: string; scope: string; id: string; bytes: ArrayBuffer; size: number; touched: number };
|
|
7
|
+
export interface FileSyncCacheOptions { indexedDB?: IDBFactory | null; name?: string; maxBytes?: number }
|
|
8
|
+
const CACHE_TIMEOUT_MS = 5000;
|
|
9
|
+
const unavailable = new WeakSet<IDBDatabase>(), expired = new WeakSet<IDBTransaction>();
|
|
10
|
+
const cacheTimeout = () => new Error("本地文件缓存响应超时");
|
|
11
|
+
function abandon(tx: IDBTransaction | null) {
|
|
12
|
+
if (!tx) return;
|
|
13
|
+
expired.add(tx); unavailable.add(tx.db);
|
|
14
|
+
try { tx.abort(); } catch { /* A completed transaction cannot be aborted. */ }
|
|
15
|
+
tx.db.close();
|
|
16
|
+
}
|
|
17
|
+
const request = <T>(value: IDBRequest<T>) => new Promise<T>((resolve, reject) => {
|
|
18
|
+
const finish = (error?: unknown) => { clearTimeout(timer); value.onsuccess = value.onerror = null; if (error) reject(error); else resolve(value.result); };
|
|
19
|
+
const timer = setTimeout(() => { abandon(value.transaction); finish(cacheTimeout()); }, CACHE_TIMEOUT_MS);
|
|
20
|
+
value.onsuccess = () => finish(value.transaction && expired.has(value.transaction) ? cacheTimeout() : undefined);
|
|
21
|
+
value.onerror = () => finish(value.error ?? new Error("文件缓存读取失败"));
|
|
22
|
+
});
|
|
23
|
+
const completed = (tx: IDBTransaction) => {
|
|
24
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
25
|
+
const finish = (error?: unknown) => { clearTimeout(timer); tx.oncomplete = tx.onabort = tx.onerror = null; if (error) reject(error); else resolve(); };
|
|
26
|
+
const timer = setTimeout(() => { abandon(tx); finish(cacheTimeout()); }, CACHE_TIMEOUT_MS);
|
|
27
|
+
tx.oncomplete = () => finish(); tx.onabort = tx.onerror = () => finish(tx.error ?? new Error("文件缓存事务失败"));
|
|
28
|
+
});
|
|
29
|
+
void done.catch(() => undefined); return done;
|
|
30
|
+
};
|
|
31
|
+
function visitCursor(cursor: IDBRequest<IDBCursor | null>, visit: (item: IDBCursor) => boolean | void) {
|
|
32
|
+
return new Promise<void>((resolve, reject) => {
|
|
33
|
+
const finish = (error?: unknown) => { clearTimeout(timer); cursor.onsuccess = cursor.onerror = null; if (error) reject(error); else resolve(); };
|
|
34
|
+
const timer = setTimeout(() => { abandon(cursor.transaction); finish(cacheTimeout()); }, CACHE_TIMEOUT_MS);
|
|
35
|
+
cursor.onerror = () => finish(cursor.error ?? new Error("文件缓存索引读取失败"));
|
|
36
|
+
cursor.onsuccess = () => {
|
|
37
|
+
try {
|
|
38
|
+
if (cursor.transaction && expired.has(cursor.transaction)) throw cacheTimeout();
|
|
39
|
+
const item = cursor.result;
|
|
40
|
+
if (!item || visit(item) === false) finish(); else item.continue();
|
|
41
|
+
} catch (error) { finish(error); }
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const blockKey = (scope: string, id: string) => JSON.stringify([scope, id]);
|
|
46
|
+
const isBuffer = (value: unknown): value is ArrayBuffer => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
|
|
47
|
+
|
|
48
|
+
/** Only authenticated namespaces reach this cache. A cache miss never substitutes another scope. */
|
|
49
|
+
export class FileSyncCache {
|
|
50
|
+
private database: Promise<IDBDatabase | null>;
|
|
51
|
+
private writes: Promise<unknown> = Promise.resolve();
|
|
52
|
+
private memory = new Map<string, CachedBlock>();
|
|
53
|
+
private files = new Map<string, CachedFile>();
|
|
54
|
+
private leases = new Map<string, number>();
|
|
55
|
+
private protectedBlocks = new Map<string, Map<string, number>>();
|
|
56
|
+
private touches = new Map<string, number>();
|
|
57
|
+
private budget: number;
|
|
58
|
+
private fallback = false;
|
|
59
|
+
private estimate: { at: number; available: number } | null = null;
|
|
60
|
+
constructor(options: FileSyncCacheOptions = {}) {
|
|
61
|
+
this.budget = Math.max(0, options.maxBytes ?? 256 * 1024 * 1024);
|
|
62
|
+
const factory = options.indexedDB === undefined ? globalThis.indexedDB : options.indexedDB;
|
|
63
|
+
this.database = !factory ? Promise.resolve(null) : new Promise(resolve => {
|
|
64
|
+
let abandoned = false;
|
|
65
|
+
let opened: IDBOpenDBRequest;
|
|
66
|
+
try { opened = factory.open(options.name ?? "cli-ui-file-sync-v1", 2); } catch { resolve(null); return; }
|
|
67
|
+
const timer = setTimeout(() => { abandoned = true; try { opened.transaction?.abort(); } catch { /* The open can complete later. */ } resolve(null); }, CACHE_TIMEOUT_MS);
|
|
68
|
+
opened.onupgradeneeded = event => {
|
|
69
|
+
if (abandoned) { opened.transaction?.abort(); return; }
|
|
70
|
+
const db = opened.result;
|
|
71
|
+
if (!db.objectStoreNames.contains("files")) db.createObjectStore("files", { keyPath: "key" });
|
|
72
|
+
const blocks = db.objectStoreNames.contains("blocks") ? opened.transaction!.objectStore("blocks") : db.createObjectStore("blocks", { keyPath: "key" });
|
|
73
|
+
if (!blocks.indexNames.contains("scope")) blocks.createIndex("scope", "scope");
|
|
74
|
+
if (!blocks.indexNames.contains("lru")) blocks.createIndex("lru", ["touched", "size"]);
|
|
75
|
+
if (event.oldVersion === 1) { const cursor = blocks.openCursor(); cursor.onsuccess = () => { if (abandoned) return; const item = cursor.result; if (item) { item.update({ ...item.value, size: item.value.bytes.byteLength, touched: item.value.touched ?? 0 }); item.continue(); } }; }
|
|
76
|
+
};
|
|
77
|
+
opened.onerror = opened.onblocked = () => { abandoned = true; clearTimeout(timer); resolve(null); };
|
|
78
|
+
opened.onsuccess = () => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
const db = opened.result;
|
|
81
|
+
if (abandoned) { db.close(); return; }
|
|
82
|
+
db.onversionchange = () => { db.close(); unavailable.add(db); this.fallback = true; };
|
|
83
|
+
db.onclose = () => { unavailable.add(db); this.fallback = true; };
|
|
84
|
+
resolve(db);
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
get mode(): "persistent" | "session" { return this.fallback ? "session" : this.persistent ? "persistent" : "session"; }
|
|
89
|
+
private persistent = false;
|
|
90
|
+
private async db() {
|
|
91
|
+
const db = await this.database;
|
|
92
|
+
if (db && unavailable.has(db)) { this.fallback = true; return null; }
|
|
93
|
+
this.persistent = !!db;
|
|
94
|
+
return db;
|
|
95
|
+
}
|
|
96
|
+
retain(scope: FileSyncScope, ids: ReadonlySet<string> = new Set()) {
|
|
97
|
+
const key = fileSyncScopeKey(scope); this.leases.set(key, (this.leases.get(key) ?? 0) + 1);
|
|
98
|
+
const protectedIds = this.protectedBlocks.get(key) ?? new Map<string, number>(); for (const id of ids) protectedIds.set(id, (protectedIds.get(id) ?? 0) + 1); this.protectedBlocks.set(key, protectedIds);
|
|
99
|
+
let active = true;
|
|
100
|
+
return () => { if (!active) return; active = false; const count = (this.leases.get(key) ?? 1) - 1; if (count) this.leases.set(key, count); else this.leases.delete(key); for (const id of ids) { const uses = (protectedIds.get(id) ?? 1) - 1; if (uses) protectedIds.set(id, uses); else protectedIds.delete(id); } if (!protectedIds.size) this.protectedBlocks.delete(key); };
|
|
101
|
+
}
|
|
102
|
+
private serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
103
|
+
const next = this.writes.then(work, work); this.writes = next.catch(() => undefined); return next;
|
|
104
|
+
}
|
|
105
|
+
private touch(key: string) {
|
|
106
|
+
const now = Date.now(); if (this.touches.has(key) && now - this.touches.get(key)! < 5000) return;
|
|
107
|
+
this.touches.set(key, now); const file = this.files.get(key); if (file) file.touched = now;
|
|
108
|
+
void this.serialize(async () => {
|
|
109
|
+
const db = await this.db(); if (!db || this.fallback) return;
|
|
110
|
+
try { const tx = db.transaction("files", "readwrite"), done = completed(tx); const files = tx.objectStore("files"), file = await request(files.get(key)) as CachedFile | undefined; if (file) files.put({ ...file, touched: now }); await done; } catch { /* LRU hints are not required for reading verified bytes. */ }
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async getBlock(scope: FileSyncScope, id: string): Promise<Uint8Array<ArrayBuffer> | null> {
|
|
114
|
+
const scopeKey = fileSyncScopeKey(scope), key = blockKey(scopeKey, id), hot = this.memory.get(key);
|
|
115
|
+
if (hot) { this.touch(scopeKey); return new Uint8Array(hot.bytes.slice(0)); }
|
|
116
|
+
const db = await this.db(); if (!db) return null;
|
|
117
|
+
try { const tx = db.transaction("blocks", "readonly"); const item = await request(tx.objectStore("blocks").get(key)) as CachedBlock | undefined; if (item && (!isBuffer(item.bytes) || item.scope !== scopeKey || item.id !== id)) { await this.removeBlock(scope, id); return null; } if (item) this.touch(scopeKey); return item ? new Uint8Array(item.bytes) : null; }
|
|
118
|
+
catch { this.fallback = true; return null; }
|
|
119
|
+
}
|
|
120
|
+
private async deleteDiskFile(tx: IDBTransaction, key: string) {
|
|
121
|
+
const blocks = tx.objectStore("blocks"), index = blocks.index("scope");
|
|
122
|
+
await visitCursor(index.openKeyCursor(IDBKeyRange.only(key)), item => { blocks.delete(item.primaryKey); });
|
|
123
|
+
tx.objectStore("files").delete(key);
|
|
124
|
+
}
|
|
125
|
+
private memoryPut(scope: FileSyncScope, id: string, bytes: Uint8Array<ArrayBuffer>) {
|
|
126
|
+
const key = fileSyncScopeKey(scope), entryKey = blockKey(key, id);
|
|
127
|
+
let used = [...this.memory.values()].reduce((n, block) => n + block.bytes.byteLength, 0);
|
|
128
|
+
const replaced = this.memory.get(entryKey)?.bytes.byteLength ?? 0; used -= replaced;
|
|
129
|
+
for (const file of [...this.files.values()].sort((a, b) => a.touched - b.touched)) {
|
|
130
|
+
if (used + bytes.byteLength <= this.budget) break;
|
|
131
|
+
if (this.leases.has(file.key)) continue;
|
|
132
|
+
for (const [oldKey, block] of this.memory) if (block.scope === file.key) { used -= block.bytes.byteLength; this.memory.delete(oldKey); }
|
|
133
|
+
this.files.delete(file.key);
|
|
134
|
+
}
|
|
135
|
+
for (const [oldKey, block] of [...this.memory].sort((a, b) => a[1].touched - b[1].touched)) {
|
|
136
|
+
if (used + bytes.byteLength <= this.budget) break;
|
|
137
|
+
if (oldKey === entryKey || this.protectedBlocks.get(block.scope)?.has(block.id)) continue;
|
|
138
|
+
used -= block.bytes.byteLength; this.memory.delete(oldKey); const file = this.files.get(block.scope); if (file) file.bytes -= block.bytes.byteLength;
|
|
139
|
+
}
|
|
140
|
+
if (used + bytes.byteLength > this.budget) return false;
|
|
141
|
+
this.memory.set(entryKey, { key: entryKey, scope: key, id, bytes: bytes.slice().buffer, size: bytes.byteLength, touched: Date.now() });
|
|
142
|
+
const previous = this.files.get(key); this.files.set(key, { ...previous, key, namespace: scope.namespace, bytes: (previous?.bytes ?? 0) + bytes.byteLength - replaced, touched: Date.now() });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
putBlock(scope: FileSyncScope, id: string, bytes: Uint8Array<ArrayBuffer>): Promise<boolean> {
|
|
146
|
+
return this.serialize(async () => {
|
|
147
|
+
const db = await this.db();
|
|
148
|
+
if (!db || this.fallback) { this.fallback = true; return this.memoryPut(scope, id, bytes); }
|
|
149
|
+
const key = fileSyncScopeKey(scope), entryKey = blockKey(key, id);
|
|
150
|
+
let transaction: IDBTransaction | undefined;
|
|
151
|
+
try {
|
|
152
|
+
if (!this.estimate || Date.now() - this.estimate.at > 5000) {
|
|
153
|
+
const values = await request(db.transaction("files", "readonly").objectStore("files").getAll()) as CachedFile[];
|
|
154
|
+
if (values.some(file => !Number.isSafeInteger(file.bytes) || file.bytes < 0)) throw new Error("文件缓存计账已损坏");
|
|
155
|
+
const owned = values.reduce((sum, file) => sum + file.bytes, 0);
|
|
156
|
+
let available = this.budget;
|
|
157
|
+
let estimateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
try {
|
|
159
|
+
const estimate = await Promise.race([navigator.storage?.estimate(), new Promise<undefined>(resolve => { estimateTimer = setTimeout(() => resolve(undefined), 1000); })]);
|
|
160
|
+
if (estimate?.quota !== undefined && estimate.usage !== undefined) available = Math.min(available, Math.max(0, estimate.quota - estimate.usage + owned));
|
|
161
|
+
} catch { /* Best-effort estimates cannot disable viewing. */ }
|
|
162
|
+
finally { if (estimateTimer !== undefined) clearTimeout(estimateTimer); }
|
|
163
|
+
this.estimate = { at: Date.now(), available };
|
|
164
|
+
}
|
|
165
|
+
const available = this.estimate.available;
|
|
166
|
+
// Read accounting and mutate it under the same IndexedDB write lock,
|
|
167
|
+
// including writers in other tabs or cache instances.
|
|
168
|
+
const update = transaction = db.transaction(["blocks", "files"], "readwrite"), committed = completed(update);
|
|
169
|
+
const existing = await request(update.objectStore("blocks").get(entryKey)) as CachedBlock | undefined;
|
|
170
|
+
const all = await request(update.objectStore("files").getAll()) as CachedFile[];
|
|
171
|
+
if (all.some(file => !Number.isSafeInteger(file.bytes) || file.bytes < 0)) throw new Error("文件缓存计账已损坏");
|
|
172
|
+
let used = all.reduce((sum, file) => sum + file.bytes, 0);
|
|
173
|
+
const added = bytes.byteLength - (isBuffer(existing?.bytes) ? existing!.bytes.byteLength : 0);
|
|
174
|
+
for (const file of all.sort((a, b) => a.touched - b.touched)) {
|
|
175
|
+
if (used + added <= available) break;
|
|
176
|
+
if (this.leases.has(file.key) || file.key === key) continue;
|
|
177
|
+
await this.deleteDiskFile(update, file.key); used -= file.bytes;
|
|
178
|
+
}
|
|
179
|
+
if (used + added > available) await visitCursor(update.objectStore("blocks").index("lru").openKeyCursor(), item => {
|
|
180
|
+
if (used + added <= available) return false;
|
|
181
|
+
const [scopeKey, id] = JSON.parse(String(item.primaryKey)) as [string, string];
|
|
182
|
+
if (String(item.primaryKey) !== entryKey && !this.protectedBlocks.get(scopeKey)?.has(id)) {
|
|
183
|
+
const size = Number((item.key as number[])[1]); if (!Number.isSafeInteger(size) || size < 0) throw new Error("文件缓存计账已损坏");
|
|
184
|
+
used -= size; update.objectStore("blocks").delete(item.primaryKey);
|
|
185
|
+
const file = all.find(value => value.key === scopeKey); if (file) { file.bytes = Math.max(0, file.bytes - size); update.objectStore("files").put(file); }
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
if (used + added > available) { await committed; return false; }
|
|
189
|
+
const previous = all.find(file => file.key === key);
|
|
190
|
+
update.objectStore("blocks").put({ key: entryKey, scope: key, id, bytes: bytes.slice().buffer, size: bytes.byteLength, touched: Date.now() } satisfies CachedBlock);
|
|
191
|
+
update.objectStore("files").put({ ...previous, key, namespace: scope.namespace, bytes: Math.max(0, (previous?.bytes ?? 0) + added), touched: Date.now() } satisfies CachedFile);
|
|
192
|
+
await committed; return true;
|
|
193
|
+
} catch { try { transaction?.abort(); } catch { /* A failed request may already have aborted it. */ } this.fallback = true; return this.memoryPut(scope, id, bytes); }
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
removeBlock(scope: FileSyncScope, id: string) {
|
|
197
|
+
return this.serialize(async () => {
|
|
198
|
+
const key = fileSyncScopeKey(scope), entryKey = blockKey(key, id), previous = this.memory.get(entryKey); this.memory.delete(entryKey);
|
|
199
|
+
const memoryFile = this.files.get(key); if (memoryFile && previous) memoryFile.bytes = Math.max(0, memoryFile.bytes - previous.bytes.byteLength);
|
|
200
|
+
const db = await this.db(); if (!db) return;
|
|
201
|
+
try {
|
|
202
|
+
const tx = db.transaction(["blocks", "files"], "readwrite"), done = completed(tx);
|
|
203
|
+
const block = await request(tx.objectStore("blocks").get(entryKey)) as CachedBlock | undefined;
|
|
204
|
+
const file = await request(tx.objectStore("files").get(key)) as CachedFile | undefined;
|
|
205
|
+
tx.objectStore("blocks").delete(entryKey);
|
|
206
|
+
if (file && block) tx.objectStore("files").put({ ...file, bytes: Math.max(0, file.bytes - (isBuffer(block.bytes) ? block.bytes.byteLength : Number(block.size) || 0)) });
|
|
207
|
+
await done;
|
|
208
|
+
} catch { this.fallback = true; }
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
authorize(scope: FileSyncScope, revision: string) {
|
|
212
|
+
return this.serialize(async () => {
|
|
213
|
+
const key = fileSyncScopeKey(scope), db = await this.db();
|
|
214
|
+
const remember = () => { const old = this.files.get(key); this.files.set(key, { ...old, key, namespace: scope.namespace, bytes: old?.bytes ?? 0, touched: Date.now(), authorizedRevision: revision }); };
|
|
215
|
+
if (!db || this.fallback) { remember(); return; }
|
|
216
|
+
try { const tx = db.transaction("files", "readwrite"), done = completed(tx), files = tx.objectStore("files"); const old = await request(files.get(key)) as CachedFile | undefined; files.put({ ...old, key, namespace: scope.namespace, bytes: old?.bytes ?? 0, touched: Date.now(), authorizedRevision: revision }); await done; }
|
|
217
|
+
catch { this.fallback = true; remember(); }
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
putManifest(scope: FileSyncScope, manifest: FileSyncManifest) {
|
|
221
|
+
return this.serialize(async () => {
|
|
222
|
+
const key = fileSyncScopeKey(scope), db = await this.db();
|
|
223
|
+
if (!db || this.fallback) { const previous = this.files.get(key); if (previous?.authorizedRevision && previous.authorizedRevision !== manifest.revision) return; this.files.set(key, { ...previous, key, namespace: scope.namespace, bytes: previous?.bytes ?? 0, touched: Date.now(), manifest }); return; }
|
|
224
|
+
try { const tx = db.transaction("files", "readwrite"), done = completed(tx); const previous = await request(tx.objectStore("files").get(key)) as CachedFile | undefined; if (!previous?.authorizedRevision || previous.authorizedRevision === manifest.revision) tx.objectStore("files").put({ ...previous, key, namespace: scope.namespace, bytes: previous?.bytes ?? 0, touched: Date.now(), manifest }); await done; }
|
|
225
|
+
catch { this.fallback = true; }
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async getManifest(scope: FileSyncScope): Promise<FileSyncManifest | null> {
|
|
229
|
+
const key = fileSyncScopeKey(scope); if (this.files.get(key)?.manifest) return this.files.get(key)!.manifest!;
|
|
230
|
+
const db = await this.db(); if (!db) return null;
|
|
231
|
+
try { const tx = db.transaction("files", "readonly"); return ((await request(tx.objectStore("files").get(key))) as CachedFile | undefined)?.manifest ?? null; } catch { return null; }
|
|
232
|
+
}
|
|
233
|
+
purgeFile(scope: FileSyncScope) {
|
|
234
|
+
return this.serialize(async () => {
|
|
235
|
+
const key = fileSyncScopeKey(scope); this.files.delete(key); this.touches.delete(key);
|
|
236
|
+
for (const [id, block] of this.memory) if (block.scope === key) this.memory.delete(id);
|
|
237
|
+
const db = await this.db(); if (!db) return;
|
|
238
|
+
try { const tx = db.transaction(["blocks", "files"], "readwrite"), done = completed(tx); await this.deleteDiskFile(tx, key); await done; }
|
|
239
|
+
catch { this.fallback = true; }
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
purgeNamespace(namespace: string) {
|
|
243
|
+
return this.serialize(async () => {
|
|
244
|
+
for (const [key, file] of this.files) if (file.namespace === namespace) { this.files.delete(key); for (const [id, block] of this.memory) if (block.scope === key) this.memory.delete(id); }
|
|
245
|
+
const db = await this.db(); if (!db) return;
|
|
246
|
+
try { const tx = db.transaction(["blocks", "files"], "readwrite"), done = completed(tx); const files = await request(tx.objectStore("files").getAll()) as CachedFile[]; for (const file of files) if (file.namespace === namespace) await this.deleteDiskFile(tx, file.key); await done; } catch { this.fallback = true; }
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
async close() { await this.writes; (await this.database)?.close(); }
|
|
250
|
+
}
|