feat(web): space roles, upload queue with pipelined frames, background resume and faster sending

This commit is contained in:
justme committed 2026-10-16 19:42:33 +08:00
1 parent 96c4335cb5
commit a574583943
39 files changed
+1515 -194

No files matched your search

+2 -2
View File
@@ -278,8 +278,8 @@ export async function inviteChannelUser(channelId: string, input: string): Promi
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) });
}
export async function listChannelInvitations(channelId: string): Promise<Invitation[]> {
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise<Invitation[]>);
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read') }).then((r) => r.json() as Promise<Invitation[]>);
}
export async function revokeChannelInvitation(channelId: string, input: string): Promise<void> {
const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders('owner') });
const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read') });
}
@@ -214,6 +214,8 @@ export async function signObjectDeletion(resource: SavedResource, session: Chann
const { state } = await readDataKeys(resource, session);
const object = state.objects.find(row => row.itemId === itemId);
const signed = object?.event;
// 末帧从未上传的对象没有签名清单:没有可签的内容,服务端允许作者和空间主直接删除
if (object && !signed?.signature) return {};
if (!signed?.manifestHash || !signed.frameCount || (object?.domain !== 'chat' && object?.domain !== 'files'))
throw new Error('对象缺少已验签的密文清单,不能生成删除事件');
const manifestHash = fromBase64Url(signed.manifestHash, 32);
@@ -231,6 +233,8 @@ export async function verifyObjectDeletion(resource: SavedResource, session: Cha
}
const deleted = object?.deleteEvent;
if (resource.mode === 'raw' && !deleted?.signature) return true;
// 没传完的上传从未发布过有效内容,删掉它不需要事件(见 signObjectDeletion)
if (object && !object.event?.signature && !deleted?.signature) return true;
if (!object || !deleted?.signature || !deleted.eventId || !deleted.kind || !deleted.signerDeviceId
|| !deleted.signerRootPublicKey || !deleted.signerDevicePublicKey || !deleted.deviceCertificateSignature
|| !object.authorAccount || !object.event?.manifestHash || !object.event.frameCount || (object.domain !== 'chat' && object.domain !== 'files')) return false;
+8 -93
View File
@@ -1,21 +1,12 @@
import { removeChannelItem } from './channelItems';
import { queueFileCatalog } from './channelThreads';
import { enqueueFileUploads } from './uploadQueue';
import { senderSnapshot } from './senderSnapshot';
import { prepareObject } from './channelDataKeys';
import { errorMessage } from '../errors';
import { saveResource, queueLocation } from '../database';
import { previewKindOf } from '@/lib/fileKind';
import type { FileItem, FileUploadOptions, Item } from '../types';
import { queueLocation } from '../database';
import type { FileUploadOptions, Item } from '../types';
import { blobs, getSession } from './sessions';
import { deviceIdentity } from './presentation';
import { accountDisplay } from './accountSession';
import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer';
import { flushOutbox, persistPlan } from './transfer';
import { publishWorkspace } from './presentation';
export async function sendText(resourceId: string, text: string, threadId?: string,
@@ -33,93 +24,17 @@ export async function sendText(resourceId: string, text: string, threadId?: stri
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch,
eventKind: event?.kind ?? 'chat.create', ...(event?.targetItemId ? { targetItemId: event.targetItemId } : {}) });
await queueLocation(resourceId, item.id, threadId);
const uploaded = await flushOutbox(resource, session);
// 只发这条消息自己的帧,不被队列里已有的大文件堵住
const uploaded = await flushOutbox(resource, session, undefined, { only: new Set([plan.itemId]) });
publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } });
if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' });
return { itemId: plan.itemId, uploaded };
}
/** 文件进入该空间的上传队列:一次传一个,其余排队。文件内部的帧并发上传,见 uploadQueue */
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void> {
if (!threadId) throw new Error('请先选择频道');
const { session, resource } = await getSession(resourceId);
const options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
const device = await deviceIdentity(session.endpointSecret);
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
const author = await senderSnapshot();
for (const [index, file] of files.entries()) {
const localId = localIds?.[index];
const ts = Date.now();
let item: FileItem | undefined;
try {
const prepared = await prepareObject(resource, session, threadId, 'files');
const plan = session.encryptFile(file, {}, {
itemId: prepared.itemIdBytes,
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
ts, maxFrameData, threadId, folderId,
});
item = {
id: plan.itemId, kind: 'file', ts, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
...(author ? { author } : {}),
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
remaining: plan.gated
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
: null,
preview: previewKindOf(file.type, file.name), gated: plan.gated,
};
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item];
await saveResource(resource);
blobs.set(`${resourceId}:${item.id}`, file);
publishWorkspace(resourceId, { t: 'item', item });
const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => {
if (!item) return;
item = {
...item,
state: {
kind: 'receiving',
progress: Math.max(0, Math.min(0.99, progress)),
phase,
...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }),
},
};
publishWorkspace(resourceId, { t: 'item', item });
};
const planned = await persistPlan(resource, plan, (_frames) => {
const processed = Math.min(file.size, _frames * MAX_FRAME_DATA);
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
}, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.publish' });
const uploadedFrames = new Set<number>();
await queueFileCatalog(resource, session, item);
await queueLocation(resourceId, item.id, threadId, folderId);
let uploadedBytes = 0;
const uploadStartedAt = performance.now();
const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => {
if (frame.itemId !== item?.id) return;
if (complete && !uploadedFrames.has(frame.index)) {
uploadedFrames.add(frame.index);
uploadedBytes += totalBytes;
}
const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes);
const elapsed = Math.max(1, performance.now() - uploadStartedAt);
updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed);
});
item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } };
resource.localItems = resource.localItems.map((entry) => entry.id === item?.id ? item! : entry);
await saveResource(resource);
publishWorkspace(resourceId, { t: 'item', item });
if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开空间并连接网络后,可以继续发送已加密内容。' });
} catch (error) {
if (item) {
item = { ...item, state: { kind: 'incomplete' } };
resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === item?.id ? item! : entry);
await saveResource(resource);
publishWorkspace(resourceId, { t: 'item', item });
}
publishWorkspace(resourceId, { t: 'error', message: `文件上传失败:${errorMessage(error)}` });
throw error;
}
}
return enqueueFileUploads(resourceId, files, suppliedOptions, threadId, folderId, localIds);
}
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
+10 -2
View File
@@ -2,13 +2,13 @@ import type { ChannelSession } from '@webdrop/core';
import type { SavedResource } from '../database';
import type { FileItem } from '../types';
import { persistPlan } from './transfer';
import { changeWorkspace, createWorkspaceChannel, createWorkspaceGroup } from './workspaceStructure';
import { changeWorkspace, createWorkspaceChannel, createWorkspaceGroup, createWorkspaceRole } from './workspaceStructure';
import { getSession } from './sessions';
import { initializeChannelKeys, prepareObject } from './channelDataKeys';
import { toPublishedFile } from './fileCatalog';
import { apiFetch } from './http';
import { WORKSPACE_APP, type ResourceControl } from '../protocol/workspace';
import type { WorkspaceChannelRequest, WorkspaceGroupRequest, WorkspaceNameRequest, WorkspaceFolderRequest, WorkspaceMoveRequest } from '../generated/api';
import type { WorkspaceChannelRequest, WorkspaceGroupRequest, WorkspaceNameRequest, WorkspaceFolderRequest, WorkspaceMoveRequest, WorkspaceRoleRequest, WorkspaceRoleMembersRequest } from '../generated/api';
const encoder = new TextEncoder();
@@ -27,6 +27,14 @@ export async function renameGroup(resourceId: string, id: string, name: string):
export async function deleteGroup(resourceId: string, id: string): Promise<void> {
await changeWorkspace(resourceId, 'groups/' + id, 'DELETE');
}
export async function createRole(resourceId: string, role: WorkspaceRoleRequest): Promise<string> { return createWorkspaceRole(resourceId, role); }
export async function updateRole(resourceId: string, id: string, role: WorkspaceRoleRequest): Promise<void> {
await changeWorkspace(resourceId, 'roles/' + id, 'PATCH', role);
}
export async function deleteRole(resourceId: string, id: string): Promise<void> { await changeWorkspace(resourceId, 'roles/' + id, 'DELETE'); }
export async function setRoleMembers(resourceId: string, id: string, members: string[]): Promise<void> {
await changeWorkspace(resourceId, 'roles/' + id + '/members', 'PUT', { members } satisfies WorkspaceRoleMembersRequest);
}
export async function groupThread(resourceId: string, id: string, groupId?: string): Promise<void> {
await changeWorkspace(resourceId, 'channels/' + id + '/group', 'PUT', { groupId: groupId ?? null } satisfies WorkspaceGroupRequest);
}
+12 -6
View File
@@ -5,6 +5,7 @@ import { publishWorkspace } from './presentation';
import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents';
import { loadWorkspace } from './workspaceStructure';
import { flushOutbox } from './transfer';
import { resumeOutbox } from './resumeUploads';
import { socketStream, sseStream, delay, type StreamConsumer } from './transports';
import { errorMessage } from '../errors';
import { saveResource } from '../database';
@@ -43,19 +44,24 @@ export async function connectChannel(id: string): Promise<void> {
let initialized = false;
let transport = 0;
let backoff = 1000;
flushTimer = setInterval(() => {
if (!signal.aborted) void flushOutbox(resource, session).catch((error: unknown) => {
publishWorkspace(id, { t: 'error', message: errorMessage(error) });
});
}, 2500);
// 离线队列的续传在后台跑:几个 GB 的遗留不能挡住频道加载,也不能叠出一堆并行的续传
let resuming = false;
const resume = () => {
if (signal.aborted || resuming) return;
resuming = true;
void resumeOutbox(resource, session, signal).then(() => flushOutbox(resource, session))
.catch((error: unknown) => { if (!signal.aborted) publishWorkspace(id, { t: 'error', message: errorMessage(error) }); })
.finally(() => { resuming = false; });
};
flushTimer = setInterval(resume, 2500);
while (!signal.aborted) {
try {
if (!initialized) {
[stream.cursor] = await Promise.all([syncChannel(resource, session, signal), channelMetadata(resource, session), joining]);
if (signal.aborted) break;
await flushOutbox(resource, session);
initialized = true;
publishWorkspace(id, { t: 'ready' });
resume();
}
stream.online();
if (transport === 0) await socketStream(id, session, signal, stream);
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import type { QueuedFrame } from '../database';
import { FrameStash } from './frameStash';
const frame = (index: number): QueuedFrame => ({ key: `k${index}`, resourceId: 'r', itemId: 'i', index, bytes: new Uint8Array(10), extraHeaders: {}, createdAt: index });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function fakeDisk(writeMs = 5) {
const stored = new Set<string>(); const writes: string[] = []; const removes: string[] = [];
return {
stored, writes, removes,
put: async (f: QueuedFrame) => { await sleep(writeMs); stored.add(f.key); writes.push(f.key); },
remove: async (key: string) => { stored.delete(key); removes.push(key); },
};
}
describe('FrameStash', () => {
it('宽限期内就被确认的帧:根本不写盘', async () => {
const disk = fakeDisk(5);
const stash = new FrameStash(disk.put, disk.remove, 8, 100, 4);
const frames = [0, 1, 2, 3].map(frame);
for (const f of frames) await stash.add(f);
await sleep(20);
for (const f of frames) stash.accepted(f);
await stash.drain();
expect(disk.writes).toEqual([]);
expect(disk.stored.size).toBe(0);
});
it('超过宽限期还没确认的帧会写入,之后确认时再删除', async () => {
const disk = fakeDisk(1);
const stash = new FrameStash(disk.put, disk.remove, 8, 30, 4);
const first = frame(0); await stash.add(first);
await sleep(80);
expect(disk.stored.has('k0')).toBe(true);
stash.accepted(first);
await sleep(10);
expect(disk.stored.has('k0')).toBe(false);
});
it('断网时(一直不确认)drain 之后全部帧都已落盘,续传才有东西可发', async () => {
const disk = fakeDisk(1);
const stash = new FrameStash(disk.put, disk.remove, 3, 5000, 4);
for (let index = 0; index < 10; index += 1) await stash.add(frame(index));
await stash.drain();
expect(disk.stored.size).toBe(10);
});
it('积压超过上限时 add 会等待写盘,不让内存里越堆越多', async () => {
const disk = fakeDisk(30);
const stash = new FrameStash(disk.put, disk.remove, 2, 5000, 1);
let added = 0;
const producer = (async () => { for (let index = 0; index < 6; index += 1) { await stash.add(frame(index)); added += 1; } })();
await sleep(40);
expect(added).toBeLessThan(6);
await producer; await stash.drain();
expect(disk.stored.size).toBe(6);
});
it('取消后不会再写入新的帧', async () => {
const disk = fakeDisk(5);
const stash = new FrameStash(disk.put, disk.remove, 8, 20, 4);
await stash.add(frame(0)); await stash.add(frame(1));
await stash.discard();
const before = disk.writes.length;
await sleep(60);
expect(disk.writes.length).toBe(before);
});
it('写盘失败会让加密端停下来并抛出', async () => {
const stash = new FrameStash(async () => { throw new Error('quota'); }, async () => {}, 1, 0, 0);
await expect((async () => { for (let index = 0; index < 5; index += 1) await stash.add(frame(index)); await stash.drain(); })()).rejects.toThrow('quota');
});
});
+85
View File
@@ -0,0 +1,85 @@
import type { QueuedFrame } from '../database';
interface Pending { frame: QueuedFrame; at: number }
/**
* 离线队列的「后写」:加密好的帧先在内存里直接上传,只有没能及时上传的才写进 IndexedDB 留作续传保险。
* 每帧写盘要几十毫秒,同步写会把上传速度压到约 25 MB/s;网络够快时帧在宽限期内就已被确认,根本不用写。
* 网络慢或断了,帧超过宽限期(或积压太多)就在后台写入,离线续传不受影响(protocol §5:重试发送的仍是同一份字节)。
*/
export class FrameStash {
private pending: Pending[] = [];
private readonly written = new Set<string>();
private writing: Promise<void> | undefined;
private error: unknown;
private forced = false;
private waiters: (() => void)[] = [];
constructor(
private readonly put: (frame: QueuedFrame) => Promise<void>,
private readonly remove: (key: string) => Promise<void>,
/** 还没写入的帧超过这个数就让加密端等一等,避免断网后内存里越堆越多 */
private readonly maxPending = 16,
/** 帧在内存里等服务端确认的宽限时间,超过才写盘 */
private readonly graceMs = 400,
/** 积压超过这个数就不再等宽限期,马上写最早的。要大于上传流水线本身在途+缓冲的帧数(约 10),否则正常上传也会被逼着写盘 */
private readonly eagerAbove = 12,
) {}
/** 新加密出一帧。积压太多时等待写盘追上来 */
async add(frame: QueuedFrame): Promise<void> {
if (this.error) throw this.error;
this.pending.push({ frame, at: performance.now() });
this.kick();
this.notify();
while (this.pending.length > this.maxPending && !this.error) await this.wait();
if (this.error) throw this.error;
}
/** 服务端确认了这一帧:还没写盘的不用写了,已经写了的从队列里删掉 */
accepted(frame: QueuedFrame): void {
const at = this.pending.findIndex((entry) => entry.frame.key === frame.key);
if (at >= 0) { this.pending.splice(at, 1); this.notify(); return; }
if (this.written.delete(frame.key)) void this.remove(frame.key).catch(() => undefined);
}
/** 等所有该写的帧都落盘(不再等宽限期)。全部上传完成时这里几乎是空的 */
async drain(): Promise<void> {
this.forced = true; this.kick(); this.notify();
while ((this.pending.length || this.writing) && !this.error) await this.wait();
if (this.error) throw this.error;
}
/** 取消上传:丢掉还没写的帧,等正在写的那一帧结束。已写入的由调用方用 dequeueItem 清掉 */
async discard(): Promise<void> {
this.pending = [];
this.notify();
await this.writing;
}
private kick(): void {
if (this.writing || !this.pending.length) return;
this.writing = (async () => {
try {
while (this.pending.length) {
const head = this.pending[0]!;
const age = performance.now() - head.at;
if (!this.forced && age < this.graceMs && this.pending.length <= this.eagerAbove && this.pending.length < this.maxPending) {
// 还在宽限期里:等它被确认(会被移出 pending)、或有新帧到来、或宽限期到
await Promise.race([this.wait(), new Promise((resolve) => setTimeout(resolve, this.graceMs - age))]);
continue;
}
await this.put(head.frame);
// 写的过程中如果已经被确认,accepted() 已把它移出 pending:这次写入白做了,补一个删除
if (this.pending[0] === head) { this.pending.shift(); this.written.add(head.frame.key); }
else await this.remove(head.frame.key).catch(() => undefined);
this.notify();
}
} catch (error) { this.error = error; }
finally { this.writing = undefined; this.notify(); }
})();
}
private wait(): Promise<void> { return new Promise((resolve) => { this.waiters.push(resolve); }); }
private notify(): void { const waiting = this.waiters; this.waiters = []; for (const resume of waiting) resume(); }
}
+27 -7
View File
@@ -2,7 +2,7 @@ import { supportsApiContract } from '../contract';
import { accountToken } from './accountSession';
import type { ServerInfoBody } from '../generated/api';
export type ApiError = Error & { status?: number; code?: string };
export type ApiError = Error & { status?: number; code?: string; /** 服务端要求的等待秒数(429 的 Retry-After) */ retryAfter?: number };
let apiBase = '/api/v1';
let contractCheck: Promise<ServerInfoBody> | undefined;
@@ -69,24 +69,37 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise<Re
export async function apiUpload(
path: string,
headers: Record<string, string>,
body: ArrayBuffer,
body: ArrayBuffer | Uint8Array | Blob,
onProgress: (loadedBytes: number, totalBytes: number) => void,
signal?: AbortSignal,
/** 这么久没有任何上传进展就放弃这次请求(弱网下连接静默卡死),由调用方重试 */
stallTimeoutMs?: number,
): Promise<void> {
try {
await getServerInfo();
const response = await new Promise<Response>((resolve, reject) => {
if (signal?.aborted) { reject(new DOMException('上传已取消', 'AbortError')); return; }
const xhr = new XMLHttpRequest();
signal?.addEventListener('abort', () => xhr.abort(), { once: true });
let stalled = false; let stallTimer: ReturnType<typeof setTimeout> | undefined;
const watch = () => {
if (!stallTimeoutMs) return;
clearTimeout(stallTimer);
stallTimer = setTimeout(() => { stalled = true; xhr.abort(); }, stallTimeoutMs);
};
xhr.open('POST', apiPath(path));
xhr.withCredentials = false;
xhr.responseType = 'text';
xhr.setRequestHeader('Cache-Control', 'no-store');
// 不要设 Cache-Control 请求头:它不在服务端 CORS 允许的头里,跨域部署(API 在另一个域名)时预检会失败;POST 本来也不会被缓存
const token = accountToken();
if (token) xhr.setRequestHeader('X-WD-Account', token);
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
xhr.upload.onprogress = (event) => {
watch();
if (event.lengthComputable) onProgress(event.loaded, event.total);
};
xhr.onload = () => {
clearTimeout(stallTimer);
if (xhr.status === 0) {
reject(new Error('network_error'));
return;
@@ -94,20 +107,27 @@ export async function apiUpload(
const responseHeaders = new Headers();
const contentType = xhr.getResponseHeader('Content-Type');
if (contentType) responseHeaders.set('Content-Type', contentType);
const retryAfter = xhr.getResponseHeader('Retry-After');
if (retryAfter) responseHeaders.set('Retry-After', retryAfter);
resolve(new Response(xhr.responseText, { status: xhr.status, statusText: xhr.statusText, headers: responseHeaders }));
};
xhr.onerror = () => reject(new Error('network_error'));
xhr.onabort = () => reject(new DOMException('上传已取消', 'AbortError'));
xhr.send(body);
xhr.onerror = () => { clearTimeout(stallTimer); reject(new Error('network_error')); };
xhr.onabort = () => { clearTimeout(stallTimer); reject(stalled ? new Error('network_error') : new DOMException('上传已取消', 'AbortError')); };
watch();
xhr.send(body as XMLHttpRequestBodyInit);
});
if (!response.ok) {
const result = await response.json().catch(() => null) as { error?: unknown } | null;
const code = typeof result?.error === 'string' && /^[a-z_]{1,40}$/.test(result.error) ? result.error : 'request_failed';
throw fail(response.status, code);
const error = fail(response.status, code);
const wait = Number(response.headers.get('Retry-After'));
if (Number.isFinite(wait) && wait > 0) error.retryAfter = wait;
throw error;
}
} catch (error) {
if (error instanceof Error && 'status' in error) throw error;
if (error instanceof Error && error.message === 'api_contract_mismatch') throw error;
if (signal?.aborted) throw error;
throw new Error('网络连接失败');
}
}
@@ -22,7 +22,7 @@ describe('resource and rendering boundaries', () => {
it('never synchronizes draft content or server structure snapshots inside the key vault', () => {
const resource: SavedResource = { id: 'a', kind: 'share', mode: 'enc', role: 'owner', link: 'key', createdAt: 1, expiresAt: 2,
endpointSecret: new Uint8Array(32), maxMembers: 0, draftItems: [], manifestEntries: [],
workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], folders: [], locations: [] }, people: [] } };
workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], roles: [], rolesSeeded: true, folders: [], locations: [] }, people: [] } };
const exported = exportResource(resource);
for (const field of ['draftItems', 'manifestEntries', 'workspaceMetadata', 'endpointSecret']) expect(exported).not.toHaveProperty(field);
});
@@ -0,0 +1,87 @@
import { dequeueItem, queuedFrame, queuedFrameKeys, saveResource, type QueuedFrame, type SavedResource } from '../database';
import type { ChannelSession } from '@webdrop/core';
import { publishWorkspace } from './presentation';
import { pipelinedItems, uploadFrame } from './transfer';
import { FrameUploader, TransferMeter } from './uploadPipeline';
const FRAME_CONCURRENCY = 4;
const keyOf = (key: string) => { const [, itemId = '', index = '0'] = key.split(':'); return { itemId, index: Number(index) }; };
/** 内容事件的签名头只在末帧上;离线队列里没有带签名头的末帧,说明上次加密被打断,这个条目无法补完 */
const isFinalFrame = (frame: QueuedFrame): boolean => 'X-WD-Event-Signature' in frame.extraHeaders;
/**
* 续传离线队列里上次没发完的内容:按条目分组,同一个条目内的帧按 FrameUploader 的规则并发上传,
* 进度会回到聊天里的文件卡片。在后台运行,不挡住频道加载。
* 返回 true 表示没有遗留(或都发完了)。
*/
export async function resumeOutbox(resource: SavedResource, session: ChannelSession, signal?: AbortSignal): Promise<boolean> {
const items = new Map<string, number[]>();
for (const key of await queuedFrameKeys(resource.id)) {
const { itemId, index } = keyOf(key);
if (pipelinedItems.has(`${resource.id}:${itemId}`)) continue;
(items.get(itemId) ?? items.set(itemId, []).get(itemId)!).push(index);
}
// 上次会话里还显示「传输中」的文件,如果既没有在续传、也没有可续传的帧,就是被打断的:标成未完成,用户可以删除
const stale = (resource.localItems ?? []).filter((item) => item.kind === 'file' && item.state.kind === 'receiving'
&& !pipelinedItems.has(`${resource.id}:${item.id}`) && !items.has(item.id));
for (const item of stale) await markIncomplete(resource, item.id);
let complete = true;
for (const [itemId, indices] of items) {
if (signal?.aborted) return false;
if (!await resumeItem(resource, session, itemId, indices.sort((a, b) => a - b), signal)) complete = false;
}
return complete;
}
async function resumeItem(resource: SavedResource, session: ChannelSession, itemId: string, indices: number[], signal?: AbortSignal): Promise<boolean> {
const keyFor = (index: number) => `${resource.id}:${itemId}:${String(index).padStart(10, '0')}`;
const last = indices[indices.length - 1]!;
const lastFrame = await queuedFrame(keyFor(last));
// 没有末帧的条目补不完整:留给全局队列处理,或等用户重新发送
if (!lastFrame || !isFinalFrame(lastFrame)) { await markIncomplete(resource, itemId); return true; }
const queued = new Set(indices);
const accepted: number[] = [];
for (let index = 0; index <= last; index += 1) if (!queued.has(index)) accepted.push(index);
const local = resource.localItems?.find((item) => item.id === itemId);
const controller = new AbortController();
signal?.addEventListener('abort', () => controller.abort(), { once: true });
const meter = new TransferMeter();
const total = last + 1;
let lastPublished = 0;
const publish = (sent: number, force = false) => {
if (!local || local.kind !== 'file') return;
const now = performance.now();
if (!force && now - lastPublished < 150) return;
lastPublished = now;
const speed = meter.speed();
publishWorkspace(resource.id, { t: 'item', item: { ...local, state: { kind: 'receiving', progress: Math.min(0.99, (accepted.length + sent) / total), phase: 'uploading', ...(speed ? { speedBytesPerSecond: speed } : {}) } } });
};
const uploader = new FrameUploader({
gated: local?.kind === 'file' ? Boolean(local.gated) : false, concurrency: FRAME_CONCURRENCY, signal: controller.signal, alreadyAccepted: accepted,
send: (frame, hooks, s) => uploadFrame(resource, session, frame, hooks, s),
onBytes: (bytes) => { meter.add(bytes); publish(bytes / Math.max(1, lastFrame.bytes.byteLength)); },
});
try {
for (const index of indices) {
const frame = index === last ? lastFrame : await queuedFrame(keyFor(index));
if (frame) await uploader.push(frame, index === last);
}
const done = await uploader.finish();
if (local && local.kind === 'file') publishWorkspace(resource.id, { t: 'item', item: { ...local, state: done ? { kind: 'complete' } : { kind: 'incomplete' } } });
return done;
} catch (error) {
if (controller.signal.aborted) return false;
// 服务端明确拒绝(例如已被删除):这些帧再发也没用,清掉
await dequeueItem(resource.id, itemId);
throw error;
}
}
async function markIncomplete(resource: SavedResource, itemId: string): Promise<void> {
const item = resource.localItems?.find((entry) => entry.id === itemId);
if (!item || item.state.kind === 'incomplete') return;
const next = { ...item, state: { kind: 'incomplete' as const } };
resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === itemId ? next : entry);
await saveResource(resource);
publishWorkspace(resource.id, { t: 'item', item: next });
}
+88 -40
View File
@@ -1,5 +1,5 @@
import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, nextQueuedLocation, dequeueLocation, type SavedResource } from '../database';
import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, nextQueuedLocation, dequeueLocation, type QueuedFrame, type SavedResource } from '../database';
import { registerItemLocation } from './workspaceStructure';
import { verifyObjectEvent } from './channelDataKeys';
import { apiFetch, apiUpload, type ApiError } from './http';
@@ -11,21 +11,28 @@ import { storeVerifiedDownload } from './downloadStorage';
// Frame plaintext includes a 4-byte length prefix before padding to the 256 KiB tier.
export const MOBILE_UPLOAD_FRAME_DATA_BYTES = 256 * 1024 - 4;
export { MAX_FRAME_DATA };
const UPLOAD_RETRY_DELAYS_MS = [500, 1500, 4000] as const;
/** 正由上传流水线逐帧发送的条目(空间 id:条目 id)。全局续传 flushOutbox 要绕开它们,否则同一帧会发两遍、进度互相抢 */
const pipelined = new Set<string>();
export { pipelined as pipelinedItems };
export function markPipelined(resourceId: string, itemId: string): void { pipelined.add(`${resourceId}:${itemId}`); }
export function unmarkPipelined(resourceId: string, itemId: string): void { pipelined.delete(`${resourceId}:${itemId}`); }
export async function persistPlan(
resource: SavedResource,
plan: ItemPlan,
onProgress?: (frames: number, bytes: number) => void,
signedEvent?: { channelId: string; domain: 'chat' | 'files'; epoch: number; eventKind: string; targetItemId?: string },
/** 每一帧写入离线队列之后调用;上传流水线借此边加密边上传,返回的 Promise 用来给加密端施加背压 */
onFrame?: (frame: QueuedFrame, final: boolean) => Promise<void>,
): Promise<{ frames: number; bytes: number }> {
const createdAt = Math.max(Date.now(), (await lastQueuedAt(resource.id)) + 1);
let frames = 0;
let bytes = 0;
const frameDigests: Uint8Array[] = [];
let pending: { index: number; bytes: Uint8Array; extraHeaders?: Record<string, string> } | undefined;
const persistFrame = async (frame: NonNullable<typeof pending>, extraHeaders: Record<string, string> = {}) => {
await queueFrame({
const persistFrame = async (frame: NonNullable<typeof pending>, extraHeaders: Record<string, string> = {}, final = false) => {
const queued: QueuedFrame = {
key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`,
resourceId: resource.id,
itemId: plan.itemId,
@@ -33,15 +40,17 @@ export async function persistPlan(
bytes: frame.bytes,
extraHeaders: { ...(frame.index === 0 ? plan.frame0Headers : {}), ...frame.extraHeaders, ...extraHeaders },
createdAt: createdAt + frame.index,
});
};
// 提供了 onFrame 的调用方(上传流水线)自己决定何时落盘,见 frameStash
if (!onFrame) await queueFrame(queued);
frames += 1;
bytes += frame.bytes.byteLength;
onProgress?.(frames, bytes);
await onFrame?.(queued, final);
};
for await (const frame of plan.frames) {
if (pending) await persistFrame(pending);
const frameBuffer = frame.bytes.slice().buffer as ArrayBuffer;
frameDigests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', frameBuffer)));
frameDigests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', frame.bytes)));
pending = frame;
}
if (pending) {
@@ -50,7 +59,7 @@ export async function persistPlan(
epoch: signedEvent.epoch, itemId: plan.itemId, eventKind: signedEvent.eventKind,
...(signedEvent.targetItemId ? { targetItemId: signedEvent.targetItemId } : {}), frameDigests })
: {};
await persistFrame(pending, headers);
await persistFrame(pending, headers, true);
}
return { frames, bytes };
}
@@ -61,25 +70,88 @@ function writeRole(session: ChannelSession): 'owner' | 'write' {
throw new Error('此链接没有上传权限');
}
/** The outbox contains ciphertext only. Every retry sends these exact bytes. */
/** 一次请求这么久没有任何上传进展就放弃重试:弱网下连接可能静默卡死,不能一直等 */
const FRAME_STALL_TIMEOUT_MS = 30_000;
const MAX_UPLOAD_ATTEMPTS = 8;
const MAX_RETRY_WAIT_MS = 60_000;
/** 第 n 次失败后的等待:指数退避加抖动;服务端给了 Retry-After(限流)就按它来 */
export function retryDelayMs(attempt: number, retryAfterSeconds?: number): number {
if (retryAfterSeconds) return Math.min(MAX_RETRY_WAIT_MS, retryAfterSeconds * 1000 + Math.random() * 250);
return Math.min(15_000, 500 * 2 ** attempt) * (0.75 + Math.random() * 0.5);
}
/**
* 上传一帧。可重试的错误(断网、卡住、5xx、429 等)按退避重试,重试发送的永远是同一份字节(protocol §5)。
* 返回 false 表示重试用尽(多半断网),帧仍留在离线队列;不可重试的错误会抛出;取消会抛 AbortError。
* manageQueue 为 false 时调用方自己管理离线队列(frameStash),这里不动它。
*/
export async function uploadFrame(
resource: SavedResource,
session: ChannelSession,
frame: QueuedFrame,
hooks?: { onLoaded?: (loadedBytes: number, totalBytes: number) => void; onAccepted?: () => void; onRetry?: () => void; manageQueue?: boolean },
signal?: AbortSignal,
): Promise<boolean> {
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
let maxLoadedBytes = 0;
for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS; attempt += 1) {
try {
await apiUpload(
path,
{ ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
// 用 Blob 发而不是字节数组:浏览器对字节数组请求体要多拷贝几次,实测上传只有 20–35 MB/s;Blob 走快速通道,单连接 100+ MB/s。
// 每次重试都从同一份 frame.bytes 重新包装,发出的字节完全相同(protocol §5)
new Blob([frame.bytes as BlobPart]),
(loaded, total) => { maxLoadedBytes = Math.max(maxLoadedBytes, loaded); hooks?.onLoaded?.(maxLoadedBytes, total); },
signal,
FRAME_STALL_TIMEOUT_MS,
);
if (hooks?.manageQueue !== false) await dequeueFrame(frame.key);
hooks?.onAccepted?.();
return true;
} catch (error) {
if (signal?.aborted || (error as Error | undefined)?.name === 'AbortError') throw error;
const apiError = error as ApiError;
const retryable = apiError.status === undefined || apiError.status === 408 || apiError.status === 425
|| apiError.status === 429 || apiError.status === 499 || apiError.status >= 500;
if (!retryable) throw error;
if (attempt === MAX_UPLOAD_ATTEMPTS - 1) return false;
hooks?.onRetry?.();
maxLoadedBytes = 0;
await new Promise((resolve) => setTimeout(resolve, retryDelayMs(attempt, apiError.retryAfter)));
}
}
return false;
}
/**
* 把离线队列里的帧(和待登记的位置)发完。默认处理整个空间的队列,但跳过正由上传流水线处理的条目;
* only 指定时只处理这些条目,发文字消息就用它,不被已入队的大文件堵住。
*/
export async function flushOutbox(
resource: SavedResource,
session: ChannelSession,
onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void,
scope?: { only?: ReadonlySet<string> },
): Promise<boolean> {
return serial(`outbox:${resource.id}`, () => flush(resource, session, onFrameProgress));
const lane = scope?.only ? [...scope.only].sort().join(',') : 'all';
return serial(`outbox:${resource.id}:${lane}`, () => flush(resource, session, onFrameProgress, scope));
}
async function flush(
resource: SavedResource,
session: ChannelSession,
onFrameProgress?: (frame: { itemId: string; index: number }, loadedBytes: number, totalBytes: number, complete: boolean) => void,
scope?: { only?: ReadonlySet<string> },
): Promise<boolean> {
const accept = (itemId: string) => !pipelined.has(`${resource.id}:${itemId}`) && (!scope?.only || scope.only.has(itemId));
for (;;) {
const frame = await nextQueuedFrame(resource.id);
const frame = await nextQueuedFrame(resource.id, accept);
if (!frame) {
if (resource.kind !== 'channel') return true;
const location = await nextQueuedLocation(resource.id);
const location = await nextQueuedLocation(resource.id, accept);
if (!location) return true;
try {
await registerItemLocation(resource.id, { id: location.itemId, threadId: location.threadId, folderId: location.folderId });
@@ -91,34 +163,10 @@ async function flush(
}
continue;
}
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
let maxLoadedBytes = 0;
let accepted = false;
for (let attempt = 0; attempt <= UPLOAD_RETRY_DELAYS_MS.length; attempt += 1) {
try {
await apiUpload(
path,
{ ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders },
frame.bytes.slice().buffer as ArrayBuffer,
(loaded, total) => {
maxLoadedBytes = Math.max(maxLoadedBytes, loaded);
onFrameProgress?.(frame, maxLoadedBytes, total, false);
},
);
await dequeueFrame(frame.key);
onFrameProgress?.(frame, frame.bytes.byteLength, frame.bytes.byteLength, true);
accepted = true;
break;
} catch (error) {
const apiError = error as ApiError;
const retryable = apiError.status === undefined || apiError.status === 408 || apiError.status === 425
|| apiError.status === 429 || apiError.status === 499 || apiError.status >= 500;
if (!retryable) throw error;
if (attempt === UPLOAD_RETRY_DELAYS_MS.length) return false;
await new Promise((resolve) => setTimeout(resolve, UPLOAD_RETRY_DELAYS_MS[attempt]));
}
}
const accepted = await uploadFrame(resource, session, frame, {
onLoaded: (loaded, total) => onFrameProgress?.(frame, loaded, total, false),
onAccepted: () => onFrameProgress?.(frame, frame.bytes.byteLength, frame.bytes.byteLength, true),
});
if (!accepted) return false;
}
}
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import type { QueuedFrame } from '../database';
import { estimateCipherBytes, FrameUploader, TransferMeter, type SendFrame } from './uploadPipeline';
const frame = (index: number, size = 100): QueuedFrame => ({ key: `k${index}`, resourceId: 'r', itemId: 'i', index, bytes: new Uint8Array(size), extraHeaders: {}, createdAt: index });
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
/** 记录每一帧开始、结束的顺序和同时在途的最大数量 */
function fakeServer(options: { delay?: (index: number) => number; reject?: (index: number) => 'pause' | 'error' | undefined } = {}) {
const log: string[] = []; let inflight = 0; let peak = 0;
const send: SendFrame = async (f, { onLoaded }) => {
inflight += 1; peak = Math.max(peak, inflight); log.push(`start:${f.index}`);
await new Promise((resolve) => setTimeout(resolve, options.delay?.(f.index) ?? 1));
onLoaded(f.bytes.byteLength);
inflight -= 1;
const verdict = options.reject?.(f.index);
if (verdict === 'error') throw new Error('boom');
if (verdict === 'pause') return false;
log.push(`done:${f.index}`);
return true;
};
return { send, log, peak: () => peak };
}
async function run(uploader: FrameUploader, count: number) {
for (let index = 0; index < count; index += 1) await uploader.push(frame(index), index === count - 1);
return uploader.finish();
}
describe('FrameUploader', () => {
it('受限文件:第 0 帧先传,中间帧并发,末帧等其余帧都确认后才发', async () => {
const server = fakeServer({ delay: (index) => (index === 2 ? 20 : 2) });
const uploader = new FrameUploader({ gated: true, concurrency: 3, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
expect(await run(uploader, 6)).toBe(true);
expect(server.log.indexOf('done:0')).toBeLessThan(server.log.indexOf('start:1'));
expect(server.peak()).toBeGreaterThan(1);
expect(server.peak()).toBeLessThanOrEqual(3);
const start5 = server.log.indexOf('start:5');
for (const index of [0, 1, 2, 3, 4]) expect(server.log.indexOf(`done:${index}`)).toBeLessThan(start5);
});
it('非受限文件:逐帧按顺序上传,不并发', async () => {
const server = fakeServer();
const uploader = new FrameUploader({ gated: false, concurrency: 4, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
expect(await run(uploader, 4)).toBe(true);
expect(server.peak()).toBe(1);
expect(server.log).toEqual(['start:0', 'done:0', 'start:1', 'done:1', 'start:2', 'done:2', 'start:3', 'done:3']);
});
it('只有一帧时直接上传', async () => {
const server = fakeServer();
const uploader = new FrameUploader({ gated: true, concurrency: 4, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
expect(await run(uploader, 1)).toBe(true);
expect(server.log).toEqual(['start:0', 'done:0']);
});
it('某帧重试用尽暂停:不再发新帧,finish 返回 false', async () => {
const server = fakeServer({ reject: (index) => (index === 1 ? 'pause' : undefined) });
const uploader = new FrameUploader({ gated: true, concurrency: 2, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
expect(await run(uploader, 8)).toBe(false);
expect(server.log).not.toContain('start:7');
});
it('不可重试的错误:push 或 finish 抛出,并且不再发新帧', async () => {
const server = fakeServer({ reject: (index) => (index === 1 ? 'error' : undefined) });
const uploader = new FrameUploader({ gated: true, concurrency: 2, send: server.send, signal: new AbortController().signal, onBytes: () => {} });
await expect(run(uploader, 8)).rejects.toThrow('boom');
expect(server.log).not.toContain('start:7');
});
it('积压超过上限时 push 会等待,不让加密一口气跑完整个文件', async () => {
const server = fakeServer({ delay: () => 15 });
const uploader = new FrameUploader({ gated: true, concurrency: 1, send: server.send, signal: new AbortController().signal, onBytes: () => {}, buffer: 2 });
let pushed = 0;
const producer = (async () => { for (let index = 0; index < 6; index += 1) { await uploader.push(frame(index), index === 5); pushed += 1; } })();
await tick(); await tick();
expect(pushed).toBeLessThan(6);
await producer; expect(await uploader.finish()).toBe(true);
});
it('已取消的信号:push 直接中止', async () => {
const controller = new AbortController(); controller.abort();
const uploader = new FrameUploader({ gated: true, concurrency: 2, send: fakeServer().send, signal: controller.signal, onBytes: () => {} });
await expect(uploader.push(frame(0), true)).rejects.toMatchObject({ name: 'AbortError' });
});
it('上报的是已确认加在途的字节数', async () => {
const seen: number[] = [];
const uploader = new FrameUploader({ gated: false, concurrency: 1, send: fakeServer().send, signal: new AbortController().signal, onBytes: (sent) => seen.push(sent) });
await run(uploader, 3);
expect(Math.max(...seen)).toBe(300);
expect(seen[seen.length - 1]).toBe(300);
});
});
describe('FrameUploader 自适应并发', () => {
it('遇到重试就把并发减半,不再占满连接', async () => {
let retried = false; let droppedTo = 0;
const send: SendFrame = async (f, hooks) => {
await new Promise((resolve) => setTimeout(resolve, 5));
if (f.index === 1 && !retried) { retried = true; hooks.onRetry(); droppedTo = uploader.currentConcurrency; }
return true;
};
const uploader: FrameUploader = new FrameUploader({ gated: true, concurrency: 4, send, signal: new AbortController().signal, onBytes: () => {} });
expect(await run(uploader, 20)).toBe(true);
expect(droppedTo).toBe(2);
});
it('连续顺利后并发度慢慢恢复', async () => {
let first = true;
const send: SendFrame = async (f, hooks) => { await new Promise((resolve) => setTimeout(resolve, 1)); if (first && f.index === 1) { first = false; hooks.onRetry(); } return true; };
const uploader = new FrameUploader({ gated: true, concurrency: 4, send, signal: new AbortController().signal, onBytes: () => {} });
expect(await run(uploader, 60)).toBe(true);
expect(uploader.currentConcurrency).toBeGreaterThan(2);
});
});
describe('TransferMeter', () => {
it('用最近一段时间估算速度和剩余时间', () => {
const meter = new TransferMeter(3000);
expect(meter.speed()).toBeUndefined();
meter.add(0, 0); meter.add(1_000_000, 1000); meter.add(2_000_000, 2000);
expect(meter.speed()).toBeCloseTo(1_000_000, -3);
expect(meter.eta(2_000_000, 5_000_000)).toBe(3);
});
it('速度只看窗口内的样本,网络变慢后很快反映出来', () => {
const meter = new TransferMeter(3000);
meter.add(0, 0); meter.add(10_000_000, 1000); meter.add(10_100_000, 5000); meter.add(10_200_000, 7000);
expect(meter.speed()!).toBeLessThan(100_000);
});
});
describe('estimateCipherBytes', () => {
it('按帧数乘第一帧密文长度估算', () => {
expect(estimateCipherBytes(0, 1000, 1016)).toBe(1016);
expect(estimateCipherBytes(2500, 1000, 1016)).toBe(3048);
});
});
@@ -0,0 +1,156 @@
import type { QueuedFrame } from '../database';
/**
* 单个文件的帧上传调度(protocol §5、服务端 AppendFrame 的约束):
* - 第 0 帧先单独上传(它在服务端创建条目);
* - 受限(gated)文件的中间帧彼此独立,可以并发;非受限文件的帧会按到达顺序进入日志,必须逐帧顺序上传;
* - 末帧带整份内容的签名,服务端要求前面的帧都已入库,所以必须等其余帧全部确认后再发。
*/
export type SendFrame = (frame: QueuedFrame, hooks: { onLoaded: (loadedBytes: number) => void; onRetry: () => void }, signal: AbortSignal) => Promise<boolean>;
interface Options {
/** 受限文件的中间帧可以并发 */
gated: boolean;
concurrency: number;
send: SendFrame;
signal: AbortSignal;
/** 已确认 + 在途的字节数变化时回调 */
onBytes: (sentBytes: number) => void;
/** 生产者(加密)领先上传的最大帧数,防止整个文件堆在内存里 */
buffer?: number;
/** 续传:这些帧上次已经被服务端确认,不必再发 */
alreadyAccepted?: Iterable<number>;
}
export class FrameUploader {
private ready: { frame: QueuedFrame; final: boolean }[] = [];
private inflight = 0;
private readonly accepted = new Set<number>();
private readonly loaded = new Map<number, number>();
private acceptedBytes = 0;
private finalIndex: number | undefined;
private paused = false;
private error: unknown;
private failed = false;
private waiters: (() => void)[] = [];
constructor(private readonly options: Options) {
this.dynamicLimit = options.concurrency;
for (const index of options.alreadyAccepted ?? []) this.accepted.add(index);
}
/** 已确认的帧数(含续传前已确认的) */
get acceptedCount(): number { return this.accepted.size; }
/** 当前并发度:遇到重试就减半,连续顺利再慢慢加回去(弱网下少占连接,好网下跑满) */
private dynamicLimit: number;
private streak = 0;
get currentConcurrency(): number { return this.limit; }
private get limit(): number { return this.options.gated ? Math.max(1, Math.min(this.options.concurrency, this.dynamicLimit)) : 1; }
private get bufferLimit(): number { return this.options.buffer ?? this.limit * 2 + 2; }
/** 一帧已加密并写入离线队列。积压太多时等待,让加密不要跑得比上传快太多 */
async push(frame: QueuedFrame, final: boolean): Promise<void> {
if (this.failed) throw this.error;
if (this.options.signal.aborted) throw new DOMException('上传已取消', 'AbortError');
if (final) this.finalIndex = frame.index;
// 已暂停:帧留在离线队列里,等网络恢复后由 flushOutbox 续传
if (this.paused) return;
this.ready.push({ frame, final });
this.pump();
while (!final && !this.paused && !this.failed && this.ready.length + this.inflight >= this.bufferLimit) await this.wait();
if (this.failed) throw this.error;
}
/** 等所有帧确认。返回 false 表示中途暂停(重试用尽,多半断网);失败会抛出 */
async finish(): Promise<boolean> {
while (!this.settled()) await this.wait();
if (this.failed) throw this.error;
return !this.paused;
}
private settled(): boolean {
if (this.failed || this.paused) return this.inflight === 0;
return this.finalIndex !== undefined && this.accepted.size === this.finalIndex + 1;
}
private canStart(frame: QueuedFrame, final: boolean): boolean {
if (final) return this.accepted.size === frame.index && this.inflight === 0;
if (frame.index === 0) return this.inflight === 0;
if (!this.options.gated) return this.accepted.size === frame.index && this.inflight === 0;
return this.accepted.has(0);
}
private pump(): void {
while (!this.failed && !this.paused && this.inflight < this.limit) {
const at = this.ready.findIndex((entry) => this.canStart(entry.frame, entry.final));
if (at < 0) break;
const [entry] = this.ready.splice(at, 1);
this.start(entry!.frame);
}
this.notify();
}
private start(frame: QueuedFrame): void {
this.inflight += 1;
let retried = false;
this.options.send(frame, {
onLoaded: (loaded) => { this.loaded.set(frame.index, loaded); this.report(); },
onRetry: () => { retried = true; this.dynamicLimit = Math.max(1, Math.floor(this.dynamicLimit / 2)); this.streak = 0; },
}, this.options.signal)
.then((accepted) => {
this.loaded.delete(frame.index);
if (accepted) {
this.accepted.add(frame.index); this.acceptedBytes += frame.bytes.byteLength;
if (!retried && ++this.streak >= 8 && this.dynamicLimit < this.options.concurrency) { this.dynamicLimit += 1; this.streak = 0; }
}
else { this.paused = true; this.ready = []; }
})
.catch((error: unknown) => {
this.loaded.delete(frame.index);
if (!this.failed) { this.failed = true; this.error = error; }
this.ready = [];
})
.finally(() => { this.inflight -= 1; this.report(); this.pump(); });
}
private report(): void {
let sent = this.acceptedBytes;
for (const value of this.loaded.values()) sent += value;
this.options.onBytes(sent);
}
private wait(): Promise<void> { return new Promise((resolve) => { this.waiters.push(resolve); }); }
private notify(): void { const waiting = this.waiters; this.waiters = []; for (const resume of waiting) resume(); }
}
/** 最近几秒的滑动平均速度,网络变化后很快跟上,不会被开头的数据拖累 */
export class TransferMeter {
private samples: { at: number; bytes: number }[] = [];
constructor(private readonly windowMs = 3000) {}
add(bytes: number, at = performance.now()): void {
this.samples.push({ at, bytes });
const from = at - this.windowMs;
while (this.samples.length > 2 && this.samples[1]!.at <= from) this.samples.shift();
}
/** 字节每秒;样本不足或时间太短时为 undefined */
speed(): number | undefined {
const first = this.samples[0]; const last = this.samples[this.samples.length - 1];
if (!first || !last || last.at - first.at < 400) return undefined;
const value = (last.bytes - first.bytes) * 1000 / (last.at - first.at);
return value > 0 ? value : undefined;
}
/** 剩余秒数 */
eta(sentBytes: number, totalBytes: number): number | undefined {
const speed = this.speed();
return speed ? Math.max(0, Math.ceil((totalBytes - sentBytes) / speed)) : undefined;
}
}
/** 按第一帧的密文长度估算整个文件上传的总字节数;全部加密完成后会用精确值替换 */
export function estimateCipherBytes(fileSize: number, maxFrameData: number, firstFrameBytes: number): number {
return Math.max(1, Math.ceil(fileSize / maxFrameData)) * firstFrameBytes;
}
+192
View File
@@ -0,0 +1,192 @@
import { previewKindOf } from '@/lib/fileKind';
import { dequeueFrame, dequeueItem, queueFrame, queueLocation, saveResource, type QueuedFrame, type SavedResource } from '../database';
import { errorMessage } from '../errors';
import type { FileItem, FileUploadOptions } from '../types';
import { prepareObject } from './channelDataKeys';
import { queueFileCatalog } from './channelThreads';
import { deviceIdentity, publishWorkspace } from './presentation';
import { senderSnapshot } from './senderSnapshot';
import { blobs, getSession } from './sessions';
import { flushOutbox, markPipelined, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan, unmarkPipelined, uploadFrame } from './transfer';
import { FrameStash } from './frameStash';
import { estimateCipherBytes, FrameUploader, TransferMeter } from './uploadPipeline';
/** 受限文件内部同时在途的帧数。再多对单条连接的带宽提升有限,还会占满浏览器的并发连接 */
const FRAME_CONCURRENCY = 4;
const PROGRESS_INTERVAL_MS = 150;
interface Job {
resourceId: string;
file: File;
options: FileUploadOptions | undefined;
threadId: string;
folderId: string | undefined;
localId: string | undefined;
controller: AbortController;
/** 到运行时才建立的条目 id,取消时据此清理 */
itemId?: string;
run: () => Promise<void>;
settle: { resolve: () => void; reject: (error: unknown) => void };
}
interface Lane { active?: Job; waiting: Job[] }
/** 每个空间一条上传队列:一次只传一个文件,其余排队;文件内部的帧可以并发(见 FrameUploader) */
const lanes = new Map<string, Lane>();
const isAbort = (error: unknown): boolean => (error as Error | undefined)?.name === 'AbortError';
export function enqueueFileUploads(resourceId: string, files: File[], options: FileUploadOptions | undefined,
threadId: string, folderId: string | undefined, localIds: string[] | undefined): Promise<void> {
const lane = lanes.get(resourceId) ?? { waiting: [] };
lanes.set(resourceId, lane);
const done = files.map((file, index) => new Promise<void>((resolve, reject) => {
const job: Job = { resourceId, file, options, threadId, folderId, localId: localIds?.[index], controller: new AbortController(),
run: () => runJob(job), settle: { resolve, reject } };
lane.waiting.push(job);
}));
drain(resourceId, lane);
return Promise.allSettled(done).then((results) => {
const failed = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
if (failed) throw failed.reason;
});
}
/** 取消排队中或上传中的文件。返回是否找到了这个任务 */
export function cancelFileUpload(resourceId: string, localId: string): boolean {
const lane = lanes.get(resourceId);
if (!lane) return false;
const waiting = lane.waiting.findIndex((job) => job.localId === localId);
if (waiting >= 0) { const [job] = lane.waiting.splice(waiting, 1); job!.settle.resolve(); return true; }
if (lane.active?.localId === localId) { lane.active.controller.abort(); return true; }
return false;
}
function drain(resourceId: string, lane: Lane): void {
if (lane.active) return;
const job = lane.waiting.shift();
if (!job) { lanes.delete(resourceId); return; }
lane.active = job;
job.run().then(job.settle.resolve, job.settle.reject).finally(() => { lane.active = undefined; drain(resourceId, lane); });
}
async function runJob(job: Job): Promise<void> {
const { resourceId, file, threadId, folderId, localId } = job;
const signal = job.controller.signal;
const { session, resource } = await getSession(resourceId);
const options = job.options ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
const device = await deviceIdentity(session.endpointSecret);
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
const author = await senderSnapshot();
const ts = Date.now();
let item: FileItem | undefined;
let stash: FrameStash | undefined;
try {
if (signal.aborted) return;
const prepared = await prepareObject(resource, session, threadId, 'files');
const plan = session.encryptFile(file, {}, {
itemId: prepared.itemIdBytes,
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
ts, maxFrameData, threadId, folderId,
});
item = {
id: plan.itemId, kind: 'file', ts, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
...(author ? { author } : {}),
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
remaining: plan.gated
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
: null,
preview: previewKindOf(file.type, file.name), gated: plan.gated,
};
job.itemId = item.id;
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item];
await saveResource(resource);
blobs.set(`${resourceId}:${item.id}`, file);
publishWorkspace(resourceId, { t: 'item', item });
markPipelined(resourceId, item.id);
const progress = createProgress(resourceId, () => item, (next) => { item = next; });
// 帧先直接上传,来不及上传的才在后台写进离线队列(见 frameStash);离线队列由 stash 管理,上传成功后不用再去删
const queued = new FrameStash(queueFrame, dequeueFrame);
stash = queued;
const uploader = new FrameUploader({
gated: plan.gated, concurrency: FRAME_CONCURRENCY, signal,
send: (frame, hooks, s) => uploadFrame(resource, session, frame, { ...hooks, manageQueue: false, onAccepted: () => queued.accepted(frame) }, s),
onBytes: progress.sent,
});
const planned = await persistPlan(resource, plan, undefined,
{ channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.publish' },
async (frame: QueuedFrame, final) => {
if (frame.index === 0) progress.estimate(estimateCipherBytes(file.size, maxFrameData, frame.bytes.byteLength));
await queued.add(frame);
await uploader.push(frame, final);
});
progress.exact(planned.bytes);
await queueFileCatalog(resource, session, item);
await queueLocation(resourceId, item.id, threadId, folderId);
const framesUploaded = await uploader.finish();
// 没传完的(断网暂停)帧要先写进离线队列,flushOutbox 才续传得了
await queued.drain();
unmarkPipelined(resourceId, item.id);
// 帧都确认后,再发目录条目和位置登记(以及上次没发完的遗留)
const uploaded = framesUploaded && await flushOutbox(resource, session);
await finish(resource, item, uploaded ? { kind: 'complete' } : { kind: 'incomplete' });
if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开空间并连接网络后,可以继续发送已加密内容。' });
} catch (error) {
if (item) unmarkPipelined(resourceId, item.id);
if (signal.aborted || isAbort(error)) { await stash?.discard(); await discard(resource, item); return; }
job.controller.abort();
// 失败时已加密还没确认的帧要保住,之后才能续传
await stash?.drain().catch(() => undefined);
if (item) await finish(resource, item, { kind: 'incomplete' });
publishWorkspace(resourceId, { t: 'error', message: `文件上传失败:${errorMessage(error)}` });
throw error;
}
}
async function finish(resource: SavedResource, item: FileItem, state: FileItem['state']): Promise<void> {
const next = { ...item, state };
resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === next.id ? next : entry);
await saveResource(resource);
publishWorkspace(resource.id, { t: 'item', item: next });
}
/** 取消:清掉离线队列里这个文件的帧和本地条目。服务端已收下的帧会随空间保留期自然过期 */
async function discard(resource: SavedResource, item: FileItem | undefined): Promise<void> {
if (!item) return;
await dequeueItem(resource.id, item.id);
resource.localItems = (resource.localItems ?? []).filter((entry) => entry.id !== item.id);
await saveResource(resource);
blobs.delete(`${resource.id}:${item.id}`);
publishWorkspace(resource.id, { t: 'removed', id: item.id });
}
/**
* 把「已发送字节数」换算成一条 0–100% 的进度,并带上速度和剩余时间。
* 总量先按第一帧估算,全部加密完成后换成精确值;最后一帧确认前封顶 99%。
*/
function createProgress(resourceId: string, current: () => FileItem | undefined, update: (item: FileItem) => void) {
const meter = new TransferMeter();
let total = 0; let lastSent = 0; let lastPublished = 0;
const publish = (force: boolean) => {
const item = current();
if (!item || total <= 0) return;
const now = performance.now();
if (!force && now - lastPublished < PROGRESS_INTERVAL_MS) return;
lastPublished = now;
const speed = meter.speed(); const eta = meter.eta(lastSent, total);
const next: FileItem = { ...item, state: {
kind: 'receiving', progress: Math.max(0, Math.min(0.99, lastSent / total)), phase: lastSent > 0 ? 'uploading' : 'encrypting',
sentBytes: Math.min(lastSent, total), totalBytes: total,
...(speed === undefined ? {} : { speedBytesPerSecond: speed }), ...(eta === undefined ? {} : { etaSeconds: eta }),
} };
update(next);
publishWorkspace(resourceId, { t: 'item', item: next });
};
return {
estimate: (bytes: number) => { total = bytes; publish(true); },
exact: (bytes: number) => { total = bytes; publish(true); },
sent: (bytes: number) => { lastSent = bytes; meter.add(bytes); publish(false); },
};
}
@@ -1,4 +1,4 @@
import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest } from '../generated/api';
import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest, WorkspaceRoleCreatedBody, WorkspaceRoleRequest } from '../generated/api';
import type { FileItem } from '../types';
import { getResource, cacheResource } from '../database';
import { getSession } from './sessions';
@@ -40,7 +40,7 @@ export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', value?: unknown): Promise<WorkspaceBody> {
const { session } = await getSession(id);
const body = await apiFetch(`/spaces/${id}/${path}`, {
method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, body: JSON.stringify(value),
method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify(value),
}).then(r => r.json() as Promise<WorkspaceBody>);
await publish(id, body);
return body;
@@ -59,6 +59,13 @@ export async function createWorkspaceGroup(id: string, value: WorkspaceNameReque
await publish(id, body.workspace);
return body.group.id;
}
export async function createWorkspaceRole(id: string, value: WorkspaceRoleRequest): Promise<string> {
const { session } = await getSession(id);
const body = await apiFetch(`/spaces/${id}/roles`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(value) })
.then(r => r.json() as Promise<WorkspaceRoleCreatedBody>);
await publish(id, body.workspace);
return body.role.id;
}
export async function registerItemLocation(id: string, item: Pick<FileItem, 'id' | 'threadId' | 'folderId'>): Promise<void> {
if (!item.threadId) return;
await changeWorkspace(id, 'locations', 'PUT', { itemIds: [item.id], threadId: item.threadId, folderId: item.folderId } satisfies WorkspaceMoveRequest);