chore(repo): initialize WebDrop monorepo
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
commit
53768a195d
39 files changed
+9063
No files matched your search
@@ -0,0 +1,340 @@
|
||||
import { Flags, MAX_FRAME_DATA, RAW_MAX_PAYLOAD, type ItemKind } from './constants.js';
|
||||
import { concat, readU16, toBytes, tryDecodeUtf8, utf8, writeU16, toBase64Url, type Bytes } from './bytes.js';
|
||||
import { IntegrityError, LimitError } from './errors.js';
|
||||
|
||||
// ───────────────────────── 条目元数据(protocol §7) ─────────────────────────
|
||||
|
||||
export interface ItemMeta {
|
||||
kind: ItemKind;
|
||||
name?: string;
|
||||
mime?: string;
|
||||
/** 内容总字节数,流式上传时可能未知。 */
|
||||
size?: number;
|
||||
/** 毫秒时间戳,仅供参考,排序以 seq 为准。 */
|
||||
ts: number;
|
||||
/** 设备名。 */
|
||||
dev?: string;
|
||||
}
|
||||
|
||||
const KINDS: readonly ItemKind[] = ['text', 'json', 'file', 'other'];
|
||||
|
||||
/** u16 meta_len ‖ meta_json。字段顺序固定,undefined 的字段省略,保证字节级确定。 */
|
||||
export function encodeMeta(meta: ItemMeta): Bytes {
|
||||
const o: Record<string, unknown> = { v: 1, kind: meta.kind };
|
||||
if (meta.name !== undefined) o.name = meta.name;
|
||||
if (meta.mime !== undefined) o.mime = meta.mime;
|
||||
if (meta.size !== undefined) o.size = meta.size;
|
||||
o.ts = meta.ts;
|
||||
if (meta.dev !== undefined) o.dev = meta.dev;
|
||||
|
||||
const json = utf8(JSON.stringify(o));
|
||||
if (json.length > 0xffff) throw new LimitError('条目元数据过大');
|
||||
return concat(writeU16(json.length), json);
|
||||
}
|
||||
|
||||
/** 解析第 0 帧的 data:u16 meta_len ‖ meta_json ‖ 内容。 */
|
||||
export function decodeFirstFrame(data: Uint8Array): { meta: ItemMeta; content: Bytes } {
|
||||
if (data.length < 2) throw new IntegrityError('bad_meta');
|
||||
const metaLen = readU16(data, 0);
|
||||
if (data.length < 2 + metaLen) throw new IntegrityError('bad_meta');
|
||||
|
||||
const text = tryDecodeUtf8(data.subarray(2, 2 + metaLen));
|
||||
if (text === null) throw new IntegrityError('bad_meta');
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
throw new IntegrityError('bad_meta');
|
||||
}
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new IntegrityError('bad_meta');
|
||||
const o = raw as Record<string, unknown>;
|
||||
|
||||
if (o.v !== 1 || typeof o.kind !== 'string' || typeof o.ts !== 'number' || !Number.isFinite(o.ts)) {
|
||||
throw new IntegrityError('bad_meta');
|
||||
}
|
||||
const meta: ItemMeta = {
|
||||
// 未知的 kind 按 other 处理,向前兼容
|
||||
kind: (KINDS as readonly string[]).includes(o.kind) ? (o.kind as ItemKind) : 'other',
|
||||
ts: o.ts,
|
||||
};
|
||||
for (const key of ['name', 'mime', 'dev'] as const) {
|
||||
const v = o[key];
|
||||
if (v === undefined) continue;
|
||||
if (typeof v !== 'string') throw new IntegrityError('bad_meta');
|
||||
meta[key] = v;
|
||||
}
|
||||
if (o.size !== undefined) {
|
||||
if (typeof o.size !== 'number' || !Number.isSafeInteger(o.size) || o.size < 0) throw new IntegrityError('bad_meta');
|
||||
meta.size = o.size;
|
||||
}
|
||||
return { meta, content: data.slice(2 + metaLen) };
|
||||
}
|
||||
|
||||
// ───────────────────────── 条目编码(加密) ─────────────────────────
|
||||
|
||||
export type ContentSource = Uint8Array | Blob | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
|
||||
|
||||
export interface PlanOptions {
|
||||
/** 指定条目 id(重试、续传时必须复用同一个);默认随机生成。 */
|
||||
itemId?: Uint8Array;
|
||||
/** 作为频道的「最新快照」。快照必须是单帧,所以内容不能超过一帧。 */
|
||||
snapshot?: boolean;
|
||||
/**
|
||||
* 是否 GATED(不实时内联推送,只能按条目拉取并计下载次数)。
|
||||
* 'auto'(默认):内容放不进一帧,或设置了 maxDownloads 时为 GATED。
|
||||
*/
|
||||
gated?: boolean | 'auto';
|
||||
/** 下载次数上限,0 为不限。设置后条目必须是 GATED;需要作为 X-WD-Max-Downloads 随第 0 帧发送。 */
|
||||
maxDownloads?: number;
|
||||
/** 单帧 data 的字节上限,默认 1,048,572。调小只用于测试和极弱网络。 */
|
||||
maxFrameData?: number;
|
||||
/** 从第几帧开始产出(断点续传)。只支持长度已知的来源(Uint8Array、Blob)。 */
|
||||
fromFrame?: number;
|
||||
}
|
||||
|
||||
export interface EncodedFrame {
|
||||
index: number;
|
||||
final: boolean;
|
||||
/** 完整封装,可直接作为 POST /frames 的请求体。 */
|
||||
bytes: Bytes;
|
||||
}
|
||||
|
||||
export interface ItemPlan {
|
||||
/** 条目 id 的 base64url 文本。 */
|
||||
itemId: string;
|
||||
idBytes: Bytes;
|
||||
/**
|
||||
* 是否 GATED。来源长度已知时在创建计划时就确定;来源是流(长度未知)时,
|
||||
* 要等第 0 帧产出之后才确定,所以请在拿到第 0 帧之后再读取它和 frame0Headers。
|
||||
*/
|
||||
readonly gated: boolean;
|
||||
/** 第 0 帧要带的附加请求头(X-WD-Max-Downloads / X-WD-Label)。 */
|
||||
frame0Headers: Record<string, string>;
|
||||
/** 按序产出加密后的帧。同一个计划只能迭代一次。 */
|
||||
frames: AsyncGenerator<EncodedFrame, void, void>;
|
||||
}
|
||||
|
||||
export async function collectFrames(plan: ItemPlan): Promise<EncodedFrame[]> {
|
||||
const out: EncodedFrame[] = [];
|
||||
for await (const f of plan.frames) out.push(f);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function newItemId(): Bytes {
|
||||
const id = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 把各种来源统一成字节块的异步序列。 */
|
||||
async function* chunksOf(source: ContentSource): AsyncGenerator<Uint8Array, void, void> {
|
||||
if (source instanceof Uint8Array) {
|
||||
yield source;
|
||||
return;
|
||||
}
|
||||
if (typeof Blob !== 'undefined' && source instanceof Blob) {
|
||||
yield* chunksOf(source.stream());
|
||||
return;
|
||||
}
|
||||
if (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream) {
|
||||
// 不依赖 ReadableStream 的异步迭代器(旧版 Safari 没有)
|
||||
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>;
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
data: Bytes;
|
||||
last: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把任意切分的字节流重新切成 [firstCap, cap, cap, ...] 大小的块,并通过向前看一个块知道哪一块是最后一块。
|
||||
* 内容恰好填满某一块时不会多出一个空块;内容为空时产出一个空的最后一块。
|
||||
*/
|
||||
async function* rechunk(source: ContentSource, firstCap: number, cap: number): AsyncGenerator<Chunk, void, void> {
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let target = firstCap;
|
||||
let ended = false;
|
||||
const iter = chunksOf(source)[Symbol.asyncIterator]();
|
||||
|
||||
const take = (n: number): Bytes => {
|
||||
const out = new Uint8Array(n);
|
||||
let filled = 0;
|
||||
while (filled < n) {
|
||||
const head = parts[0] as Uint8Array;
|
||||
const need = n - filled;
|
||||
if (head.length <= need) {
|
||||
out.set(head, filled);
|
||||
filled += head.length;
|
||||
parts.shift();
|
||||
} else {
|
||||
out.set(head.subarray(0, need), filled);
|
||||
parts[0] = head.subarray(need);
|
||||
filled += need;
|
||||
}
|
||||
}
|
||||
total -= n;
|
||||
return out;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
// 需要缓冲到 target+1 字节,才能确定当前块不是最后一块
|
||||
while (!ended && total <= target) {
|
||||
const r = await iter.next();
|
||||
if (r.done) ended = true;
|
||||
else if (r.value.length > 0) {
|
||||
parts.push(r.value);
|
||||
total += r.value.length;
|
||||
}
|
||||
}
|
||||
if (total > target) {
|
||||
yield { data: take(target), last: false };
|
||||
target = cap;
|
||||
continue;
|
||||
}
|
||||
yield { data: take(total), last: true };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function isSized(source: ContentSource): source is Uint8Array | Blob {
|
||||
return source instanceof Uint8Array || (typeof Blob !== 'undefined' && source instanceof Blob);
|
||||
}
|
||||
|
||||
/** 构造条目计划的公共部分:标志位、GATED 的取舍、校验。 */
|
||||
function resolveGating(opts: PlanOptions, fitsInOneFrame: boolean | undefined): boolean {
|
||||
const wantsLimit = opts.maxDownloads !== undefined;
|
||||
if (opts.snapshot && (opts.gated === true || wantsLimit)) throw new RangeError('快照不能是 GATED,也不能设置下载上限');
|
||||
if (opts.gated === true || wantsLimit) return true;
|
||||
if (opts.gated === false) {
|
||||
if (fitsInOneFrame === false) throw new LimitError('内容放不进单帧,不能关闭 GATED');
|
||||
return false;
|
||||
}
|
||||
return fitsInOneFrame === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* enc 模式:把内容加密成一个条目(protocol §7)。
|
||||
* 小内容单帧、非 GATED;放不下就拆成多帧并标记 GATED。第 0 帧携带元数据。
|
||||
*/
|
||||
export function planEncItem(
|
||||
seal: (p: { flags: number; itemId: Uint8Array; index: number; data: Uint8Array }) => Promise<Bytes>,
|
||||
source: ContentSource,
|
||||
meta: ItemMeta,
|
||||
opts: PlanOptions = {},
|
||||
): ItemPlan {
|
||||
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
|
||||
const maxData = opts.maxFrameData ?? MAX_FRAME_DATA;
|
||||
if (maxData < 1 || maxData > MAX_FRAME_DATA) throw new RangeError('maxFrameData 超出范围');
|
||||
|
||||
const head = encodeMeta(meta);
|
||||
const cap0 = maxData - head.length;
|
||||
if (cap0 < 0) throw new LimitError('元数据超过单帧上限');
|
||||
|
||||
const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined;
|
||||
const fits = size === undefined ? undefined : size <= cap0;
|
||||
let gated = resolveGating(opts, fits);
|
||||
|
||||
const fromFrame = opts.fromFrame ?? 0;
|
||||
if (fromFrame > 0 && !isSized(source)) throw new RangeError('只有长度已知的来源才能从中间帧续传');
|
||||
|
||||
const frame0Headers: Record<string, string> = {};
|
||||
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
|
||||
|
||||
const snapshot = opts.snapshot === true;
|
||||
|
||||
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
|
||||
const base = (gatedNow: boolean) => (gatedNow ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | Flags.Enc;
|
||||
const emit = async (index: number, last: boolean, data: Uint8Array, gatedNow: boolean): Promise<EncodedFrame> => ({
|
||||
index,
|
||||
final: last,
|
||||
bytes: await seal({ flags: base(gatedNow) | (last ? Flags.Final : 0), itemId: idBytes, index, data }),
|
||||
});
|
||||
|
||||
if (fromFrame > 0) {
|
||||
// 断点续传:跳过已经上传的帧。第 k(>=1)帧的内容起点 = cap0 + (k-1) * maxData
|
||||
const blob = source as Uint8Array | Blob;
|
||||
const total = blob instanceof Uint8Array ? blob.length : blob.size;
|
||||
if (total <= cap0) throw new RangeError('内容只有一帧,没有可续传的后续帧');
|
||||
const offset = cap0 + (fromFrame - 1) * maxData;
|
||||
if (offset >= total) throw new RangeError('fromFrame 超出条目的帧数');
|
||||
const rest = blob instanceof Uint8Array ? blob.subarray(offset) : blob.slice(offset);
|
||||
let index = fromFrame;
|
||||
for await (const chunk of rechunk(rest, maxData, maxData)) {
|
||||
yield await emit(index++, chunk.last, chunk.data, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
for await (const chunk of rechunk(source, cap0, maxData)) {
|
||||
if (index === 0) {
|
||||
// 第 0 帧:来源长度未知时,到这里才知道放不放得进单帧
|
||||
if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false;
|
||||
else if (!chunk.last) {
|
||||
if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧');
|
||||
if (opts.gated === false) throw new LimitError('内容放不进单帧,不能关闭 GATED');
|
||||
gated = true;
|
||||
}
|
||||
yield await emit(0, chunk.last, concat(head, chunk.data), gated);
|
||||
} else {
|
||||
yield await emit(index, chunk.last, chunk.data, true);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
|
||||
}
|
||||
|
||||
/**
|
||||
* raw 模式:负载原样,没有加密、没有元数据。单帧放不下时拆成 GATED 多帧。
|
||||
* 注意 raw 内容对服务端完全可见。
|
||||
*/
|
||||
export function planRawItem(
|
||||
build: (p: { flags: number; itemId: Uint8Array; index: number; payload: Uint8Array }) => Bytes,
|
||||
source: ContentSource,
|
||||
opts: PlanOptions & { label?: string } = {},
|
||||
): ItemPlan {
|
||||
const idBytes: Bytes = opts.itemId ? toBytes(opts.itemId) : newItemId();
|
||||
const cap = Math.min(opts.maxFrameData ?? RAW_MAX_PAYLOAD, RAW_MAX_PAYLOAD);
|
||||
const snapshot = opts.snapshot === true;
|
||||
|
||||
const size = source instanceof Uint8Array ? source.length : typeof Blob !== 'undefined' && source instanceof Blob ? source.size : undefined;
|
||||
let gated = resolveGating(opts, size === undefined ? undefined : size <= cap);
|
||||
|
||||
const frame0Headers: Record<string, string> = {};
|
||||
if (opts.maxDownloads !== undefined) frame0Headers['X-WD-Max-Downloads'] = String(opts.maxDownloads);
|
||||
if (opts.label !== undefined) frame0Headers['X-WD-Label'] = opts.label;
|
||||
|
||||
async function* frames(): AsyncGenerator<EncodedFrame, void, void> {
|
||||
let index = 0;
|
||||
for await (const chunk of rechunk(source, cap, cap)) {
|
||||
if (index === 0) {
|
||||
if (chunk.last && opts.gated !== true && opts.maxDownloads === undefined) gated = false;
|
||||
else if (!chunk.last) {
|
||||
if (snapshot) throw new LimitError('快照必须是单帧,内容超过了一帧');
|
||||
gated = true;
|
||||
}
|
||||
}
|
||||
const flags = (gated ? Flags.Gated : 0) | (snapshot ? Flags.Snapshot : 0) | (chunk.last ? Flags.Final : 0);
|
||||
yield { index, final: chunk.last, bytes: build({ flags, itemId: idBytes, index, payload: chunk.data }) };
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return { itemId: toBase64Url(idBytes), idBytes, get gated() { return gated; }, frame0Headers, frames: frames() };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user