3 Commits
Author SHA1 Message Date
justme b1ac11e1d7 perf(web): shorten the cold start when opening a saved space
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
Activate the account with parallel requests, run the full vault sync in the
background, skip the duplicate space list request, and send the rejoin
together with the first screen of data. [CI]
2026-10-16 06:00:17 +08:00
justme f2e118c966 perf(server): compress JSON and frontend responses with brotli or gzip 2026-10-16 06:00:16 +08:00
justme d77ca1f113 perf(web): reuse cached crypto state and structure when decrypting and sending
Decrypting each history item no longer refetches the whole crypto state, and
sending a message only needs the object and frame requests.
2026-10-16 06:00:16 +08:00
6 changed files with 46 additions and 17 deletions

No files matched your search

+4
View File
@@ -55,6 +55,9 @@ public static class WebDropApp
sp.GetRequiredService<IResourceStore>(), sp.GetRequiredService<IBlobStore>(), sp.GetRequiredService<IClock>(), sp.GetRequiredService<IResourceStore>(), sp.GetRequiredService<IBlobStore>(), sp.GetRequiredService<IClock>(),
options.Limits, options.ShareDraftTtlSeconds)); options.Limits, options.ShareDraftTtlSeconds));
builder.Services.AddHostedService<SweepWorker>(); builder.Services.AddHostedService<SweepWorker>();
// JSON(如 crypto 状态)与前端静态资源压缩后再传;认证走请求头而非 cookie,开启 HTTPS 压缩不会带来 BREACH 风险。
// 默认 MIME 列表不含 text/event-stream 与 octet-stream,日志流和密文不受影响
builder.Services.AddResponseCompression(o => o.EnableForHttps = true);
if (options.CorsOrigins.Length > 0) if (options.CorsOrigins.Length > 0)
{ {
@@ -99,6 +102,7 @@ public static class WebDropApp
}); });
} }
app.UseResponseCompression();
app.Use(SecurityHeaders); app.Use(SecurityHeaders);
app.Use(HandleErrors); app.Use(HandleErrors);
if (options.CorsOrigins.Length > 0) app.UseCors(); if (options.CorsOrigins.Length > 0) app.UseCors();
+11 -7
View File
@@ -106,14 +106,17 @@ async function syncVault(): Promise<void> {
async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true, cachedPersonal?: PersonalProfile): Promise<AccountSession> { async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true, cachedPersonal?: PersonalProfile): Promise<AccountSession> {
const profile = validateVault(await openJson(remote.vault, key)); const profile = validateVault(await openJson(remote.vault, key));
const deviceSigning = await ensureDeviceSigning(identity.username, identity.token, profile.keys.signing); // 设备签名密钥与个人资料互不依赖,同时请求,冷启动少等一个往返
const [deviceSigning, personal] = await Promise.all([
ensureDeviceSigning(identity.username, identity.token, profile.keys.signing),
loadPersonal(identity.token).catch((error: unknown) => {
if (!(error instanceof Error && error.message === '网络连接失败')) throw error;
return cachedPersonal;
}),
]);
clearResourceSessions(); setResourceScope(identity.username); clearResourceSessions(); setResourceScope(identity.username);
const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false, deviceSigning }; const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false, deviceSigning };
try { session.personal = await loadPersonal(identity.token); } session.personal = personal;
catch (error) {
if (!(error instanceof Error && error.message === '网络连接失败')) throw error;
session.personal = cachedPersonal;
}
account = session; setAccountIdentity(identity, profile.keys.publicKey, session.personal); account = session; setAccountIdentity(identity, profile.keys.publicKey, session.personal);
await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes())); await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes()));
setResourceChangeListener(scheduleSync); setResourceChangeListener(scheduleSync);
@@ -135,7 +138,8 @@ export function restoreAccount(): Promise<void> {
try { try {
const session = await activate(saved, saved.key, remote, true, saved.personal); const session = await activate(saved, saved.key, remote, true, saved.personal);
setAccountIdentity(session, session.profile.keys.publicKey, session.personal); setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
await syncVault().catch(() => {}); // 保险库已在 activate 里并入本机资源,完整同步(约 4 次往返)放到后台,不挡住打开页面
void syncVault().catch(() => {});
} catch (error) { await logoutAccount(); throw error; } } catch (error) { await logoutAccount(); throw error; }
})().catch((error: unknown) => { restoration = undefined; throw error; }); })().catch((error: unknown) => { restoration = undefined; throw error; });
return restoration; return restoration;
+19 -6
View File
@@ -5,7 +5,7 @@ import { apiFetch } from './http';
import { accountPublicKey, accountToken, requireAccount } from './accountSession'; import { accountPublicKey, accountToken, requireAccount } from './accountSession';
import { decryptInvitation, encryptInvitation } from './accountCrypto'; import { decryptInvitation, encryptInvitation } from './accountCrypto';
import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto'; import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto';
import { loadWorkspace } from './workspaceStructure'; import { cachedWorkspace, loadWorkspace } from './workspaceStructure';
import { pinSigningRoot } from './accountPersistence'; import { pinSigningRoot } from './accountPersistence';
import { signExistingContentEvent, verifyDeviceCertificate } from './accountSigning'; import { signExistingContentEvent, verifyDeviceCertificate } from './accountSigning';
@@ -13,12 +13,17 @@ const objectKeys = new Map<string, CryptoKey>();
const states = new Map<string, CryptoState>(); const states = new Map<string, CryptoState>();
const domainId = (channelId: string, domain: string, epoch: number) => `${channelId}:${domain}:${epoch}`; const domainId = (channelId: string, domain: string, epoch: number) => `${channelId}:${domain}:${epoch}`;
const contextId = (spaceId: string, channelId: string, domain: string, epoch: number) => `${spaceId}:${channelId}:${domain}:${epoch}`; const contextId = (spaceId: string, channelId: string, domain: string, epoch: number) => `${spaceId}:${channelId}:${domain}:${epoch}`;
export function clearDataKeys(): void { objectKeys.clear(); states.clear(); } const loaded = new Map<string, DataKeys>();
export function clearDataKeys(): void { objectKeys.clear(); states.clear(); loaded.clear(); }
export function attachDataKeys(session: ChannelSession, resource: SavedResource): void { export function attachDataKeys(session: ChannelSession, resource: SavedResource): void {
if (resource.kind !== 'channel') return; if (resource.kind !== 'channel') return;
session.objectKeyResolver = async itemId => { session.objectKeyResolver = async itemId => {
const cached = objectKeys.get(`${resource.id}:${itemId}`); if (cached) return cached; const cached = objectKeys.get(`${resource.id}:${itemId}`); if (cached) return cached;
const { keys } = await readDataKeys(resource, session); // 内存里已有这个对象和它的数据域密钥就直接用;否则才重新拉(每次都拉整份 crypto 状态,历史越长越慢)
const { keys } = await cachedKeys(resource, session, data => {
const known = data.state.objects.find(o => o.itemId === itemId);
return Boolean(known && data.keys[domainId(known.channelId, known.domain, known.epoch)]);
});
const object = states.get(resource.id)?.objects.find(o => o.itemId === itemId); const object = states.get(resource.id)?.objects.find(o => o.itemId === itemId);
if (!object || object.domain !== 'chat' && object.domain !== 'files') throw new Error('该对象没有已授权的解密数据域'); if (!object || object.domain !== 'chat' && object.domain !== 'files') throw new Error('该对象没有已授权的解密数据域');
const key = keys[domainId(object.channelId, object.domain, object.epoch)]; const key = keys[domainId(object.channelId, object.domain, object.epoch)];
@@ -35,6 +40,11 @@ export function readDataKeys(resource: SavedResource, session: ChannelSession):
if (!task) { task = loadDataKeys(resource, session).finally(() => pendingKeys.delete(resource.id)); pendingKeys.set(resource.id, task); } if (!task) { task = loadDataKeys(resource, session).finally(() => pendingKeys.delete(resource.id)); pendingKeys.set(resource.id, task); }
return task; return task;
} }
/** 内存里的结果满足 enough 就复用,不满足再请求 */
async function cachedKeys(resource: SavedResource, session: ChannelSession, enough: (data: DataKeys) => boolean): Promise<DataKeys> {
const have = loaded.get(resource.id);
return have && enough(have) ? have : readDataKeys(resource, session);
}
async function loadDataKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> { async function loadDataKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise<CryptoState>); const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise<CryptoState>);
states.set(resource.id, state); states.set(resource.id, state);
@@ -49,7 +59,9 @@ async function loadDataKeys(resource: SavedResource, session: ChannelSession): P
keys[id] = value.key; keys[id] = value.key;
} }
if (latest && JSON.stringify(keys) !== JSON.stringify(latest.domainKeys ?? {})) await saveResource({ ...latest, domainKeys: keys }); if (latest && JSON.stringify(keys) !== JSON.stringify(latest.domainKeys ?? {})) await saveResource({ ...latest, domainKeys: keys });
return { state, keys }; const result = { state, keys };
loaded.set(resource.id, result);
return result;
} }
async function envelope(spaceId: string, channelId: string, domain: 'chat' | 'files', epoch: number, key: string, username: string): Promise<DomainEnvelope> { async function envelope(spaceId: string, channelId: string, domain: 'chat' | 'files', epoch: number, key: string, username: string): Promise<DomainEnvelope> {
const publicKey = username === requireAccount() ? accountPublicKey() : JSON.parse((await apiFetch(`/accounts/users/${username}/key`).then(r => r.json() as Promise<PublicKeyBody>)).public_key) as JsonWebKey; const publicKey = username === requireAccount() ? accountPublicKey() : JSON.parse((await apiFetch(`/accounts/users/${username}/key`).then(r => r.json() as Promise<PublicKeyBody>)).public_key) as JsonWebKey;
@@ -102,7 +114,8 @@ export async function initializeChannelKeys(resource: SavedResource, session: Ch
export interface PreparedObject { itemIdBytes: Uint8Array; itemId: string; channelId: string; domain: 'chat' | 'files'; epoch: number } export interface PreparedObject { itemIdBytes: Uint8Array; itemId: string; channelId: string; domain: 'chat' | 'files'; epoch: number }
export async function prepareObject(resource: SavedResource, session: ChannelSession, channelId: string, domain: 'chat' | 'files'): Promise<PreparedObject> { export async function prepareObject(resource: SavedResource, session: ChannelSession, channelId: string, domain: 'chat' | 'files'): Promise<PreparedObject> {
const idBytes = crypto.getRandomValues(new Uint8Array(16)); const itemId = toBase64Url(idBytes); const idBytes = crypto.getRandomValues(new Uint8Array(16)); const itemId = toBase64Url(idBytes);
const { structure } = await loadWorkspace(resource.id); let channel = structure.channels.find(c => c.id === channelId); let channel = (await cachedWorkspace(resource.id)).structure.channels.find(c => c.id === channelId);
if (!channel) channel = (await loadWorkspace(resource.id)).structure.channels.find(c => c.id === channelId);
if (!channel) throw new Error('请先创建频道'); if (!channel) throw new Error('请先创建频道');
if (resource.mode === 'enc' && !(domain === 'chat' ? channel.chatEpoch : channel.filesEpoch)) { if (resource.mode === 'enc' && !(domain === 'chat' ? channel.chatEpoch : channel.filesEpoch)) {
await initializeChannelKeys(resource, session, channelId); await initializeChannelKeys(resource, session, channelId);
@@ -111,7 +124,7 @@ export async function prepareObject(resource: SavedResource, session: ChannelSes
const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch; const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch;
let keyEnvelope = ''; let keyEnvelope = '';
if (resource.mode === 'enc') { if (resource.mode === 'enc') {
const { keys } = await readDataKeys(resource, session); const key = keys[domainId(channelId, domain, epoch)]; const { keys } = await cachedKeys(resource, session, data => Boolean(data.keys[domainId(channelId, domain, epoch)])); const key = keys[domainId(channelId, domain, epoch)];
if (!key) throw new Error('未获得该频道数据域的访问授权'); if (!key) throw new Error('未获得该频道数据域的访问授权');
const created = await newObjectKey(key, { spaceId: resource.id, channelId, domain, epoch, itemId }); keyEnvelope = created.envelope; const created = await newObjectKey(key, { spaceId: resource.id, channelId, domain, epoch, itemId }); keyEnvelope = created.envelope;
objectKeys.set(`${resource.id}:${itemId}`, created.key); objectKeys.set(`${resource.id}:${itemId}`, created.key);
@@ -21,8 +21,10 @@ export async function connectChannel(id: string): Promise<void> {
if (resource.kind !== 'channel') throw new Error('资源类型不匹配'); if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
// Device sessions can be revoked independently of account membership; rejoin idempotently. // Device sessions can be revoked independently of account membership; rejoin idempotently.
const joinedAt = freshlyJoined.get(id); freshlyJoined.delete(id); const joinedAt = freshlyJoined.get(id); freshlyJoined.delete(id);
if (joinedAt === undefined || Date.now() - joinedAt > 60_000) const join = () => apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal }); // 已保存的成员重连只是确认身份:与首屏数据同时发出,失败就由下面的重连循环再来一次。首次加入(needsJoin)仍然先 join
const joining = joinedAt !== undefined && Date.now() - joinedAt <= 60_000 ? undefined
: resource.needsJoin ? await join().then(() => undefined) : join().then(() => undefined);
if (resource.needsJoin) { if (resource.needsJoin) {
if (session.can('owner')) await channelMetadata(resource, session); if (session.can('owner')) await channelMetadata(resource, session);
resource.needsJoin = false; resource.needsJoin = false;
@@ -49,7 +51,7 @@ export async function connectChannel(id: string): Promise<void> {
while (!signal.aborted) { while (!signal.aborted) {
try { try {
if (!initialized) { if (!initialized) {
[stream.cursor] = await Promise.all([syncChannel(resource, session, signal), channelMetadata(resource, session)]); [stream.cursor] = await Promise.all([syncChannel(resource, session, signal), channelMetadata(resource, session), joining]);
if (signal.aborted) break; if (signal.aborted) break;
await flushOutbox(resource, session); await flushOutbox(resource, session);
initialized = true; initialized = true;
@@ -24,6 +24,10 @@ async function publish(id: string, body: WorkspaceBody): Promise<WorkspaceBody>
publishWorkspace(id, { t: 'structure', state: body.structure, people }); publishWorkspace(id, { t: 'structure', state: body.structure, people });
return body; return body;
} }
/** 结构由流里的 settings 通知保持最新;发消息等热路径直接用缓存,不再每次多等一个往返 */
export async function cachedWorkspace(id: string): Promise<WorkspaceBody> {
return current.get(id) ?? loadWorkspace(id);
}
export async function loadWorkspace(id: string): Promise<WorkspaceBody> { export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
const { session } = await getSession(id); const { session } = await getSession(id);
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>); const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);
+3 -1
View File
@@ -24,7 +24,7 @@ interface ChannelsState {
const upsert = (list: ChannelInfo[], c: ChannelInfo) => [c, ...list.filter((x) => x.id !== c.id)]; const upsert = (list: ChannelInfo[], c: ChannelInfo) => [c, ...list.filter((x) => x.id !== c.id)];
let hydration: Promise<void> | undefined; let hydration: Promise<void> | undefined;
export const useChannels = create<ChannelsState>()((set) => ({ export const useChannels = create<ChannelsState>()((set, get) => ({
list: [], list: [],
hydrated: false, hydrated: false,
pendingLink: null, pendingLink: null,
@@ -41,6 +41,8 @@ export const useChannels = create<ChannelsState>()((set) => ({
return c; return c;
}, },
hydrate: () => { hydrate: () => {
// 账号刷新已经列过一次,别再请求一遍
if (get().hydrated) return Promise.resolve();
hydration ??= api.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true })); hydration ??= api.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
return hydration; return hydration;
}, },