chore(repo): initialize WebDrop monorepo

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:22 +08:00
commit 53768a195d
39 files changed
+9063

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
import { Flags } from './constants.js';
import { equalBytes, type Bytes } from './bytes.js';
import { IntegrityError } from './errors.js';
import { MAX_ENC_PAYLOAD, openFrame, parseEnvelope, peekEnvelopeLength } from './frame.js';
import { decodeFirstFrame, type ItemMeta } from './item.js';
import type { ReaderContext } from './reader.js';
/** 字节队列:接收任意切分的块,按需取出前 n 个字节。 */
class ByteQueue {
readonly #parts: Uint8Array[] = [];
#length = 0;
get length(): number {
return this.#length;
}
push(chunk: Uint8Array): void {
if (chunk.length === 0) return;
this.#parts.push(chunk);
this.#length += chunk.length;
}
/** 复制前 n 个字节,不消耗。 */
peek(n: number): Bytes {
const out = new Uint8Array(n);
let filled = 0;
for (const part of this.#parts) {
if (filled >= n) break;
const take = Math.min(part.length, n - filled);
out.set(part.subarray(0, take), filled);
filled += take;
}
return out;
}
take(n: number): Bytes {
const out = new Uint8Array(n);
let filled = 0;
while (filled < n) {
const head = this.#parts[0] as Uint8Array;
const need = n - filled;
if (head.length <= need) {
out.set(head, filled);
filled += head.length;
this.#parts.shift();
} else {
out.set(head.subarray(0, need), filled);
this.#parts[0] = head.subarray(need);
filled += need;
}
}
this.#length -= n;
return out;
}
}
async function* bytesOf(source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, void> {
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
const reader = source.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) return;
yield value;
}
} finally {
reader.releaseLock();
}
}
yield* source as AsyncIterable<Uint8Array>;
}
/**
* 把「封装首尾相接」的字节流(GET /items/{id}/frames 的响应体)切成一个个完整封装。
* 连接中途断开会在末尾留下半个封装:这是正常的网络情况,不是篡改,所以直接丢弃,
* 调用方根据 decryptItemStream 报告的 nextIndex 续传即可。
*/
export async function* splitEnvelopes(
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
): AsyncGenerator<Bytes, void, void> {
const queue = new ByteQueue();
for await (const chunk of bytesOf(source)) {
queue.push(chunk);
for (;;) {
if (queue.length < 26) break;
const total = peekEnvelopeLength(queue.peek(26), MAX_ENC_PAYLOAD);
if (total === null || queue.length < total) break;
yield queue.take(total);
}
}
}
export interface DecryptStreamOptions {
/** 这次响应从哪一帧开始(拉取时的 from 参数),默认 0。 */
fromIndex?: number;
/** 期望的条目 id(base64url 解码后的字节)。提供后,混入别的条目的帧会被拒绝。 */
itemId?: Uint8Array;
}
export interface ItemDecodeResult {
/** 已收到已认证的 FINAL。 */
complete: boolean;
/** 下一个期望的帧序号。没收完时用它作为 from 续传(不会重复计下载次数)。 */
nextIndex: number;
/** 第 0 帧里的元数据;从中间帧续传时没有。 */
meta: ItemMeta | undefined;
}
/**
* 流式解密一个条目:边收边解边产出内容,内存占用只有一帧。可以放进 Worker 里运行。
*
* 逐帧校验:认证、条目 id、帧序号连续、标志一致、FINAL 之后不再有帧。
* 任何一项失败都会抛 IntegrityError(流会出错),已产出的内容不可信的部分由调用方丢弃。
* 流正常结束但没有 FINAL 不是错误(连接可能被断开),此时 complete 为 false。
*/
export async function* decryptItemFrames(
ctx: ReaderContext,
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
opts: DecryptStreamOptions = {},
): AsyncGenerator<Bytes, ItemDecodeResult, void> {
let expected = opts.fromIndex ?? 0;
let itemId = opts.itemId;
let flagsMask: number | undefined;
let finalSeen = false;
let meta: ItemMeta | undefined;
for await (const bytes of splitEnvelopes(source)) {
const env = parseEnvelope(bytes);
const isEnc = (env.flags & Flags.Enc) !== 0;
if (isEnc !== (ctx.mode === 'enc')) throw new IntegrityError('wrong_mode');
if (!itemId) itemId = env.itemId;
else if (!equalBytes(itemId, env.itemId)) throw new IntegrityError('item_mismatch');
if (finalSeen) throw new IntegrityError('frame_after_final');
if (env.frameIndex !== expected) throw new IntegrityError('gap');
const itemFlags = env.flags & (Flags.Gated | Flags.Snapshot);
if (flagsMask === undefined) flagsMask = itemFlags;
else if (flagsMask !== itemFlags) throw new IntegrityError('flags_changed');
let data: Bytes;
if (ctx.mode === 'enc') {
data = await openFrame(await ctx.itemKey(env.itemId), ctx.channelId, env);
if (env.frameIndex === 0) {
const first = decodeFirstFrame(data);
meta = first.meta;
data = first.content;
}
} else {
data = env.payload;
}
expected++;
if ((env.flags & Flags.Final) !== 0) finalSeen = true;
if (data.length > 0) yield data;
}
return { complete: finalSeen, nextIndex: expected, meta };
}
/** 把 decryptItemFrames 包成 ReadableStream,便于直接 pipe 到文件。 */
export function decryptItemStream(
ctx: ReaderContext,
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
opts: DecryptStreamOptions = {},
): { content: ReadableStream<Uint8Array>; result: Promise<ItemDecodeResult> } {
const gen = decryptItemFrames(ctx, source, opts);
let resolve!: (r: ItemDecodeResult) => void;
let reject!: (e: unknown) => void;
const result = new Promise<ItemDecodeResult>((res, rej) => {
resolve = res;
reject = rej;
});
result.catch(() => {}); // 错误通过流本身传播,避免未处理的 rejection
const content = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const r = await gen.next();
if (r.done) {
resolve(r.value);
controller.close();
} else {
controller.enqueue(r.value);
}
} catch (e) {
reject(e);
controller.error(e);
}
},
async cancel() {
await gen.return({ complete: false, nextIndex: opts.fromIndex ?? 0, meta: undefined });
},
});
return { content, result };
}