Files
WebDrop/packages/core/src/reader.ts
T
2026-10-02 00:59:22 +08:00

211 lines
8.1 KiB
TypeScript

import { Flags, type ChannelMode, type ItemKind } from './constants.js';
import { concat, equalBytes, tryDecodeUtf8, toBase64Url, type Bytes } from './bytes.js';
import { IntegrityError, type IntegrityReason } from './errors.js';
import { openFrame, parseEnvelope, type Envelope } from './frame.js';
import { sha256 } from './kdf.js';
import { decodeFirstFrame, type ItemMeta } from './item.js';
import { parseLogRecord, type LogRecord, type RemovedReason } from './log.js';
/** 接收端需要的会话信息。由 ChannelSession 提供。 */
export interface ReaderContext {
channelId: Uint8Array;
mode: ChannelMode;
/** enc 模式:取某个条目的 AES-GCM 密钥。 */
itemKey(itemId: Uint8Array): Promise<CryptoKey>;
}
export type ReceivedKind = ItemKind | 'raw';
export interface ReceivedItem {
itemId: string;
/** 帧所在日志记录的 seq(经 pushFrame 单独喂入且未提供 seq 时为空)。 */
seq: number | undefined;
kind: ReceivedKind;
/** 仅 enc 模式。 */
meta?: ItemMeta;
snapshot: boolean;
gated: boolean;
/** 已收到已认证的 FINAL,且 0..FINAL 的帧全部到齐。 */
complete: boolean;
/** 到第一个缺口为止已收到的内容。大文件请改用 decryptItemStream,不要在这里缓冲。 */
content: Bytes;
}
export type ItemEvent =
/** GATED 条目的通知:字节需要另外拉取(GET /items/{id}/frames),拉取计入下载次数。 */
| { type: 'announce'; itemId: string; seq: number; flags: number; createdAt: number }
/** 一个条目有新内容或已完成。小内容(单帧)只会触发一次,complete 为 true。 */
| { type: 'item'; item: ReceivedItem }
/** 条目被删除、过期、淘汰、取代或下载达上限。 */
| { type: 'removed'; itemId: string; seq: number; reason: RemovedReason }
/** 一帧被拒绝(篡改、乱序、模式不符等)。该条目的缓冲状态已丢弃。 */
| { type: 'rejected'; itemId: string | undefined; seq: number | undefined; reason: IntegrityReason };
interface FrameState {
content: Bytes;
digest: Bytes;
}
interface ItemState {
itemFlags: number;
frames: Map<number, FrameState>;
finalIndex: number | undefined;
maxIndex: number;
meta: ItemMeta | undefined;
}
/**
* 条目装配器:喂入日志记录或条目的帧,吐出解密、校验后的事件。
*
* 校验失败**不抛异常**,而是产生 `rejected` 事件并丢弃该条目的缓冲:
* 一个坏帧(或恶意服务端)不应让整个订阅挂掉。直接调用 openFrame 才会抛 IntegrityError。
*/
export class ItemReader {
readonly #ctx: ReaderContext;
readonly #open = new Map<string, ItemState>();
/** 已完成的条目。回放时重复到达的帧直接忽略,不会让条目「复活」。 */
readonly #done = new Set<string>();
constructor(ctx: ReaderContext) {
this.#ctx = ctx;
}
/** 喂入一条日志记录(WebSocket 的一条二进制消息,或 parseLogRecords 的一项)。 */
async push(input: Uint8Array | LogRecord): Promise<ItemEvent[]> {
let record: LogRecord;
try {
record = input instanceof Uint8Array ? parseLogRecord(input) : input;
} catch (e) {
if (e instanceof IntegrityError) return [{ type: 'rejected', itemId: undefined, seq: undefined, reason: e.reason }];
throw e;
}
switch (record.kind) {
case 'announce':
return [{ type: 'announce', itemId: record.itemId, seq: record.seq, flags: record.flags, createdAt: record.createdAt }];
case 'tombstone':
this.#open.delete(record.itemId);
this.#done.delete(record.itemId);
return [{ type: 'removed', itemId: record.itemId, seq: record.seq, reason: record.reason }];
case 'frame':
return this.pushFrame(record.envelope, record.seq);
}
}
/** 喂入一个完整的封装(例如拉取 GATED 条目时按帧切出来的)。 */
async pushFrame(envelope: Uint8Array, seq?: number): Promise<ItemEvent[]> {
let env: Envelope;
try {
env = parseEnvelope(envelope);
} catch (e) {
if (e instanceof IntegrityError) return [{ type: 'rejected', itemId: undefined, seq, reason: e.reason }];
throw e;
}
const itemId = toBase64Url(env.itemId);
try {
return await this.#accept(env, itemId, seq);
} catch (e) {
if (!(e instanceof IntegrityError)) throw e;
this.#open.delete(itemId);
return [{ type: 'rejected', itemId, seq, reason: e.reason }];
}
}
/** 丢弃某个条目的缓冲(例如用户取消了下载)。 */
forget(itemId: string): void {
this.#open.delete(itemId);
this.#done.delete(itemId);
}
async #accept(env: Envelope, itemId: string, seq: number | undefined): Promise<ItemEvent[]> {
const isEncFrame = (env.flags & Flags.Enc) !== 0;
// 降级防御:enc 会话只接受认证过的 enc 帧,服务端不能注入明文帧;raw 会话反之
if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
if (this.#done.has(itemId)) return [];
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot);
const gated = (env.flags & Flags.Gated) !== 0;
const isFinal = (env.flags & Flags.Final) !== 0;
// 非 GATED 条目必须是单帧且 FINAL(协议规则,服务端也强制)
if (!gated && (env.frameIndex !== 0 || !isFinal)) throw new IntegrityError('bad_envelope');
let state = this.#open.get(itemId);
if (state && state.itemFlags !== itemFlags) throw new IntegrityError('flags_changed');
if (state?.finalIndex !== undefined && env.frameIndex > state.finalIndex) throw new IntegrityError('frame_after_final');
if (isFinal && state) {
if (state.maxIndex > env.frameIndex) throw new IntegrityError('final_conflict');
if (state.finalIndex !== undefined && state.finalIndex !== env.frameIndex) throw new IntegrityError('final_conflict');
}
// 重复帧:内容必须逐字节相同,相同则视为重放,不产生事件
const digest = await sha256(env.payload);
const dup = state?.frames.get(env.frameIndex);
if (dup) {
if (!equalBytes(dup.digest, digest)) throw new IntegrityError('duplicate_mismatch');
return [];
}
// 解密 / 取出内容
let data: Bytes;
if (this.#ctx.mode === 'enc') {
data = await openFrame(await this.#ctx.itemKey(env.itemId), this.#ctx.channelId, env);
} else {
data = env.payload;
}
let meta = state?.meta;
let content = data;
if (this.#ctx.mode === 'enc' && env.frameIndex === 0) {
const first = decodeFirstFrame(data);
meta = first.meta;
content = first.content;
}
if (!state) {
state = { itemFlags, frames: new Map(), finalIndex: undefined, maxIndex: -1, meta: undefined };
this.#open.set(itemId, state);
}
state.frames.set(env.frameIndex, { content, digest });
state.maxIndex = Math.max(state.maxIndex, env.frameIndex);
if (isFinal) state.finalIndex = env.frameIndex;
if (meta) state.meta = meta;
// 拼接到第一个缺口为止
const parts: Uint8Array[] = [];
for (let i = 0; state.frames.has(i); i++) parts.push((state.frames.get(i) as FrameState).content);
const complete = state.finalIndex !== undefined && parts.length === state.finalIndex + 1;
const item: ReceivedItem = {
itemId,
seq,
kind: this.#ctx.mode === 'raw' ? 'raw' : (state.meta?.kind ?? 'other'),
snapshot: (itemFlags & Flags.Snapshot) !== 0,
gated,
complete,
content: concat(...parts),
};
if (state.meta) item.meta = state.meta;
if (complete) {
this.#open.delete(itemId);
this.#done.add(itemId);
}
return [{ type: 'item', item }];
}
}
// ───────────────────────── 便利函数 ─────────────────────────
/** 内容按 UTF-8 解码,非法序列抛 TypeError。 */
export function itemText(item: Pick<ReceivedItem, 'content'>): string {
const text = tryDecodeUtf8(item.content);
if (text === null) throw new TypeError('条目内容不是合法的 UTF-8');
return text;
}
export function itemJson(item: Pick<ReceivedItem, 'content'>): unknown {
return JSON.parse(itemText(item));
}