diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 52edc3b..e6966ae 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,9 +13,13 @@ packages/raptorqr-core src/gif GIF parse/render helpers src/reconstruct payload assembly -packages/raptorqr-wasm - src/fast_qr fast_qr wasm-bindgen artifacts and Colab script - src/raptorq cberner/raptorq wasm-bindgen artifacts and Colab script +packages/raptorqr-fast-qr-wasm + src/wasm fast_qr wasm-bindgen artifacts + src/build_fast_qr_wasm_colab.py + +packages/raptorqr-raptorq-wasm + src/wasm cberner/raptorq wasm-bindgen artifacts + src/build_raptorq_wasm_colab.py packages/raptorqr-cli src/raptorqr.ts CLI entrypoint @@ -33,9 +37,11 @@ apps/web `@raptorqr/core` owns protocol behavior and public transfer APIs. It exports environment-neutral modules from `@raptorqr/core`, browser wrappers from `@raptorqr/core/browser`, and Node/CLI wrappers from `@raptorqr/core/node`. -`@raptorqr/wasm` owns generated WASM artifacts. Core imports fast_qr through `@raptorqr/wasm/fast-qr` and RaptorQ through `@raptorqr/wasm/raptorq`. +`@raptorqr/fast-qr-wasm` owns the generated fast_qr renderer artifacts. Core imports it directly for browser and Node QR rendering. -`@raptorqr/cli` depends on core and wasm. It bundles to `packages/raptorqr-cli/dist/raptorqr.js` and copies required WASM sidecars next to the bundle. +`@raptorqr/raptorq-wasm` owns the generated RaptorQ codec artifacts. Core imports it directly for the primary FEC path. + +`@raptorqr/cli` depends on core and the split WASM packages. It bundles to `packages/raptorqr-cli/dist/raptorqr.js` and copies required WASM sidecars next to the bundle. `@raptorqr/web` is private. It owns Vite, Preact UI, workers, and app-local worker pools. App-local `@/*` imports must not leak into packages. @@ -74,6 +80,7 @@ pnpm dev:web The test split follows ownership: * core: protocol, packetization, FEC, reconstruction -* wasm: generated RaptorQ artifact verification +* fast-qr-wasm: generated fast_qr type/build verification +* raptorq-wasm: generated RaptorQ artifact verification * cli: terminal raster and CLI encode pipeline * web: browser QR/GIF/ZXing/worker integration diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a9be85..a93b3e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,9 @@ The repository includes precompiled WASM artifacts for convenience. If you update Rust/WASM wrappers, also update the related build script and manifest. Generated artifacts should stay under: ```text -packages/raptorqr-wasm/src/fast_qr/wasm -packages/raptorqr-wasm/src/raptorq/wasm +packages/raptorqr-fast-qr-wasm/src/wasm +packages/raptorqr-fast-qr-wasm/src/wasm +packages/raptorqr-raptorq-wasm/src/wasm ``` ## Pull Requests diff --git a/README.md b/README.md index ab0b75d..e2e6efb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Transfer files and text between devices by displaying high-throughput animated Q Everything runs locally in the browser or terminal: no upload server, no Bluetooth, no cable. -RaptorQR is a project inspired by [hermitm0nk/qr-stream](https://github.com/hermitm0nk/qr-stream). The project has since become a substantial rewrite of the core transfer pipeline and user experience: FEC, QR rendering, worker scheduling, scanner integration, sender/receiver UI, CLI packaging, and the repo layout have all been rebuilt around a higher-throughput, production-ready architecture. +RaptorQR started from an earlier open-source QR streaming prototype and has since become a substantial rewrite of the core transfer pipeline and user experience: FEC, QR rendering, worker scheduling, scanner integration, sender/receiver UI, CLI packaging, and the repo layout have all been rebuilt around a higher-throughput, production-ready architecture. Live demo: https://qr.linkto.host/ @@ -46,7 +46,8 @@ The current RaptorQ WASM path is intended to be production-ready for local offli ```text packages/raptorqr-core protocol, packetization, FEC, QR encode/decode APIs -packages/raptorqr-wasm fast_qr and RaptorQ WASM artifacts plus Colab scripts +packages/raptorqr-fast-qr-wasm fast_qr WASM renderer artifacts plus Colab script +packages/raptorqr-raptorq-wasm RaptorQ WASM codec artifacts plus Colab script packages/raptorqr-cli raptorqr terminal CLI apps/web Preact/Vite web app ``` @@ -124,7 +125,7 @@ Run tests: pnpm test ``` -Run the CLI from source: +Run the CLI locally. This builds the Node bundle, then starts it: ```bash pnpm --filter @raptorqr/cli cli @@ -171,15 +172,15 @@ The CLI copies its required WASM sidecars into the same `dist/` directory. The generated artifacts live under: ```text -packages/raptorqr-wasm/src/fast_qr/wasm -packages/raptorqr-wasm/src/raptorq/wasm +packages/raptorqr-fast-qr-wasm/src/wasm +packages/raptorqr-raptorq-wasm/src/wasm ``` The build scripts are: ```text -packages/raptorqr-wasm/src/fast_qr/build_fast_qr_wasm_colab.py -packages/raptorqr-wasm/src/raptorq/build_raptorq_wasm_colab.py +packages/raptorqr-fast-qr-wasm/src/build_fast_qr_wasm_colab.py +packages/raptorqr-raptorq-wasm/src/build_raptorq_wasm_colab.py ``` You can paste the scripts into a Colab notebook to build the WASM artifacts. The scripts will download and build the upstream Rust dependencies, then compile them to WASM. diff --git a/apps/web/package.json b/apps/web/package.json index cdc1047..1008f10 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,8 @@ "type": "module", "dependencies": { "@raptorqr/core": "workspace:*", - "@raptorqr/wasm": "workspace:*", + "@raptorqr/fast-qr-wasm": "workspace:*", + "@raptorqr/raptorq-wasm": "workspace:*", "fflate": "^0.8.2", "preact": "10.29.4" }, diff --git a/apps/web/src/pwa/preload.ts b/apps/web/src/pwa/preload.ts index aa5c354..c82e238 100644 --- a/apps/web/src/pwa/preload.ts +++ b/apps/web/src/pwa/preload.ts @@ -1,5 +1,5 @@ -import fastQrWasmUrl from '@raptorqr/wasm/fast-qr/wasm/qrstream_fast_qr_wasm_bg.wasm?url'; -import raptorqWasmUrl from '@raptorqr/wasm/raptorq/wasm/qrstream_raptorq_wasm_bg.wasm?url'; +import fastQrWasmUrl from '@raptorqr/fast-qr-wasm/wasm/raptorqr_fast_qr_wasm_bg.wasm?url'; +import raptorqWasmUrl from '@raptorqr/raptorq-wasm/wasm/raptorqr_raptorq_wasm_bg.wasm?url'; import { zxingReaderWasmUrl, zxingWriterWasmUrl, diff --git a/apps/web/src/tests/setup.ts b/apps/web/src/tests/setup.ts index 8df1fd5..2abe48c 100644 --- a/apps/web/src/tests/setup.ts +++ b/apps/web/src/tests/setup.ts @@ -56,20 +56,18 @@ const zxingWriterWasmPath = join( const raptorqWasmPath = join( repoRoot, 'packages', - 'raptorqr-wasm', + 'raptorqr-raptorq-wasm', 'src', - 'raptorq', 'wasm', - 'qrstream_raptorq_wasm_bg.wasm', + 'raptorqr_raptorq_wasm_bg.wasm', ); const fastQrWasmPath = join( repoRoot, 'packages', - 'raptorqr-wasm', + 'raptorqr-fast-qr-wasm', 'src', - 'fast_qr', 'wasm', - 'qrstream_fast_qr_wasm_bg.wasm', + 'raptorqr_fast_qr_wasm_bg.wasm', ); // happy-dom's Response is not accepted by Node's instantiateStreaming; tests can @@ -96,11 +94,11 @@ globalThis.fetch = async (input, init) => { return wasmResponse(url, zxingWriterWasmPath); } - if (url.includes('qrstream_raptorq_wasm_bg.wasm')) { + if (url.includes('raptorqr_raptorq_wasm_bg.wasm')) { return wasmResponse(url, raptorqWasmPath); } - if (url.includes('qrstream_fast_qr_wasm_bg.wasm')) { + if (url.includes('raptorqr_fast_qr_wasm_bg.wasm')) { return wasmResponse(url, fastQrWasmPath); } @@ -124,8 +122,14 @@ function findRepoRoot(startDir: string): string { } async function wasmResponse(url: string, fallbackPath: string): Promise { - const localPath = viteFsPath(url); - const bytes = await readFile(localPath && existsSync(localPath) ? localPath : fallbackPath); + const explicitPath = viteFsPath(url) ?? repoRelativeFsPath(url); + const localPath = explicitPath ?? fallbackPath; + + if (!existsSync(localPath)) { + throw new Error(`Unable to resolve test WASM asset: ${url} -> ${localPath}`); + } + + const bytes = await readFile(localPath); const body = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength); return new Response(body, { headers: { 'Content-Type': 'application/wasm' }, @@ -140,3 +144,20 @@ function viteFsPath(url: string): string | null { const rawPath = decodeURIComponent(url.slice(idx + marker.length)); return normalize(rawPath); } + +function repoRelativeFsPath(url: string): string | null { + try { + const parsed = new URL(url, 'http://localhost'); + + if (parsed.protocol === 'file:') { + return normalize(fileURLToPath(parsed)); + } + + const relativePath = decodeURIComponent(parsed.pathname).replace(/^\/+/, ''); + return relativePath ? normalize(join(repoRoot, relativePath)) : null; + } catch { + if (!url.startsWith('/')) return null; + const relativePath = decodeURIComponent(url).replace(/^\/+/, ''); + return relativePath ? normalize(join(repoRoot, relativePath)) : null; + } +} diff --git a/audit1 b/audit1 deleted file mode 100644 index 6741315..0000000 --- a/audit1 +++ /dev/null @@ -1,438 +0,0 @@ -# @hermitm0nk/qr-stream 初步核心审计结论 - -审计对象:`@hermitm0nk/qr-stream` -本地目录:`D:\Code\QRloop` -上游 commit:`80f8633939321a9ed60b7eb6e8da857b94e7903a` -package 版本:`0.8.4` -审计日期:2026-07-06 - -本次只做只读审计,重点回答四个核心问题: - -1. 有没有用奇怪的、不知名的包。 -2. 构建脚本有没有除了下载包以外的网络请求,源代码有没有网络请求。 -3. 有没有可疑文件,尤其是二进制文件。 -4. `RLNC over GF(256)` / fountain coding 的实现是否正确、是否标准、是否用了库。 - -## 总结 - -没有看到明显恶意供应链、偷偷外联、混淆载荷或可疑二进制文件。依赖整体与项目用途匹配:压缩、GIF、QR 编解码、UI、构建测试。 - -真正的核心问题在算法实现:FEC/RLNC/RS/GF(256) 都是项目自写,不是第三方成熟库;小规模闭环实现看起来基本能工作,但它不是标准 rateless fountain / Raptor / RaptorQ 实现,而且外层 Reed-Solomon 在 GF(256) 上没有处理 255 个评估点的硬上限。当前宣传/配置的 8MB 文件上限与这个数学限制冲突很大:预处理后数据超过约 0.8MB 时,外层 RS 会变得不可靠甚至直接失败。 - -## 1. 依赖包结论 - -### 运行时依赖 - -`package.json` 的运行依赖位于 `package.json:62`: - -- `fflate`: 压缩/解压,用于 deflate/inflate。用途匹配。 -- `gifenc`: GIF 编码。包相对小众,但用途匹配,没有发现多余行为。 -- `jsqr`: 浏览器端 QR 解码。用途匹配。 -- `preact`: UI 框架。用途匹配。 -- `qrcode-generator`: QR 生成。用途匹配。 - -源码中对应使用点: - -- `fflate`: `src/core/sender/packetizer.ts:20`、`src/workers/decode.worker.ts:8` -- `gifenc`: `src/core/gif/gif_render.ts:10` -- `jsqr`: `src/core/qr/qr_decode.ts:8` -- `qrcode-generator`: `src/core/qr/qr_encode.ts:9` -- `preact`: `src/main.tsx:4`、`src/app/app.tsx:4` - -结论:没有看到明显拼写钓鱼、强行塞入的陌生运行依赖,包的用途都能和功能对应上。 - -### 开发依赖 - -开发依赖位于 `package.json:54`: - -- `@preact/preset-vite` -- `@types/bun` -- `happy-dom` -- `typescript` -- `vite` -- `vitest` - -这些也都和 Vite/Preact/测试链路匹配。 - -### 供应链注意点 - -1. 锁文件不一致。 - -`package.json` 的包名是 `@hermitm0nk/qr-stream`,bin 是 `qr-stream`:`package.json:2`、`package.json:10`。 - -但 `package-lock.json` / `bun.lock` 的根包名仍是 `qr`,`package-lock.json` 里还保留了旧的 `qr-terminal` bin: - -- `package-lock.json:2` -- `package-lock.json:16` -- `bun.lock:6` - -这不是恶意证据,但说明发布/锁文件流程不干净。既然后续你习惯用 pnpm,建议生成并提交 `pnpm-lock.yaml`,然后删除或同步旧锁文件。 - -2. `esbuild` 有安装脚本。 - -`package-lock.json:875` 的 `esbuild` 标记了 `hasInstallScript: true`。这是 esbuild 常见的按平台选择/准备二进制逻辑,不算异常;但从供应链审计角度,它确实属于安装期执行代码。 - -3. 依赖版本有漏洞告警。 - -之前基于现有 lockfile 跑过 npm audit,发现 `preact@10.26.5` 有生产依赖 high 级告警;`happy-dom`、`vite`、`vitest` 也有开发依赖告警。因为仓库没有 `pnpm-lock.yaml`,`pnpm audit` 无法直接跑。后续应以 pnpm 接管后重新审计。 - -## 2. 构建脚本和源码网络行为 - -### 构建脚本 - -脚本在 `package.json:22`: - -- `dev`: `vite` -- `build`: `vite build && npm run build:cli` -- `preview`: `vite preview` -- `test`: `vitest run` -- `build:cli`: `esbuild src/cli/qr-stream.ts --bundle --platform=node --target=node18 --outfile=dist/qr-stream.js --format=esm --external:node:*` -- `prepublishOnly`: `npm run build` - -没有看到 `curl`、`wget`、`Invoke-WebRequest`、`fetch`、`node -e`、`npx` 之类额外外联命令。构建时除了包管理器安装依赖以外,没有显式网络请求脚本。 - -注意:`build` / `prepublishOnly` 里仍然写的是 `npm run`,和你想用 pnpm 的习惯不一致。建议改成 `pnpm run build:cli` / `pnpm run build`,同时提交 `pnpm-lock.yaml`。 - -### 源码网络行为 - -全仓搜索了以下网络相关 API: - -- `fetch` -- `XMLHttpRequest` -- `WebSocket` -- `EventSource` -- `sendBeacon` -- `http.request` -- `https.request` -- `net.connect` -- `tls.connect` -- `curl` -- `wget` -- 动态 `import("http...")` - -没有发现源码存在外发网络请求。 - -唯一网络相关源码是 CLI 本地静态服务器: - -- `src/cli/static_server.ts:1` import Node `http` -- `src/cli/static_server.ts:59` 创建 server -- `src/cli/static_server.ts:103` 默认监听 `0.0.0.0` -- `src/cli/qr-stream.ts:169` `--serve` 入口 - -这是入站监听服务,不是外发请求。风险点是默认绑定 `0.0.0.0`,局域网可访问;但 README 也给了 `--host 127.0.0.1` 用法。 - -浏览器端使用了这些敏感但合理的本地能力: - -- 摄像头:`navigator.mediaDevices.getUserMedia`,见 `src/app/routes/receiver.tsx:295` -- Blob URL:`URL.createObjectURL`,用于下载/预览,见 `src/app/routes/sender.tsx:282`、`src/app/routes/receiver.tsx:475` -- Web Worker:编码/解码/GIF 生成,属于本地线程通信,不是网络请求 - -结论:没有看到偷偷外联。 - -## 3. 可疑文件 / 二进制文件 - -Git 跟踪文件共 48 个,主要是: - -- TypeScript / TSX 源码 -- 测试文件 -- 文档 -- `package.json` -- `package-lock.json` -- `bun.lock` -- HTML / Vite 配置 - -没有发现 tracked 的二进制文件、图片、压缩包、wasm、exe、dll、字体、mp4 等常见二进制扩展。也没有发现 NUL 字节型二进制文件。 - -最大 tracked 文件大致为: - -- `bun.lock`: 约 42KB -- `package-lock.json`: 约 40KB -- `src/tests/complete.test.ts`: 约 27KB -- `src/app/routes/receiver.tsx`: 约 26KB -- `ARCHITECTURE.md`: 约 16KB - -没有发现大型 bundle 或混淆产物。`dist/` 没有被提交。 - -根目录有一个未跟踪文件: - -- `audit1` - -它是本审计报告文件,不是上游 repo 文件。`git status --short` 显示 `?? audit1`。 - -结论:没有看到可疑二进制文件或混淆载荷。 - -## 4. RLNC / GF(256) / fountain coding 实现 - -### 是否用了库 - -没有。 - -FEC 相关实现全部在 `src/core/fec/` 下自写: - -- `src/core/fec/gf256.ts` -- `src/core/fec/rlnc_encoder.ts` -- `src/core/fec/rlnc_decoder.ts` -- `src/core/fec/outer_rs.ts` -- `src/core/fec/xoshiro.ts` - -`package.json` 中没有 Reed-Solomon、Galois field、RLNC、Raptor、fountain code 相关第三方库。 - -### 实现结构 - -协议参数: - -- `MAX_PAYLOAD_SIZE = 201`,见 `src/core/protocol/constants.ts:27` -- `K = 16`,每代 16 个 source symbols,见 `src/core/protocol/constants.ts:49` -- `R = 8`,每代 8 个 repair/coded symbols,见 `src/core/protocol/constants.ts:52` -- QR profile 是 V10-M,见 `src/core/protocol/constants.ts:55` -- 外层 RS overhead 是 `0.03`,见 `src/core/protocol/constants.ts:66` - -发送流程: - -1. 可选文件 metadata 包装。 -2. 可选 deflate 压缩。 -3. 切成 201-byte symbols。 -4. 每 16 个 symbols 组成一个 generation。 -5. 先对 generation 做外层 Reed-Solomon parity。 -6. 再对每个 generation 做系统型 RLNC:16 个 systematic + 8 个 coded。 - -关键位置: - -- 切 symbol / generation:`src/core/sender/packetizer.ts:86` -- 外层 RS:`src/core/sender/packetizer.ts:119` -- RLNC encode:`src/core/sender/packetizer.ts:138` - -### 哪些部分方向是对的 - -GF(256) 基础方向基本正确: - -- 加减是 XOR:`src/core/fec/gf256.ts:75` -- 乘法用 log/exp 表:`src/core/fec/gf256.ts:98` -- 除法处理除零:`src/core/fec/gf256.ts:116` - -RLNC encoder 是系统码: - -- 前 K 个包是原始 source symbols,系数是单位向量:`src/core/fec/rlnc_encoder.ts:120` -- 后 R 个包是 GF(256) 线性组合:`src/core/fec/rlnc_encoder.ts:133` - -RLNC decoder 用增量高斯消元: - -- forward elimination:`src/core/fec/rlnc_decoder.ts:70` -- pivot 归一化:`src/core/fec/rlnc_decoder.ts:93` -- rank 到 K 后 solve:`src/core/fec/rlnc_decoder.ts:167` - -外层 RS 是系统化 generalized Reed-Solomon 的思路: - -- Lagrange coefficient:`src/core/fec/outer_rs.ts:28` -- encode parity:`src/core/fec/outer_rs.ts:48` -- decode 时解缺失 source generation:`src/core/fec/outer_rs.ts:155` - -测试覆盖了小规模闭环: - -- RLNC encoder/decoder:`src/tests/complete.test.ts:106`、`src/tests/complete.test.ts:151` -- Outer RS:`src/tests/complete.test.ts:245` -- 丢帧端到端:`src/tests/complete.test.ts:573` -- 整代丢失恢复:`src/tests/outer_ec_benefit.test.ts:16` - -结论:在小规模、自家编码解码闭环里,主体思路基本能成立。 - -### 哪些部分不是标准 fountain - -这个实现不应被理解成标准 rateless fountain code。 - -原因: - -1. 每代固定只发 `K + R = 24` 个包。 - -`K=16`、`R=8` 是固定常量,不会像 LT/Raptor/RaptorQ 那样持续生成任意数量 repair symbols。 - -2. 系数不随包发送。 - -coded symbol 的系数由 `(generationIndex, codedSymbolIndex)` 通过自写 seed + xoshiro 重新生成: - -- seed:`src/core/fec/rlnc_encoder.ts:37` -- decoder 重建系数:`src/core/fec/rlnc_decoder.ts:237` - -这可以省 header 空间,但强绑定本实现,不具备标准互操作性。 - -3. 调度不是“真正任意片段足够即可恢复”。 - -调度先发所有 systematic 层,再发 coded 层: - -- systematic section:`src/core/sender/scheduler.ts:67` -- coded section:`src/core/sender/scheduler.ts:76` - -对完整循环观看是有用的,但如果接收者只看到有限尾段,只有 8 个 coded 层时无法解出 K=16 的任一 generation。因此它更像“固定冗余的系统型 RLNC 分块传输”,不是标准 rateless fountain。 - -### 关键数学风险:外层 RS 超过 GF(256) 码长 - -这是最严重问题。 - -`src/core/fec/outer_rs.ts:20`: - -```ts -function fastEvalPoint(i: number): number { - return pow(PRIMITIVE, i % 255); -} -``` - -GF(256) 的非零评估点最多 255 个。这里用 `i % 255`,说明超过 255 后评估点会重复。 - -当前每个 source generation 容量是: - -```text -K * MAX_PAYLOAD_SIZE = 16 * 201 = 3216 bytes -``` - -外层 parity: - -```ts -Math.floor(sourceGenerations * 0.03) -``` - -见 `src/core/protocol/constants.ts:69`。 - -因此: - -- 当 `sourceGenerations = 249` 时,`P = floor(249 * 0.03) = 7`,`G + P = 256`,已经超过 GF(256) 非零点数量。 -- 预处理数据超过 `248 * 3216 = 797,568` bytes 后,就会进入 `G + P > 255` 风险区。 -- 当 `sourceGenerations >= 256` 时,source evaluation points 本身也重复,Lagrange 分母可能为 0,编码阶段可能直接抛 `GF(256) division by zero`。 -- 预处理数据超过 `255 * 3216 = 820,080` bytes 后,就进入这个更严重区域。 - -这和 README / UI 的 8MB 上限明显冲突: - -- README 写 8MB file size limit:`README.md:9` -- UI 限制 8MB:`src/app/routes/sender.tsx:192` -- 架构文档也写 Max file size ~8 MB:`ARCHITECTURE.md:136` - -结论:当前外层 RS 数学上无法支撑 8MB 级别的不压缩/弱压缩文件。对可压缩文本可能因为压缩后低于 0.8MB 而没触发,但随机数据、图片、zip/pdf 等弱压缩文件会有明显风险。 - -### 其他 FEC/协议风险 - -1. `inv(0)` 返回 0。 - -`src/core/fec/gf256.ts:154` 中 `inv(0)` 返回 0。数学上 0 没有逆元。虽然部分路径会先检查 pivot,但 `outer_rs.ts` 的 `invertMatrix` 1x1 分支调用 `inv(matrix[0][0])`,如果矩阵奇异,可能静默得到错误结果,而不是抛错: - -- `src/core/fec/outer_rs.ts:211` - -2. `xoshiro` / `splitmix64` 不是严格参考实现。 - -`src/core/fec/xoshiro.ts` 注释称参考 xoshiro128** / splitmix64,但 seed expansion 里把输出值继续当下一轮 state,且 BigInt 乘法没有在每一步完全模拟参考实现的 64-bit overflow。编码/解码自洽,但不应称作标准可互操作 PRNG: - -- `src/core/fec/xoshiro.ts:31` -- `src/core/fec/xoshiro.ts:74` - -3. Header 字段静默截断。 - -协议头里: - -- `generationIndex`: 12 bit -- `totalGenerations`: 12 bit -- `symbolIndex`: 5 bit -- `dataLength`: 24 bit - -见 `src/core/protocol/packet.ts:15`。 - -但序列化时直接 mask: - -- `generationIndex & 0xfff` -- `totalGenerations & 0xfff` -- `symbolIndex & 0x1f` - -见 `src/core/protocol/packet.ts:72`。 - -没有上限检查,超过范围会写出错误 header,而不是拒绝。 - -4. `symbolIndex` reserved 范围未拒绝。 - -协议注释中 24-31 是 reserved:`src/core/protocol/packet.ts:22`。但解码路径把所有 `symbolIndex >= K` 都当 coded symbol: - -- `src/workers/decode.worker.ts:125` - -5. 没有 session / transfer id。 - -架构文档明确说没有 session IDs:`ARCHITECTURE.md:125`。接收端会信任包里的 `totalGenerations`、`dataLength`、`compressed` 等字段,并且后续包还会持续覆盖当前状态: - -- `src/workers/decode.worker.ts:88` -- `src/workers/decode.worker.ts:107` - -这不影响“包是否恶意外联”,但影响协议稳健性:旧 GIF、旁边屏幕、恶意 QR 都可能污染当前传输。 - -6. GF 注释有错。 - -`src/core/fec/gf256.ts:4` 说 `0x11d` 是 AES 使用的多项式,这不对。`0x11d` 是 RS/QR 常见多项式;AES 常见是 `0x11b`。代码用 `0x11d` 本身不一定错,但注释不准确。 - -## 修复建议优先级 - -### P0:修正或限制 FEC 规模 - -必须处理 GF(256) 外层 RS 的 255 点限制。可选路线: - -1. 明确限制预处理后数据小于约 780KB,并把 README/UI 的 8MB 改掉。 -2. 外层 RS 分块,每个 RS block 保证 `G + P <= 255`。 -3. 改用更大域,例如 GF(2^16),但实现复杂度和性能都会上升。 -4. 直接使用成熟 erasure coding/fountain code 库,避免自写数学边界。 - -如果目标是“浏览器里可靠传文件流”,建议优先考虑成熟库或至少分块 RS。 - -### P1:协议边界校验 - -在 packetize / serialize 前明确检查: - -- `generationIndex <= 4095` -- `totalGenerations <= 4095` -- `symbolIndex <= 23` -- `dataLength <= 0xffffff` -- `payload.length <= MAX_PAYLOAD_SIZE` - -解析时也拒绝 reserved `symbolIndex`、超大 payload、不一致 metadata。 - -### P1:加 session/transfer id - -header 或 manifest 里加入: - -- transfer id -- profile/version -- total length -- content hash 或 manifest checksum - -接收端只接受同一个 transfer id 且 metadata 一致的包。 - -### P1:整理 pnpm 供应链 - -既然后续用 pnpm: - -1. 生成 `pnpm-lock.yaml`。 -2. 删除或同步 `package-lock.json` / `bun.lock`。 -3. 把脚本里的 `npm run` 改成 `pnpm run`。 -4. 用 pnpm 重新跑 audit。 - -### P2:升级有漏洞依赖 - -优先处理: - -- `preact@10.26.5` -- `vite` -- `vitest` -- `happy-dom` - -其中 `preact` 是生产依赖,优先级最高。 - -### P2:补测试 - -建议增加边界测试: - -- `G = 248` -- `G = 249` -- `G = 255` -- `G = 256` -- 弱压缩/不可压缩大文件 -- 随机 erasure property test -- metadata 混入 / transfer 混入测试 -- reserved `symbolIndex` 测试 - -## 最终判断 - -从恶意代码/供应链角度看:没有发现明显恶意包、偷偷联网、可疑二进制或混淆文件。 - -从算法可靠性角度看:当前项目对“fountain code / outer RS redundancy / 8MB 文件传输”的表达偏乐观。它是自写的固定冗余系统 RLNC + 自写 outer RS,不是标准 rateless fountain;小文件闭环可工作,但大文件在约 0.8MB 之后会撞上 GF(256) 外层 RS 的硬边界。这个是最需要优先处理的问题。 diff --git a/benchmark.final.test.ts b/benchmark.final.test.ts new file mode 100644 index 0000000..46764d2 --- /dev/null +++ b/benchmark.final.test.ts @@ -0,0 +1,277 @@ +/** + * Final high-throughput transfer benchmark. + * + * Usage: + * pnpm install + * pnpm build + * pnpm benchmark:final + * + * Scenario: + * - QR profile: V30-L, fast QR WASM + * - Display cadence: 30 fps for 10 seconds + * - Parallelism: 4 QR codes per display frame, 2x2 layout + * - QR symbol slots: 30 * 10 * 4 = 1200 + * - RaptorQ repair: 20% + * - Loss model: deterministic random loss of 200 QR symbols + * + * This is intentionally separate from `pnpm benchmark` because it renders and + * parses 1200 large QR symbols. It is a release/final gate, not a lightweight + * edit-loop test. + */ + +import './apps/web/src/tests/setup'; + +import { performance } from 'node:perf_hooks'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { initSync as initRaptorQSync } from '@raptorqr/raptorq-wasm'; +import { RaptorQWasmDecoder } from '@raptorqr/core/fec/raptorq_wasm'; +import { RAPTORQ_SYMBOL_INDEX } from '@raptorqr/core/protocol/constants'; +import { createQRTransferProfile } from '@raptorqr/core/protocol/profiles'; +import { packetCodec, parsePacket } from '@raptorqr/core/protocol/packet'; +import { decodeQRCodesFromCanvas } from '@raptorqr/core/qr/qr_decode'; +import { renderQRCodeImageData } from '@raptorqr/core/qr/qr_encoder_browser'; +import { packetizeRaptorQ } from '@raptorqr/core/sender/raptorq_packetizer'; + +const QR_VERSION = 30; +const ECC_LEVEL = 'L'; +const FPS = 30; +const DURATION_SECONDS = 10; +const PARALLEL_QR_COUNT = 4; +const DISPLAY_FRAMES = FPS * DURATION_SECONDS; +const TOTAL_QR_SYMBOLS = DISPLAY_FRAMES * PARALLEL_QR_COUNT; +const REPAIR_PERCENT = 20; +const DROPPED_QR_SYMBOLS = 200; +const SCALE = 2; +const FINAL_TIMEOUT_MS = 300_000; +const DROP_SEED = 0x30f5_1200; + +function packageRoot(packageName: string): string { + const linkPath = join('node_modules', '@raptorqr', packageName); + expect(existsSync(linkPath)).toBe(true); + return realpathSync(linkPath); +} + +function raptorqWasmPath(): string { + return join( + packageRoot('raptorq-wasm'), + 'src', + 'wasm', + 'raptorqr_raptorq_wasm_bg.wasm', + ); +} + +function deterministicPayload(byteLength: number): Uint8Array { + const out = new Uint8Array(byteLength); + let state = 0x9e37_79b9; + + for (let i = 0; i < out.length; i++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + out[i] = (state + i) & 0xff; + } + + return out; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, index) => byte === b[index]); +} + +function droppedSymbolSet(totalSymbols: number, dropCount: number, seed: number): Set { + const indices = Array.from({ length: totalSymbols }, (_, index) => index); + let state = seed >>> 0; + + for (let i = indices.length - 1; i > 0; i--) { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + const j = state % (i + 1); + const tmp = indices[i]!; + indices[i] = indices[j]!; + indices[j] = tmp; + } + + return new Set(indices.slice(0, dropCount)); +} + +function raptorQPayloadId(payload: Uint8Array): string { + expect(payload.length).toBeGreaterThanOrEqual(4); + return `${payload[0]}:${payload[1]}:${payload[2]}:${payload[3]}`; +} + +function blitImageData( + target: Uint8ClampedArray, + targetWidth: number, + source: Uint8ClampedArray, + sourceWidth: number, + sourceHeight: number, + x: number, + y: number, +): void { + for (let row = 0; row < sourceHeight; row++) { + const sourceStart = row * sourceWidth * 4; + const sourceEnd = sourceStart + sourceWidth * 4; + const targetStart = ((y + row) * targetWidth + x) * 4; + target.set(source.subarray(sourceStart, sourceEnd), targetStart); + } +} + +function tileOffset(tileIndex: number, tileSize: number): { x: number; y: number } { + return { + x: (tileIndex % 2) * tileSize, + y: Math.floor(tileIndex / 2) * tileSize, + }; +} + +function round(value: number, digits = 2): number { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} + +describe('final V30 4-way transfer benchmark', () => { + test('decodes 30fps * 10s * 4 QR with 20% repair and 200 random symbol losses', async () => { + initRaptorQSync({ module: readFileSync(raptorqWasmPath()) }); + + const profile = createQRTransferProfile(QR_VERSION, ECC_LEVEL, 'fast-qr-wasm'); + const sourceSymbols = TOTAL_QR_SYMBOLS - DROPPED_QR_SYMBOLS; + const payload = deterministicPayload(sourceSymbols * (profile.maxPayloadSize - 4)); + const dropSet = droppedSymbolSet(TOTAL_QR_SYMBOLS, DROPPED_QR_SYMBOLS, DROP_SEED); + const tileModules = QR_VERSION * 4 + 17 + 8; + const tileSize = tileModules * SCALE; + const compositeWidth = tileSize * 2; + const compositeHeight = tileSize * 2; + + const packetizeStart = performance.now(); + const packetized = await packetizeRaptorQ( + payload, + false, + false, + undefined, + undefined, + { + maxTransportPayloadSize: profile.maxPayloadSize, + repairPercent: REPAIR_PERCENT, + }, + ); + const packetizeMs = performance.now() - packetizeStart; + + expect(packetized.packets).toHaveLength(TOTAL_QR_SYMBOLS); + expect(packetized.symbolSize).toBe(profile.maxPayloadSize); + expect(packetized.dataLength).toBe(payload.length); + + const decoder = await RaptorQWasmDecoder.create(packetized.dataLength, profile.maxPayloadSize); + const seenPayloadIds = new Set(); + let decodedPayload: Uint8Array | null = null; + let displayFramesConsumed = 0; + let renderedQrSymbols = 0; + let decodedQrSymbols = 0; + let parsedPackets = 0; + let renderMs = 0; + let qrDecodeMs = 0; + let raptorqDecodeMs = 0; + + for (let displayFrame = 0; displayFrame < DISPLAY_FRAMES; displayFrame++) { + const composite = new Uint8ClampedArray(compositeWidth * compositeHeight * 4); + composite.fill(255); + + const renderStart = performance.now(); + for (let tileIndex = 0; tileIndex < PARALLEL_QR_COUNT; tileIndex++) { + const symbolIndex = displayFrame * PARALLEL_QR_COUNT + tileIndex; + if (dropSet.has(symbolIndex)) continue; + + const packet = packetized.packets[symbolIndex]!; + const qrImage = await renderQRCodeImageData( + packet, + QR_VERSION, + ECC_LEVEL, + SCALE, + 'fast-qr-wasm', + ); + const { x, y } = tileOffset(tileIndex, tileSize); + blitImageData(composite, compositeWidth, qrImage.data, qrImage.width, qrImage.height, x, y); + renderedQrSymbols++; + } + renderMs += performance.now() - renderStart; + + const imageData = new ImageData(composite, compositeWidth, compositeHeight); + const qrDecodeStart = performance.now(); + const decodedSymbols = await decodeQRCodesFromCanvas(imageData, PARALLEL_QR_COUNT); + qrDecodeMs += performance.now() - qrDecodeStart; + decodedQrSymbols += decodedSymbols.length; + displayFramesConsumed = displayFrame + 1; + + const raptorqStart = performance.now(); + for (const decoded of decodedSymbols) { + expect(decoded.version).toBe(QR_VERSION); + + const packet = parsePacket(decoded.bytes); + expect(packetCodec(packet.header)).toBe('wasm-raptorq'); + expect(packet.header.symbolIndex).toBe(RAPTORQ_SYMBOL_INDEX); + expect(packet.header.dataLength).toBe(packetized.dataLength); + expect(packet.header.totalGenerations).toBe(TOTAL_QR_SYMBOLS); + + const payloadId = raptorQPayloadId(packet.payload); + if (seenPayloadIds.has(payloadId)) continue; + seenPayloadIds.add(payloadId); + parsedPackets++; + + decodedPayload = decoder.push(packet.payload); + if (decodedPayload) break; + } + raptorqDecodeMs += performance.now() - raptorqStart; + + if (decodedPayload) break; + } + + expect(renderedQrSymbols).toBe(TOTAL_QR_SYMBOLS - DROPPED_QR_SYMBOLS); + expect(decodedQrSymbols).toBe(renderedQrSymbols); + expect(parsedPackets).toBe(sourceSymbols); + expect(decodedPayload).not.toBeNull(); + expect(bytesEqual(payload, decodedPayload!.slice(0, payload.length))).toBe(true); + + const parseMs = qrDecodeMs + raptorqDecodeMs; + const totalMeasuredMs = packetizeMs + renderMs + parseMs; + const scheduledSecondsToRecover = displayFramesConsumed / FPS; + const scheduledKbPerSecond = (payload.length / 1024) / scheduledSecondsToRecover; + const parserKbPerSecond = (payload.length / 1024) / (parseMs / 1000); + const parserDisplayFps = displayFramesConsumed / (parseMs / 1000); + const parserQrSymbolsPerSecond = parsedPackets / (parseMs / 1000); + + console.info('[bench:final] scenario', { + profile: profile.label, + fps: FPS, + durationSeconds: DURATION_SECONDS, + displayFrames: DISPLAY_FRAMES, + parallelQrCount: PARALLEL_QR_COUNT, + totalQrSymbols: TOTAL_QR_SYMBOLS, + repairPercent: REPAIR_PERCENT, + droppedQrSymbols: DROPPED_QR_SYMBOLS, + sourceSymbols, + payloadBytes: payload.length, + tileSize, + composite: `${compositeWidth}x${compositeHeight}`, + }); + console.table([ + { + packetizeMs: round(packetizeMs), + renderMs: round(renderMs), + qrDecodeMs: round(qrDecodeMs), + raptorqDecodeMs: round(raptorqDecodeMs), + totalMeasuredMs: round(totalMeasuredMs), + displayFramesConsumed, + parsedPackets, + scheduledKbPerSecond: round(scheduledKbPerSecond), + parserKbPerSecond: round(parserKbPerSecond), + parserDisplayFps: round(parserDisplayFps), + parserQrSymbolsPerSecond: round(parserQrSymbolsPerSecond), + }, + ]); + + expect(displayFramesConsumed).toBeLessThanOrEqual(DISPLAY_FRAMES); + expect(scheduledKbPerSecond).toBeGreaterThan(100); + expect(parserDisplayFps).toBeGreaterThan(30); + }, FINAL_TIMEOUT_MS); +}); diff --git a/benchmark.md b/benchmark.md new file mode 100644 index 0000000..3b15475 --- /dev/null +++ b/benchmark.md @@ -0,0 +1,83 @@ +# Benchmark Reference + +These numbers are reference measurements for the real WASM QR transfer path. +They are not portable guarantees: CPU, Node/V8 version, OS scheduling, and +test-runner load can move the results. Use them as a baseline for spotting +large regressions before publishing. + +## Commands + +```bash +pnpm build +pnpm benchmark +pnpm benchmark:final +``` + +`pnpm benchmark` is the lightweight edit-loop check. `pnpm benchmark:final` +is the release gate for the high-throughput scenario below. + +## Final V30 4-Way Transfer + +The final benchmark is implemented in `benchmark.final.test.ts` and exercises +the end-to-end transfer stack: + +- RaptorQ packetization through `@raptorqr/raptorq-wasm` +- QR rendering through `@raptorqr/fast-qr-wasm` +- 4 QR symbols per display frame in a 2x2 composite image +- QR parsing through `decodeQRCodesFromCanvas` +- packet parsing and RaptorQ decode back to the original payload + +Scenario: + +| Setting | Value | +| --- | ---: | +| QR profile | V30-L | +| QR renderer | fast QR WASM | +| Display rate | 30 fps | +| Duration | 10 seconds | +| Display frames | 300 | +| Parallel QR symbols | 4 per display frame | +| Total QR symbol slots | 1200 | +| RaptorQ repair | 20% | +| Dropped QR symbols | 200 | +| Source QR symbols received | 1000 | +| Payload bytes | 1,716,000 | +| QR tile size | 290 px | +| Composite frame size | 580 x 580 px | + +The loss model drops 200 QR symbols, not 200 display frames. Dropping 200 +display frames in a 4-way layout could remove up to 800 QR symbols, which is a +different and much harsher scenario than the 20% repair budget is intended to +cover. + +## Reference Result + +Reference run: + +- Date: 2026-07-09 +- OS: Microsoft Windows NT 10.0.26200.0 +- Logical processors: 20 +- Processor identifier: Intel64 Family 6 Model 186 Stepping 2, GenuineIntel +- Node.js: v22.19.0 +- pnpm: 10.15.1 + +Observed result from `pnpm benchmark:final`: + +| Metric | Value | +| --- | ---: | +| Packetize time | 73.62 ms | +| QR render time | 4078.21 ms | +| QR decode time | 2698.88 ms | +| RaptorQ decode time | 126.00 ms | +| Total measured time | 6976.71 ms | +| Display frames consumed | 300 | +| Parsed packets | 1000 | +| Scheduled transfer speed | 167.58 KiB/s | +| Parser throughput | 593.22 KiB/s | +| Parser display rate | 106.20 fps | +| Parser QR symbol rate | 354.00 QR/s | + +The most important release-readiness number here is parser display rate. This +run parsed the synthetic 30 fps stream at about 106 fps, leaving a large margin +above the target playback cadence while recovering the payload after 200 lost +QR symbols. diff --git a/benchmark.test.ts b/benchmark.test.ts new file mode 100644 index 0000000..0a6944c --- /dev/null +++ b/benchmark.test.ts @@ -0,0 +1,353 @@ +/** + * Full-chain benchmark for the publishable packages. + * + * Usage: + * pnpm install + * pnpm build + * pnpm benchmark + * + * What this measures: + * - @raptorqr/core packetizeRaptorQ over the RaptorQ WASM package + * - @raptorqr/fast-qr-wasm rendering each transport packet to QR ImageData + * - zxing-wasm decoding those QR images back to packet bytes + * - @raptorqr/core RaptorQ decoder reconstructing the original payload + * + * Useful knobs: + * RAPTORQR_BENCH_PAYLOAD_KB=8 + * RAPTORQR_BENCH_ITERATIONS=3 + * RAPTORQR_BENCH_SCALE=3 + * + * Optional regression gates: + * RAPTORQR_BENCH_MAX_AVG_TOTAL_MS=5000 + * RAPTORQR_BENCH_MAX_QR_DECODE_FRAME_MS=80 + * RAPTORQR_BENCH_MAX_RENDER_FRAME_MS=20 + * RAPTORQR_BENCH_MIN_KBPS=20 + * + * Default gates are intentionally loose because local laptops and CI runners + * vary a lot. Use the env gates above once you have a baseline for your target + * machine or CI runner. + */ + +import './apps/web/src/tests/setup'; + +import { performance } from 'node:perf_hooks'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { initSync as initRaptorQSync } from '@raptorqr/raptorq-wasm'; +import { DEFAULT_RAPTORQ_REPAIR_PERCENT } from '@raptorqr/core/fec/codec'; +import { RaptorQWasmDecoder } from '@raptorqr/core/fec/raptorq_wasm'; +import { + ECC_LEVEL, + MAX_PAYLOAD_SIZE, + QR_VERSION, + RAPTORQ_SYMBOL_INDEX, +} from '@raptorqr/core/protocol/constants'; +import { packetCodec, parsePacket } from '@raptorqr/core/protocol/packet'; +import type { Packet as ParsedPacket } from '@raptorqr/core/protocol/packet'; +import { decodeQRFromCanvas } from '@raptorqr/core/qr/qr_decode'; +import { renderQRCodeImageData } from '@raptorqr/core/qr/qr_encoder_browser'; +import { packetizeRaptorQ } from '@raptorqr/core/sender/raptorq_packetizer'; + +interface BenchConfig { + payloadBytes: number; + iterations: number; + scale: number; + maxAvgTotalMs: number; + maxQrDecodeFrameMs: number; + maxRenderFrameMs: number; + minKbPerSecond: number; +} + +interface BenchRun { + iteration: number; + payloadBytes: number; + frames: number; + packetizeMs: number; + qrRenderMs: number; + qrDecodeMs: number; + raptorqDecodeMs: number; + totalMs: number; + qrRenderFrameMs: number; + qrDecodeFrameMs: number; + kbPerSecond: number; +} + +const BENCH_TIMEOUT_MS = 120_000; +const textEncoder = new TextEncoder(); + +function envNumber(name: string, fallback: number, options: { integer?: boolean; min?: number } = {}): number { + const raw = process.env[name]; + if (!raw) return fallback; + + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + throw new Error(`${name} must be a finite number, got ${raw}`); + } + + const rounded = options.integer ? Math.round(parsed) : parsed; + if (options.min !== undefined && rounded < options.min) { + throw new Error(`${name} must be >= ${options.min}, got ${raw}`); + } + + return rounded; +} + +function benchConfig(): BenchConfig { + return { + payloadBytes: envNumber('RAPTORQR_BENCH_PAYLOAD_KB', 4, { integer: true, min: 1 }) * 1024, + iterations: envNumber('RAPTORQR_BENCH_ITERATIONS', 2, { integer: true, min: 1 }), + scale: envNumber('RAPTORQR_BENCH_SCALE', 3, { integer: true, min: 2 }), + maxAvgTotalMs: envNumber('RAPTORQR_BENCH_MAX_AVG_TOTAL_MS', 60_000, { min: 1 }), + maxQrDecodeFrameMs: envNumber('RAPTORQR_BENCH_MAX_QR_DECODE_FRAME_MS', 2_000, { min: 1 }), + maxRenderFrameMs: envNumber('RAPTORQR_BENCH_MAX_RENDER_FRAME_MS', 1_000, { min: 1 }), + minKbPerSecond: envNumber('RAPTORQR_BENCH_MIN_KBPS', 0.05, { min: 0 }), + }; +} + +function packageRoot(packageName: string): string { + const linkPath = join('node_modules', '@raptorqr', packageName); + expect(existsSync(linkPath)).toBe(true); + return realpathSync(linkPath); +} + +function raptorqWasmPath(): string { + return join( + packageRoot('raptorq-wasm'), + 'src', + 'wasm', + 'raptorqr_raptorq_wasm_bg.wasm', + ); +} + +function deterministicPayload(byteLength: number): Uint8Array { + const out = new Uint8Array(byteLength); + let state = 0x6d2b79f5; + + for (let i = 0; i < out.length; i++) { + state = Math.imul(state ^ (state >>> 15), 1 | state); + state ^= state + Math.imul(state ^ (state >>> 7), 61 | state); + out[i] = (state ^ (state >>> 14) ^ i) & 0xff; + } + + return out; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, index) => byte === b[index]); +} + +async function timed(work: () => Promise): Promise<[T, number]> { + const startedAt = performance.now(); + const value = await work(); + return [value, performance.now() - startedAt]; +} + +async function renderFrames(packets: Uint8Array[], scale: number): Promise { + const images: ImageData[] = []; + + for (const packet of packets) { + images.push( + await renderQRCodeImageData( + packet, + QR_VERSION, + ECC_LEVEL, + scale, + 'fast-qr-wasm', + ), + ); + } + + return images; +} + +async function decodeFrames(images: ImageData[]): Promise { + const packets: ParsedPacket[] = []; + + for (let i = 0; i < images.length; i++) { + const decoded = await decodeQRFromCanvas(images[i]!); + expect(decoded, `QR frame ${i} did not decode`).not.toBeNull(); + expect(decoded!.version).toBe(QR_VERSION); + + const parsed = parsePacket(decoded!.bytes); + expect(packetCodec(parsed.header), `QR frame ${i} codec`).toBe('wasm-raptorq'); + expect(parsed.header.symbolIndex, `QR frame ${i} symbol index`).toBe(RAPTORQ_SYMBOL_INDEX); + packets.push(parsed); + } + + return packets; +} + +async function decodePayload(packets: ParsedPacket[], expectedBytes: number): Promise { + expect(packets.length).toBeGreaterThan(0); + + const decoder = await RaptorQWasmDecoder.create( + packets[0]!.header.dataLength, + packets[0]!.payload.length, + ); + + let decoded: Uint8Array | null = null; + for (const packet of packets) { + decoded = decoder.push(packet.payload); + if (decoded) break; + } + + expect(decoded).not.toBeNull(); + return decoded!.slice(0, expectedBytes); +} + +async function runFullChainOnce(iteration: number, payload: Uint8Array, config: BenchConfig): Promise { + const totalStartedAt = performance.now(); + + const [packetized, packetizeMs] = await timed(() => packetizeRaptorQ( + payload, + false, + false, + undefined, + undefined, + { + maxTransportPayloadSize: MAX_PAYLOAD_SIZE, + repairPercent: DEFAULT_RAPTORQ_REPAIR_PERCENT, + }, + )); + const parsedOriginalPackets = packetized.packets.map((packet) => parsePacket(packet)); + + expect(packetized.dataLength).toBe(payload.length); + expect(packetized.symbolSize).toBe(MAX_PAYLOAD_SIZE); + expect(packetized.packets.length).toBeGreaterThan(0); + expect(parsedOriginalPackets.every((packet) => packetCodec(packet.header) === 'wasm-raptorq')).toBe(true); + expect(parsedOriginalPackets.every((packet) => packet.header.symbolIndex === RAPTORQ_SYMBOL_INDEX)).toBe(true); + + const [images, qrRenderMs] = await timed(() => renderFrames(packetized.packets, config.scale)); + const [decodedPackets, qrDecodeMs] = await timed(() => decodeFrames(images)); + + expect(decodedPackets).toHaveLength(parsedOriginalPackets.length); + decodedPackets.forEach((decoded, index) => { + const original = parsedOriginalPackets[index]!; + expect(decoded.header).toEqual(original.header); + expect(decoded.payload).toEqual(original.payload); + }); + + const [decodedPayload, raptorqDecodeMs] = await timed(() => decodePayload(decodedPackets, payload.length)); + expect(bytesEqual(payload, decodedPayload)).toBe(true); + + const totalMs = performance.now() - totalStartedAt; + const frames = packetized.packets.length; + const kbPerSecond = (payload.length / 1024) / (totalMs / 1000); + + return { + iteration, + payloadBytes: payload.length, + frames, + packetizeMs, + qrRenderMs, + qrDecodeMs, + raptorqDecodeMs, + totalMs, + qrRenderFrameMs: qrRenderMs / frames, + qrDecodeFrameMs: qrDecodeMs / frames, + kbPerSecond, + }; +} + +function summarize(runs: BenchRun[]): BenchRun { + const totals = runs.reduce( + (acc, run) => ({ + iteration: 0, + payloadBytes: run.payloadBytes, + frames: acc.frames + run.frames, + packetizeMs: acc.packetizeMs + run.packetizeMs, + qrRenderMs: acc.qrRenderMs + run.qrRenderMs, + qrDecodeMs: acc.qrDecodeMs + run.qrDecodeMs, + raptorqDecodeMs: acc.raptorqDecodeMs + run.raptorqDecodeMs, + totalMs: acc.totalMs + run.totalMs, + qrRenderFrameMs: acc.qrRenderFrameMs + run.qrRenderFrameMs, + qrDecodeFrameMs: acc.qrDecodeFrameMs + run.qrDecodeFrameMs, + kbPerSecond: acc.kbPerSecond + run.kbPerSecond, + }), + { + iteration: 0, + payloadBytes: runs[0]?.payloadBytes ?? 0, + frames: 0, + packetizeMs: 0, + qrRenderMs: 0, + qrDecodeMs: 0, + raptorqDecodeMs: 0, + totalMs: 0, + qrRenderFrameMs: 0, + qrDecodeFrameMs: 0, + kbPerSecond: 0, + }, + ); + const divisor = Math.max(1, runs.length); + + return { + iteration: 0, + payloadBytes: totals.payloadBytes, + frames: totals.frames / divisor, + packetizeMs: totals.packetizeMs / divisor, + qrRenderMs: totals.qrRenderMs / divisor, + qrDecodeMs: totals.qrDecodeMs / divisor, + raptorqDecodeMs: totals.raptorqDecodeMs / divisor, + totalMs: totals.totalMs / divisor, + qrRenderFrameMs: totals.qrRenderFrameMs / divisor, + qrDecodeFrameMs: totals.qrDecodeFrameMs / divisor, + kbPerSecond: totals.kbPerSecond / divisor, + }; +} + +function printableRun(run: BenchRun): Record { + return { + iteration: run.iteration || 'avg', + payloadKB: (run.payloadBytes / 1024).toFixed(1), + frames: run.frames.toFixed(1), + packetizeMs: run.packetizeMs.toFixed(1), + qrRenderMs: run.qrRenderMs.toFixed(1), + qrDecodeMs: run.qrDecodeMs.toFixed(1), + raptorqDecodeMs: run.raptorqDecodeMs.toFixed(1), + totalMs: run.totalMs.toFixed(1), + renderFrameMs: run.qrRenderFrameMs.toFixed(2), + qrDecodeFrameMs: run.qrDecodeFrameMs.toFixed(2), + kbPerSecond: run.kbPerSecond.toFixed(2), + }; +} + +describe('full-chain benchmark', () => { + test('packetize -> QR render -> QR decode -> RaptorQ decode', async () => { + const config = benchConfig(); + const payload = deterministicPayload(config.payloadBytes); + + console.info('[bench] config', config); + + // Initialize the split RaptorQ WASM package from its installed package path. + // This makes the core packetizer use the real RaptorQ path in Node tests. + initRaptorQSync({ module: readFileSync(raptorqWasmPath()) }); + + // Warm up lazy WASM/module initializers so the measured iterations are less + // dominated by one-time compilation and fetch setup. + const warmupPayload = deterministicPayload(Math.min(1024, config.payloadBytes)); + const [, warmupMs] = await timed(() => runFullChainOnce( + 0, + warmupPayload, + { ...config, payloadBytes: warmupPayload.length }, + )); + console.info(`[bench] warmup completed in ${warmupMs.toFixed(1)}ms`); + + const runs: BenchRun[] = []; + for (let i = 0; i < config.iterations; i++) { + const run = await runFullChainOnce(i + 1, payload, config); + runs.push(run); + console.info('[bench] iteration', printableRun(run)); + } + + const average = summarize(runs); + console.table([...runs, average].map(printableRun)); + + expect(average.totalMs).toBeLessThanOrEqual(config.maxAvgTotalMs); + expect(average.qrDecodeFrameMs).toBeLessThanOrEqual(config.maxQrDecodeFrameMs); + expect(average.qrRenderFrameMs).toBeLessThanOrEqual(config.maxRenderFrameMs); + expect(average.kbPerSecond).toBeGreaterThanOrEqual(config.minKbPerSecond); + }, BENCH_TIMEOUT_MS); +}); diff --git a/package.json b/package.json index 319729a..7927cb1 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,13 @@ }, "scripts": { "dev:web": "pnpm --filter @raptorqr/web dev", - "build": "pnpm --filter @raptorqr/wasm build && pnpm --filter @raptorqr/core build && pnpm --filter @raptorqr/cli build && pnpm --filter @raptorqr/web build", - "test": "pnpm --filter @raptorqr/wasm test && pnpm --filter @raptorqr/core test && pnpm --filter @raptorqr/cli test && pnpm --filter @raptorqr/web test", + "build": "pnpm --filter @raptorqr/fast-qr-wasm build && pnpm --filter @raptorqr/raptorq-wasm build && pnpm --filter @raptorqr/core build && pnpm --filter @raptorqr/cli build && pnpm --filter @raptorqr/web build", + "test": "pnpm --filter @raptorqr/fast-qr-wasm test && pnpm --filter @raptorqr/raptorq-wasm test && pnpm --filter @raptorqr/core test && pnpm --filter @raptorqr/cli test && pnpm --filter @raptorqr/web test", + "test:smoke": "vitest run smoke.test.ts --environment node", + "benchmark": "vitest run benchmark.test.ts --environment happy-dom", + "benchmark:final": "vitest run benchmark.final.test.ts --environment happy-dom", "test:watch": "pnpm --filter @raptorqr/web test:watch", - "verify:raptorq-wasm": "pnpm --filter @raptorqr/wasm verify:raptorq" + "verify:raptorq-wasm": "pnpm --filter @raptorqr/raptorq-wasm verify:raptorq" }, "keywords": [ "qr", @@ -29,7 +32,10 @@ "license": "MIT", "devDependencies": { "@preact/preset-vite": "2", - "@types/bun": "latest", + "@raptorqr/core": "workspace:*", + "@raptorqr/fast-qr-wasm": "workspace:*", + "@raptorqr/raptorq-wasm": "workspace:*", + "@types/node": "26.1.0", "esbuild": "^0.28.1", "happy-dom": "^20.10.6", "typescript": "5.7.3", diff --git a/packages/raptorqr-cli/README.md b/packages/raptorqr-cli/README.md index 1a44970..1160dea 100644 --- a/packages/raptorqr-cli/README.md +++ b/packages/raptorqr-cli/README.md @@ -37,8 +37,8 @@ dist/raptorqr.js The build also copies required WASM sidecars into `dist/`: ```text -qrstream_fast_qr_wasm_bg.wasm -qrstream_raptorq_wasm_bg.wasm +raptorqr_fast_qr_wasm_bg.wasm +raptorqr_raptorq_wasm_bg.wasm ``` Run the repository build before publishing: diff --git a/packages/raptorqr-cli/package.json b/packages/raptorqr-cli/package.json index eeeb366..aceba5a 100644 --- a/packages/raptorqr-cli/package.json +++ b/packages/raptorqr-cli/package.json @@ -1,11 +1,15 @@ { "name": "@raptorqr/cli", - "version": "0.1.0", + "version": "0.1.1", "description": "RaptorQR command line interface", + "license": "MIT", "type": "module", "bin": { "raptorqr": "dist/raptorqr.js" }, + "publishConfig": { + "access": "public" + }, "files": [ "dist/", "README.md", @@ -13,12 +17,13 @@ ], "dependencies": { "@raptorqr/core": "workspace:*", - "@raptorqr/wasm": "workspace:*" + "@raptorqr/fast-qr-wasm": "workspace:*", + "@raptorqr/raptorq-wasm": "workspace:*" }, "scripts": { "build": "esbuild src/raptorqr.ts --bundle --platform=node --target=node18 --outfile=dist/raptorqr.js --format=esm --external:node:* && node scripts/copy-wasm-assets.mjs", "test": "vitest run", "test:watch": "vitest", - "cli": "bun run src/raptorqr.ts" + "cli": "pnpm run build && node dist/raptorqr.js" } } diff --git a/packages/raptorqr-cli/scripts/copy-wasm-assets.mjs b/packages/raptorqr-cli/scripts/copy-wasm-assets.mjs index f281079..c79f8ef 100644 --- a/packages/raptorqr-cli/scripts/copy-wasm-assets.mjs +++ b/packages/raptorqr-cli/scripts/copy-wasm-assets.mjs @@ -1,4 +1,4 @@ -import { copyFileSync, mkdirSync } from 'node:fs'; +import { copyFileSync, mkdirSync, readdirSync, unlinkSync } from 'node:fs'; import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,17 +9,24 @@ const dist = resolve(packageRoot, 'dist'); const assets = [ { - source: resolve(repoRoot, 'packages', 'raptorqr-wasm', 'src', 'fast_qr', 'wasm', 'qrstream_fast_qr_wasm_bg.wasm'), - target: resolve(dist, 'qrstream_fast_qr_wasm_bg.wasm'), + source: resolve(repoRoot, 'packages', 'raptorqr-fast-qr-wasm', 'src', 'wasm', 'raptorqr_fast_qr_wasm_bg.wasm'), + target: resolve(dist, 'raptorqr_fast_qr_wasm_bg.wasm'), }, { - source: resolve(repoRoot, 'packages', 'raptorqr-wasm', 'src', 'raptorq', 'wasm', 'qrstream_raptorq_wasm_bg.wasm'), - target: resolve(dist, 'qrstream_raptorq_wasm_bg.wasm'), + source: resolve(repoRoot, 'packages', 'raptorqr-raptorq-wasm', 'src', 'wasm', 'raptorqr_raptorq_wasm_bg.wasm'), + target: resolve(dist, 'raptorqr_raptorq_wasm_bg.wasm'), }, ]; mkdirSync(dist, { recursive: true }); +const targetNames = new Set(assets.map((asset) => basename(asset.target))); +for (const entry of readdirSync(dist)) { + if (entry.endsWith('_wasm_bg.wasm') && !targetNames.has(entry)) { + unlinkSync(resolve(dist, entry)); + } +} + for (const asset of assets) { copyFileSync(asset.source, asset.target); console.log(`copied ${basename(asset.target)}`); diff --git a/packages/raptorqr-core/package.json b/packages/raptorqr-core/package.json index 34766c9..7936f5b 100644 --- a/packages/raptorqr-core/package.json +++ b/packages/raptorqr-core/package.json @@ -1,7 +1,8 @@ { "name": "@raptorqr/core", - "version": "0.1.0", + "version": "0.1.1", "description": "Protocol, packetization, FEC, QR encode/decode APIs for RaptorQR", + "license": "MIT", "type": "module", "exports": { ".": { @@ -49,6 +50,9 @@ "import": "./src/wasm/*.ts" } }, + "publishConfig": { + "access": "public" + }, "files": [ "src/browser.ts", "src/node.ts", @@ -66,7 +70,8 @@ "LICENSE" ], "dependencies": { - "@raptorqr/wasm": "workspace:*", + "@raptorqr/fast-qr-wasm": "workspace:*", + "@raptorqr/raptorq-wasm": "workspace:*", "fflate": "^0.8.2", "gifenc": "^1.0.3", "zxing-wasm": "^3.1.0" diff --git a/packages/raptorqr-core/src/fec/raptorq_wasm.ts b/packages/raptorqr-core/src/fec/raptorq_wasm.ts index b6b767a..f0c38f9 100644 --- a/packages/raptorqr-core/src/fec/raptorq_wasm.ts +++ b/packages/raptorqr-core/src/fec/raptorq_wasm.ts @@ -1,12 +1,12 @@ import init, { RaptorQDecoder, encode_packets, -} from '@raptorqr/wasm/raptorq'; +} from '@raptorqr/raptorq-wasm'; let initPromise: Promise | null = null; export function raptorQUnavailableMessage(): string { - return 'RaptorQ WASM artifacts are not installed. Run packages/raptorqr-wasm/src/raptorq/build_raptorq_wasm_colab.py in Google Colab, then copy the generated files into packages/raptorqr-wasm/src/raptorq/wasm.'; + return 'RaptorQ WASM artifacts are not installed. Run packages/raptorqr-raptorq-wasm/src/build_raptorq_wasm_colab.py in Google Colab, then copy the generated files into packages/raptorqr-raptorq-wasm/src/wasm.'; } export async function ensureRaptorQWasm(): Promise { diff --git a/packages/raptorqr-core/src/qr/fast_qr_wasm.ts b/packages/raptorqr-core/src/qr/fast_qr_wasm.ts index a1b344f..23e37f0 100644 --- a/packages/raptorqr-core/src/qr/fast_qr_wasm.ts +++ b/packages/raptorqr-core/src/qr/fast_qr_wasm.ts @@ -12,8 +12,8 @@ * @module */ -import init, { QrRenderer } from '@raptorqr/wasm/fast-qr'; -import type { InitOutput } from '@raptorqr/wasm/fast-qr'; +import init, { QrRenderer } from '@raptorqr/fast-qr-wasm'; +import type { InitOutput } from '@raptorqr/fast-qr-wasm'; // ─── Module-level singleton ─────────────────────────────────────────────────── @@ -25,8 +25,8 @@ let wasmOutput: InitOutput | null = null; export function fastQrUnavailableMessage(): string { return ( 'fast_qr WASM artifacts are not installed. ' + - 'Run packages/raptorqr-wasm/src/fast_qr/build_fast_qr_wasm_colab.py in Google Colab, ' + - 'then copy the generated files into packages/raptorqr-wasm/src/fast_qr/wasm.' + 'Run packages/raptorqr-fast-qr-wasm/src/build_fast_qr_wasm_colab.py in Google Colab, ' + + 'then copy the generated files into packages/raptorqr-fast-qr-wasm/src/wasm.' ); } diff --git a/packages/raptorqr-core/src/qr/qr_encoder_node.ts b/packages/raptorqr-core/src/qr/qr_encoder_node.ts index 0776076..f468663 100644 --- a/packages/raptorqr-core/src/qr/qr_encoder_node.ts +++ b/packages/raptorqr-core/src/qr/qr_encoder_node.ts @@ -10,7 +10,7 @@ import { QrRenderer, initSync, type InitOutput, -} from '@raptorqr/wasm/fast-qr'; +} from '@raptorqr/fast-qr-wasm'; import { DEFAULT_QR_ENCODER, formatQREncoder, @@ -24,9 +24,9 @@ export * from './qr_encoder'; export const DEFAULT_CLI_QR_ENCODER: QREncoder = DEFAULT_QR_ENCODER; const FAST_QR_WASM_ASSET = { - distFileName: 'qrstream_fast_qr_wasm_bg.wasm', - sourceRelativePath: 'packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm_bg.wasm', - packageExport: '@raptorqr/wasm/fast-qr/wasm/qrstream_fast_qr_wasm_bg.wasm', + distFileName: 'raptorqr_fast_qr_wasm_bg.wasm', + sourceRelativePath: 'packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm_bg.wasm', + packageExport: '@raptorqr/fast-qr-wasm/wasm/raptorqr_fast_qr_wasm_bg.wasm', envVar: 'RAPTORQR_FAST_QR_WASM', }; diff --git a/packages/raptorqr-core/src/wasm/node_assets.ts b/packages/raptorqr-core/src/wasm/node_assets.ts index 60339cc..29b165f 100644 --- a/packages/raptorqr-core/src/wasm/node_assets.ts +++ b/packages/raptorqr-core/src/wasm/node_assets.ts @@ -17,7 +17,7 @@ export interface NodeWasmAsset { distFileName: string; /** Repo/package-relative source path kept for dev and npm source fallback. */ sourceRelativePath: string; - /** Optional package export path, e.g. `@raptorqr/wasm/fast-qr/wasm/file.wasm`. */ + /** Optional package export path, e.g. `@raptorqr/fast-qr-wasm/wasm/file.wasm`. */ packageExport?: string; /** Optional absolute path override for local debugging. */ envVar?: string; diff --git a/packages/raptorqr-wasm/LICENSE b/packages/raptorqr-fast-qr-wasm/LICENSE similarity index 100% rename from packages/raptorqr-wasm/LICENSE rename to packages/raptorqr-fast-qr-wasm/LICENSE diff --git a/packages/raptorqr-fast-qr-wasm/README.md b/packages/raptorqr-fast-qr-wasm/README.md new file mode 100644 index 0000000..4358220 --- /dev/null +++ b/packages/raptorqr-fast-qr-wasm/README.md @@ -0,0 +1,109 @@ +# @raptorqr/fast-qr-wasm + +Generated [`fast_qr`](https://github.com/erwanvivien/fast_qr) wasm-bindgen artifacts used by RaptorQR for QR rendering. + +Most application code should use `@raptorqr/core` instead of importing this package directly. This package exposes the raw renderer module for low-level integration, tests, and core wrappers. + +## Exports + +```ts +import init, { + initSync, + QrRenderer, + type InitOutput, +} from '@raptorqr/fast-qr-wasm'; +``` + +```text +default init(module_or_path?) -> Promise +initSync(module) -> InitOutput +QrRenderer +InitOutput +``` + +`QrRenderer` methods: + +```text +new QrRenderer() +render(data, version, ecc, scale) -> sidePx +render_rgba(data, version, ecc, scale) -> sidePx +render_matrix(data, version, ecc) -> sideModules +rgba_ptr() / rgba_len() +buf_ptr() / buf_len() +matrix_ptr() / matrix_len() +last_matrix_size() +free() +``` + +ECC numeric mapping: + +```text +0 = L +1 = M +2 = Q +3 = H +``` + +## Render RGBA + +```ts +import init, { QrRenderer } from '@raptorqr/fast-qr-wasm'; + +const wasm = await init(); +const renderer = new QrRenderer(); + +const sidePx = renderer.render_rgba(packetBytes, 10, 1, 4); +const byteLength = sidePx * sidePx * 4; + +const rgba = new Uint8ClampedArray( + wasm.memory.buffer, + renderer.rgba_ptr(), + byteLength, +); + +const copy = new Uint8ClampedArray(rgba); +``` + +Copy the view before another render call if you need to keep the pixels. + +## Render Matrix + +```ts +const sideModules = renderer.render_matrix(packetBytes, 10, 1); +const modules = new Uint8Array( + wasm.memory.buffer, + renderer.matrix_ptr(), + sideModules * sideModules, +); +``` + +`modules[row * sideModules + col]` is `0` for light and `1` for dark. + +## WASM Asset Subpath + +The generated files are exported for bundlers and Node loaders: + +```text +@raptorqr/fast-qr-wasm/wasm/* +``` + +The binary sidecar is: + +```text +src/wasm/raptorqr_fast_qr_wasm_bg.wasm +``` + +## Regeneration + +The Colab build script is: + +```text +src/build_fast_qr_wasm_colab.py +``` + +After regenerating artifacts, run: + +```bash +pnpm --filter @raptorqr/fast-qr-wasm test +pnpm --filter @raptorqr/core test +``` diff --git a/packages/raptorqr-fast-qr-wasm/package.json b/packages/raptorqr-fast-qr-wasm/package.json new file mode 100644 index 0000000..dc84107 --- /dev/null +++ b/packages/raptorqr-fast-qr-wasm/package.json @@ -0,0 +1,26 @@ +{ + "name": "@raptorqr/fast-qr-wasm", + "version": "0.1.1", + "description": "Generated fast_qr WASM renderer artifacts for RaptorQR", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.js" + }, + "./wasm/*": "./src/wasm/*" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src/", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -p tsconfig.json --noEmit", + "test": "pnpm run build" + } +} diff --git a/packages/raptorqr-wasm/src/fast_qr/build_fast_qr_wasm_colab.py b/packages/raptorqr-fast-qr-wasm/src/build_fast_qr_wasm_colab.py similarity index 97% rename from packages/raptorqr-wasm/src/fast_qr/build_fast_qr_wasm_colab.py rename to packages/raptorqr-fast-qr-wasm/src/build_fast_qr_wasm_colab.py index 36cfd19..c521843 100644 --- a/packages/raptorqr-wasm/src/fast_qr/build_fast_qr_wasm_colab.py +++ b/packages/raptorqr-fast-qr-wasm/src/build_fast_qr_wasm_colab.py @@ -3,8 +3,8 @@ This script is intended for Google Colab so the main development machine does not need a Rust toolchain. It can be pasted directly into a Colab cell. If a -RaptorQR repo is present, artifacts are copied into packages/raptorqr-wasm/src/fast_qr/wasm; -otherwise they are written to /content/qrstream_fast_qr_wasm_artifacts and zipped. +RaptorQR repo is present, artifacts are copied into packages/raptorqr-fast-qr-wasm/src/wasm; +otherwise they are written to /content/raptorqr_fast_qr_wasm_artifacts and zipped. The crate exposes a single `QrRenderer` struct whose internal RGBA and matrix buffers are allocated exactly once at construction time. Callers invoke @@ -25,8 +25,8 @@ from pathlib import Path FAST_QR_VERSION = "0.13" -BUILD_DIR = Path("/content/qrstream_fast_qr_wasm_build") -PACKAGE_NAME = "qrstream_fast_qr_wasm" +BUILD_DIR = Path("/content/raptorqr_fast_qr_wasm_build") +PACKAGE_NAME = "raptorqr_fast_qr_wasm" # --------------------------------------------------------------------------- # Cargo.toml @@ -354,15 +354,15 @@ def find_repo_root() -> Path | None: candidates.append(Path(env_repo).expanduser()) for candidate in candidates: - if candidate and (candidate / "packages" / "raptorqr-wasm" / "src" / "fast_qr").exists() and (candidate / "pnpm-workspace.yaml").exists(): + if candidate and (candidate / "packages" / "raptorqr-fast-qr-wasm" / "src").exists() and (candidate / "pnpm-workspace.yaml").exists(): return candidate.resolve() return None def default_output_dir(repo_root: Path | None) -> Path: if repo_root is not None: - return repo_root / "packages" / "raptorqr-wasm" / "src" / "fast_qr" / "wasm" - return Path("/content/qrstream_fast_qr_wasm_artifacts") + return repo_root / "packages" / "raptorqr-fast-qr-wasm" / "src" / "wasm" + return Path("/content/raptorqr_fast_qr_wasm_artifacts") def ensure_rust_toolchain() -> None: diff --git a/packages/raptorqr-fast-qr-wasm/src/index.d.ts b/packages/raptorqr-fast-qr-wasm/src/index.d.ts new file mode 100644 index 0000000..590c29b --- /dev/null +++ b/packages/raptorqr-fast-qr-wasm/src/index.d.ts @@ -0,0 +1,6 @@ +export { + default, + initSync, + QrRenderer, +} from './wasm/raptorqr_fast_qr_wasm.js'; +export type { InitOutput } from './wasm/raptorqr_fast_qr_wasm.js'; diff --git a/packages/raptorqr-fast-qr-wasm/src/index.js b/packages/raptorqr-fast-qr-wasm/src/index.js new file mode 100644 index 0000000..845e421 --- /dev/null +++ b/packages/raptorqr-fast-qr-wasm/src/index.js @@ -0,0 +1,5 @@ +export { + default, + initSync, + QrRenderer, +} from './wasm/raptorqr_fast_qr_wasm.js'; diff --git a/packages/raptorqr-fast-qr-wasm/src/wasm/manifest.json b/packages/raptorqr-fast-qr-wasm/src/wasm/manifest.json new file mode 100644 index 0000000..3a5fb02 --- /dev/null +++ b/packages/raptorqr-fast-qr-wasm/src/wasm/manifest.json @@ -0,0 +1,13 @@ +{ + "builtAt": "2026-07-08T01:50:04.220654+00:00", + "fast_qr": "0.13", + "outputs": [ + "package.json", + "raptorqr_fast_qr_wasm.d.ts", + "raptorqr_fast_qr_wasm.js", + "raptorqr_fast_qr_wasm_bg.wasm", + "raptorqr_fast_qr_wasm_bg.wasm.d.ts" + ], + "package": "raptorqr_fast_qr_wasm", + "target": "web" +} diff --git a/packages/raptorqr-fast-qr-wasm/src/wasm/package.json b/packages/raptorqr-fast-qr-wasm/src/wasm/package.json new file mode 100644 index 0000000..e5c4c47 --- /dev/null +++ b/packages/raptorqr-fast-qr-wasm/src/wasm/package.json @@ -0,0 +1,15 @@ +{ + "name": "raptorqr_fast_qr_wasm", + "type": "module", + "version": "0.1.0", + "files": [ + "raptorqr_fast_qr_wasm_bg.wasm", + "raptorqr_fast_qr_wasm.js", + "raptorqr_fast_qr_wasm.d.ts" + ], + "main": "raptorqr_fast_qr_wasm.js", + "types": "raptorqr_fast_qr_wasm.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} diff --git a/packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm.d.ts b/packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm.d.ts similarity index 100% rename from packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm.d.ts rename to packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm.d.ts diff --git a/packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm.js b/packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm.js similarity index 98% rename from packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm.js rename to packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm.js index 4d3b0b0..425fc57 100644 --- a/packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm.js +++ b/packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm.js @@ -1,4 +1,4 @@ -/* @ts-self-types="./qrstream_fast_qr_wasm.d.ts" */ +/* @ts-self-types="./raptorqr_fast_qr_wasm.d.ts" */ export class QrRenderer { __destroy_into_raw() { @@ -187,7 +187,7 @@ function __wbg_get_imports() { }; return { __proto__: null, - "./qrstream_fast_qr_wasm_bg.js": import0, + "./raptorqr_fast_qr_wasm_bg.js": import0, }; } @@ -344,7 +344,7 @@ async function __wbg_init(module_or_path) { } if (module_or_path === undefined) { - module_or_path = new URL('qrstream_fast_qr_wasm_bg.wasm', import.meta.url); + module_or_path = new URL('raptorqr_fast_qr_wasm_bg.wasm', import.meta.url); } const imports = __wbg_get_imports(); diff --git a/packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm_bg.wasm b/packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm_bg.wasm similarity index 99% rename from packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm_bg.wasm rename to packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm_bg.wasm index 5e5674a..89eb9b1 100644 Binary files a/packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm_bg.wasm and b/packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm_bg.wasm differ diff --git a/packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm_bg.wasm.d.ts b/packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm_bg.wasm.d.ts similarity index 100% rename from packages/raptorqr-wasm/src/fast_qr/wasm/qrstream_fast_qr_wasm_bg.wasm.d.ts rename to packages/raptorqr-fast-qr-wasm/src/wasm/raptorqr_fast_qr_wasm_bg.wasm.d.ts diff --git a/packages/raptorqr-fast-qr-wasm/tsconfig.json b/packages/raptorqr-fast-qr-wasm/tsconfig.json new file mode 100644 index 0000000..deaf658 --- /dev/null +++ b/packages/raptorqr-fast-qr-wasm/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.d.ts"] +} diff --git a/packages/raptorqr-raptorq-wasm/LICENSE b/packages/raptorqr-raptorq-wasm/LICENSE new file mode 100644 index 0000000..5ec1d6c --- /dev/null +++ b/packages/raptorqr-raptorq-wasm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Haixiang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/raptorqr-raptorq-wasm/README.md b/packages/raptorqr-raptorq-wasm/README.md new file mode 100644 index 0000000..f3c723b --- /dev/null +++ b/packages/raptorqr-raptorq-wasm/README.md @@ -0,0 +1,98 @@ +# @raptorqr/raptorq-wasm + +Generated [`cberner/raptorq`](https://github.com/cberner/raptorq) wasm-bindgen artifacts used by RaptorQR for RaptorQ encoding and decoding. + +Most application code should use `@raptorqr/core` instead of importing this package directly. This package exposes the raw codec module for low-level integration, tests, and core wrappers. + +## Exports + +```ts +import init, { + initSync, + encode_packets, + RaptorQDecoder, +} from '@raptorqr/raptorq-wasm'; +``` + +```text +default init(module_or_path?) -> Promise +initSync(module) -> InitOutput +encode_packets(data, max_transport_payload_size, repair_percent) -> Array +RaptorQDecoder +``` + +`RaptorQDecoder` methods: + +```text +new RaptorQDecoder(data_len, max_transport_payload_size) +push(serialized_packet) -> Uint8Array | null +free() +``` + +## Encode And Decode + +```ts +import init, { + encode_packets, + RaptorQDecoder, +} from '@raptorqr/raptorq-wasm'; + +await init(); + +const data = new TextEncoder().encode('hello'); +const maxTransportPayloadSize = 201; +const repairPercent = 10; + +const packets = Array.from( + encode_packets(data, maxTransportPayloadSize, repairPercent), + (packet) => new Uint8Array(packet), +); + +const decoder = new RaptorQDecoder(data.length, maxTransportPayloadSize); + +let decoded: Uint8Array | null = null; +for (const packet of packets) { + const result = decoder.push(packet); + if (result) { + decoded = new Uint8Array(result); + break; + } +} +``` + +Direct RaptorQ packets are codec payloads only. They do not include the RaptorQR 8-byte transport header, CRC32C trailer, compression metadata, filename metadata, or QR scheduling semantics. Use `@raptorqr/core/sender/raptorq_packetizer` when you need complete RaptorQR transfer packets. + +## WASM Asset Subpath + +The generated files are exported for bundlers and Node loaders: + +```text +@raptorqr/raptorq-wasm/wasm/* +``` + +The binary sidecar is: + +```text +src/wasm/raptorqr_raptorq_wasm_bg.wasm +``` + +## Verification + +```bash +pnpm --filter @raptorqr/raptorq-wasm test +``` + +## Regeneration + +The Colab build script is: + +```text +src/build_raptorq_wasm_colab.py +``` + +After regenerating artifacts, run: + +```bash +pnpm --filter @raptorqr/raptorq-wasm test +pnpm --filter @raptorqr/core test +``` diff --git a/packages/raptorqr-raptorq-wasm/package.json b/packages/raptorqr-raptorq-wasm/package.json new file mode 100644 index 0000000..6af71c1 --- /dev/null +++ b/packages/raptorqr-raptorq-wasm/package.json @@ -0,0 +1,28 @@ +{ + "name": "@raptorqr/raptorq-wasm", + "version": "0.1.1", + "description": "Generated RaptorQ WASM codec artifacts for RaptorQR", + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.js" + }, + "./wasm/*": "./src/wasm/*" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src/", + "scripts/", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -p tsconfig.json --noEmit", + "test": "pnpm run verify:raptorq", + "verify:raptorq": "node scripts/verify-raptorq-wasm.mjs" + } +} diff --git a/packages/raptorqr-wasm/scripts/verify-raptorq-wasm.mjs b/packages/raptorqr-raptorq-wasm/scripts/verify-raptorq-wasm.mjs similarity index 97% rename from packages/raptorqr-wasm/scripts/verify-raptorq-wasm.mjs rename to packages/raptorqr-raptorq-wasm/scripts/verify-raptorq-wasm.mjs index 501b37d..a8c97d8 100644 --- a/packages/raptorqr-wasm/scripts/verify-raptorq-wasm.mjs +++ b/packages/raptorqr-raptorq-wasm/scripts/verify-raptorq-wasm.mjs @@ -7,13 +7,13 @@ import init, { RaptorQDecoder, encode_packets, initSync, -} from '../src/raptorq/wasm/qrstream_raptorq_wasm.js'; +} from '../src/wasm/raptorqr_raptorq_wasm.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); const wasmPath = resolve( repoRoot, - 'src/raptorq/wasm/qrstream_raptorq_wasm_bg.wasm', + 'src/wasm/raptorqr_raptorq_wasm_bg.wasm', ); const transportPayloadSize = 256; diff --git a/packages/raptorqr-wasm/src/raptorq/build_raptorq_wasm_colab.py b/packages/raptorqr-raptorq-wasm/src/build_raptorq_wasm_colab.py similarity index 94% rename from packages/raptorqr-wasm/src/raptorq/build_raptorq_wasm_colab.py rename to packages/raptorqr-raptorq-wasm/src/build_raptorq_wasm_colab.py index 3472be5..d9a20ad 100644 --- a/packages/raptorqr-wasm/src/raptorq/build_raptorq_wasm_colab.py +++ b/packages/raptorqr-raptorq-wasm/src/build_raptorq_wasm_colab.py @@ -3,8 +3,8 @@ This script is intended for Google Colab so the main development machine does not need a Rust toolchain. It can be pasted directly into a Colab cell. If a -RaptorQR repo is present, artifacts are copied into packages/raptorqr-wasm/src/raptorq/wasm; otherwise -they are written to /content/qrstream_raptorq_wasm_artifacts and zipped. +RaptorQR repo is present, artifacts are copied into packages/raptorqr-raptorq-wasm/src/wasm; otherwise +they are written to /content/raptorqr_raptorq_wasm_artifacts and zipped. """ from __future__ import annotations @@ -19,8 +19,8 @@ from pathlib import Path CRATE_VERSION = "2.0.1" -BUILD_DIR = Path("/content/qrstream_raptorq_wasm_build") -PACKAGE_NAME = "qrstream_raptorq_wasm" +BUILD_DIR = Path("/content/raptorqr_raptorq_wasm_build") +PACKAGE_NAME = "raptorqr_raptorq_wasm" WRAPPER_CARGO_TOML = f"""\ @@ -192,15 +192,15 @@ def find_repo_root() -> Path | None: candidates.append(Path(env_repo).expanduser()) for candidate in candidates: - if candidate and (candidate / "packages" / "raptorqr-wasm" / "src" / "raptorq").exists() and (candidate / "pnpm-workspace.yaml").exists(): + if candidate and (candidate / "packages" / "raptorqr-raptorq-wasm" / "src").exists() and (candidate / "pnpm-workspace.yaml").exists(): return candidate.resolve() return None def default_output_dir(repo_root: Path | None) -> Path: if repo_root is not None: - return repo_root / "packages" / "raptorqr-wasm" / "src" / "raptorq" / "wasm" - return Path("/content/qrstream_raptorq_wasm_artifacts") + return repo_root / "packages" / "raptorqr-raptorq-wasm" / "src" / "wasm" + return Path("/content/raptorqr_raptorq_wasm_artifacts") def ensure_rust_toolchain() -> None: diff --git a/packages/raptorqr-wasm/src/raptorq/index.ts b/packages/raptorqr-raptorq-wasm/src/index.d.ts similarity index 61% rename from packages/raptorqr-wasm/src/raptorq/index.ts rename to packages/raptorqr-raptorq-wasm/src/index.d.ts index 275381c..b8155c0 100644 --- a/packages/raptorqr-wasm/src/raptorq/index.ts +++ b/packages/raptorqr-raptorq-wasm/src/index.d.ts @@ -3,4 +3,4 @@ export { initSync, RaptorQDecoder, encode_packets, -} from './wasm/qrstream_raptorq_wasm.js'; +} from './wasm/raptorqr_raptorq_wasm.js'; diff --git a/packages/raptorqr-raptorq-wasm/src/index.js b/packages/raptorqr-raptorq-wasm/src/index.js new file mode 100644 index 0000000..b8155c0 --- /dev/null +++ b/packages/raptorqr-raptorq-wasm/src/index.js @@ -0,0 +1,6 @@ +export { + default, + initSync, + RaptorQDecoder, + encode_packets, +} from './wasm/raptorqr_raptorq_wasm.js'; diff --git a/packages/raptorqr-raptorq-wasm/src/wasm/manifest.json b/packages/raptorqr-raptorq-wasm/src/wasm/manifest.json new file mode 100644 index 0000000..4a15859 --- /dev/null +++ b/packages/raptorqr-raptorq-wasm/src/wasm/manifest.json @@ -0,0 +1,13 @@ +{ + "builtAt": "2026-07-07T13:44:11.574541+00:00", + "outputs": [ + "package.json", + "raptorqr_raptorq_wasm.d.ts", + "raptorqr_raptorq_wasm.js", + "raptorqr_raptorq_wasm_bg.wasm", + "raptorqr_raptorq_wasm_bg.wasm.d.ts" + ], + "package": "raptorqr_raptorq_wasm", + "raptorq": "2.0.1", + "target": "web" +} diff --git a/packages/raptorqr-raptorq-wasm/src/wasm/package.json b/packages/raptorqr-raptorq-wasm/src/wasm/package.json new file mode 100644 index 0000000..a3a35ec --- /dev/null +++ b/packages/raptorqr-raptorq-wasm/src/wasm/package.json @@ -0,0 +1,15 @@ +{ + "name": "raptorqr_raptorq_wasm", + "type": "module", + "version": "0.1.0", + "files": [ + "raptorqr_raptorq_wasm_bg.wasm", + "raptorqr_raptorq_wasm.js", + "raptorqr_raptorq_wasm.d.ts" + ], + "main": "raptorqr_raptorq_wasm.js", + "types": "raptorqr_raptorq_wasm.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} diff --git a/packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm.d.ts b/packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm.d.ts similarity index 100% rename from packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm.d.ts rename to packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm.d.ts diff --git a/packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm.js b/packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm.js similarity index 98% rename from packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm.js rename to packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm.js index 17d449c..abc01a5 100644 --- a/packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm.js +++ b/packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm.js @@ -1,4 +1,4 @@ -/* @ts-self-types="./qrstream_raptorq_wasm.d.ts" */ +/* @ts-self-types="./raptorqr_raptorq_wasm.d.ts" */ export class RaptorQDecoder { __destroy_into_raw() { @@ -108,7 +108,7 @@ function __wbg_get_imports() { }; return { __proto__: null, - "./qrstream_raptorq_wasm_bg.js": import0, + "./raptorqr_raptorq_wasm_bg.js": import0, }; } @@ -270,7 +270,7 @@ async function __wbg_init(module_or_path) { } if (module_or_path === undefined) { - module_or_path = new URL('qrstream_raptorq_wasm_bg.wasm', import.meta.url); + module_or_path = new URL('raptorqr_raptorq_wasm_bg.wasm', import.meta.url); } const imports = __wbg_get_imports(); diff --git a/packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm_bg.wasm b/packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm_bg.wasm similarity index 99% rename from packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm_bg.wasm rename to packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm_bg.wasm index 48c93bc..f4fa07e 100644 Binary files a/packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm_bg.wasm and b/packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm_bg.wasm differ diff --git a/packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm_bg.wasm.d.ts b/packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm_bg.wasm.d.ts similarity index 100% rename from packages/raptorqr-wasm/src/raptorq/wasm/qrstream_raptorq_wasm_bg.wasm.d.ts rename to packages/raptorqr-raptorq-wasm/src/wasm/raptorqr_raptorq_wasm_bg.wasm.d.ts diff --git a/packages/raptorqr-raptorq-wasm/tsconfig.json b/packages/raptorqr-raptorq-wasm/tsconfig.json new file mode 100644 index 0000000..deaf658 --- /dev/null +++ b/packages/raptorqr-raptorq-wasm/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.d.ts"] +} diff --git a/packages/raptorqr-wasm/README.md b/packages/raptorqr-wasm/README.md deleted file mode 100644 index b0b9087..0000000 --- a/packages/raptorqr-wasm/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# @raptorqr/wasm - -Generated WASM artifacts used by RaptorQR. - -Most application code should use `@raptorqr/core` instead of importing this package directly. This package exposes the raw wasm-bindgen modules for low-level integration, tests, and core wrappers. - -## Exports - -### fast_qr - -```ts -import init, { - initSync, - QrRenderer, - type InitOutput, -} from '@raptorqr/wasm/fast-qr'; -``` - -Exports: - -```text -default init(module_or_path?) -> Promise -initSync(module) -> InitOutput -QrRenderer -InitOutput -``` - -`QrRenderer` methods: - -```text -new QrRenderer() -render(data, version, ecc, scale) -> sidePx -render_rgba(data, version, ecc, scale) -> sidePx -render_matrix(data, version, ecc) -> sideModules -rgba_ptr() / rgba_len() -buf_ptr() / buf_len() -matrix_ptr() / matrix_len() -last_matrix_size() -free() -``` - -ECC numeric mapping: - -```text -0 = L -1 = M -2 = Q -3 = H -``` - -Render an RGBA QR image: - -```ts -import init, { QrRenderer } from '@raptorqr/wasm/fast-qr'; - -const wasm = await init(); -const renderer = new QrRenderer(); - -const sidePx = renderer.render_rgba(packetBytes, 10, 1, 4); -const byteLength = sidePx * sidePx * 4; - -const rgba = new Uint8ClampedArray( - wasm.memory.buffer, - renderer.rgba_ptr(), - byteLength, -); - -// Copy before another render call if you need to keep the pixels. -const copy = new Uint8ClampedArray(rgba); -``` - -Render a raw QR module matrix: - -```ts -const sideModules = renderer.render_matrix(packetBytes, 10, 1); -const modules = new Uint8Array( - wasm.memory.buffer, - renderer.matrix_ptr(), - sideModules * sideModules, -); - -// modules[row * sideModules + col] is 0 for light, 1 for dark. -``` - -### RaptorQ - -```ts -import init, { - initSync, - encode_packets, - RaptorQDecoder, -} from '@raptorqr/wasm/raptorq'; -``` - -Exports: - -```text -default init(module_or_path?) -> Promise -initSync(module) -> InitOutput -encode_packets(data, max_transport_payload_size, repair_percent) -> Array -RaptorQDecoder -``` - -`RaptorQDecoder` methods: - -```text -new RaptorQDecoder(data_len, max_transport_payload_size) -push(serialized_packet) -> Uint8Array | null -free() -``` - -Encode and decode directly: - -```ts -import init, { - encode_packets, - RaptorQDecoder, -} from '@raptorqr/wasm/raptorq'; - -await init(); - -const data = new TextEncoder().encode('hello'); -const maxTransportPayloadSize = 201; -const repairPercent = 10; - -const packets = Array.from( - encode_packets(data, maxTransportPayloadSize, repairPercent), - (packet) => new Uint8Array(packet), -); - -const decoder = new RaptorQDecoder(data.length, maxTransportPayloadSize); - -let decoded: Uint8Array | null = null; -for (const packet of packets) { - const result = decoder.push(packet); - if (result) { - decoded = new Uint8Array(result); - break; - } -} -``` - -Direct RaptorQ packets are codec payloads only. They do not include the RaptorQR 8-byte transport header, CRC32C trailer, compression metadata, filename metadata, or QR scheduling semantics. Use `@raptorqr/core/sender/raptorq_packetizer` when you need complete RaptorQR transfer packets. - -## WASM Asset Subpaths - -The generated files are exported for bundlers and Node loaders: - -```text -@raptorqr/wasm/fast-qr/wasm/* -@raptorqr/wasm/raptorq/wasm/* -``` - -The important binary sidecars are: - -```text -src/fast_qr/wasm/qrstream_fast_qr_wasm_bg.wasm -src/raptorq/wasm/qrstream_raptorq_wasm_bg.wasm -``` - -## Verification - -Verify the RaptorQ artifact: - -```bash -pnpm --filter @raptorqr/wasm test -``` - -## Regeneration - -The Colab build scripts are kept in: - -```text -src/fast_qr/build_fast_qr_wasm_colab.py -src/raptorq/build_raptorq_wasm_colab.py -``` - -After regenerating artifacts, run: - -```bash -pnpm test -pnpm build -``` diff --git a/packages/raptorqr-wasm/package.json b/packages/raptorqr-wasm/package.json deleted file mode 100644 index 58fd751..0000000 --- a/packages/raptorqr-wasm/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@raptorqr/wasm", - "version": "0.1.0", - "description": "Generated WASM artifacts and build scripts for RaptorQR", - "type": "module", - "exports": { - "./fast-qr": { - "types": "./src/fast_qr/index.ts", - "import": "./src/fast_qr/index.ts" - }, - "./raptorq": { - "types": "./src/raptorq/index.ts", - "import": "./src/raptorq/index.ts" - }, - "./fast-qr/wasm/*": "./src/fast_qr/wasm/*", - "./raptorq/wasm/*": "./src/raptorq/wasm/*" - }, - "files": [ - "src/fast_qr/", - "src/raptorq/", - "scripts/" - ], - "scripts": { - "build": "tsc -p tsconfig.json --noEmit", - "test": "pnpm run verify:raptorq", - "verify:raptorq": "node scripts/verify-raptorq-wasm.mjs" - } -} diff --git a/packages/raptorqr-wasm/src/fast_qr/index.ts b/packages/raptorqr-wasm/src/fast_qr/index.ts deleted file mode 100644 index 09f4322..0000000 --- a/packages/raptorqr-wasm/src/fast_qr/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - default, - initSync, - QrRenderer, -} from './wasm/qrstream_fast_qr_wasm.js'; -export type { InitOutput } from './wasm/qrstream_fast_qr_wasm.js'; diff --git a/packages/raptorqr-wasm/src/fast_qr/wasm/manifest.json b/packages/raptorqr-wasm/src/fast_qr/wasm/manifest.json deleted file mode 100644 index 7ec19e8..0000000 --- a/packages/raptorqr-wasm/src/fast_qr/wasm/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "builtAt": "2026-07-08T01:50:04.220654+00:00", - "fast_qr": "0.13", - "outputs": [ - "package.json", - "qrstream_fast_qr_wasm.d.ts", - "qrstream_fast_qr_wasm.js", - "qrstream_fast_qr_wasm_bg.wasm", - "qrstream_fast_qr_wasm_bg.wasm.d.ts" - ], - "package": "qrstream_fast_qr_wasm", - "target": "web" -} diff --git a/packages/raptorqr-wasm/src/fast_qr/wasm/package.json b/packages/raptorqr-wasm/src/fast_qr/wasm/package.json deleted file mode 100644 index 2842cfe..0000000 --- a/packages/raptorqr-wasm/src/fast_qr/wasm/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "qrstream_fast_qr_wasm", - "type": "module", - "version": "0.1.0", - "files": [ - "qrstream_fast_qr_wasm_bg.wasm", - "qrstream_fast_qr_wasm.js", - "qrstream_fast_qr_wasm.d.ts" - ], - "main": "qrstream_fast_qr_wasm.js", - "types": "qrstream_fast_qr_wasm.d.ts", - "sideEffects": [ - "./snippets/*" - ] -} diff --git a/packages/raptorqr-wasm/src/raptorq/wasm/manifest.json b/packages/raptorqr-wasm/src/raptorq/wasm/manifest.json deleted file mode 100644 index 22390f4..0000000 --- a/packages/raptorqr-wasm/src/raptorq/wasm/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "builtAt": "2026-07-07T13:44:11.574541+00:00", - "outputs": [ - "package.json", - "qrstream_raptorq_wasm.d.ts", - "qrstream_raptorq_wasm.js", - "qrstream_raptorq_wasm_bg.wasm", - "qrstream_raptorq_wasm_bg.wasm.d.ts" - ], - "package": "qrstream_raptorq_wasm", - "raptorq": "2.0.1", - "target": "web" -} diff --git a/packages/raptorqr-wasm/src/raptorq/wasm/package.json b/packages/raptorqr-wasm/src/raptorq/wasm/package.json deleted file mode 100644 index a8f13a8..0000000 --- a/packages/raptorqr-wasm/src/raptorq/wasm/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "qrstream_raptorq_wasm", - "type": "module", - "version": "0.1.0", - "files": [ - "qrstream_raptorq_wasm_bg.wasm", - "qrstream_raptorq_wasm.js", - "qrstream_raptorq_wasm.d.ts" - ], - "main": "qrstream_raptorq_wasm.js", - "types": "qrstream_raptorq_wasm.d.ts", - "sideEffects": [ - "./snippets/*" - ] -} diff --git a/packages/raptorqr-wasm/tsconfig.json b/packages/raptorqr-wasm/tsconfig.json deleted file mode 100644 index e6c108a..0000000 --- a/packages/raptorqr-wasm/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src/**/*.ts", "src/**/*.d.ts"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d684cb8..b2b4192 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,18 @@ importers: '@preact/preset-vite': specifier: '2' version: 2.10.5(@babel/core@7.29.7)(preact@10.29.4)(rollup@4.62.2)(vite@6.4.3(@types/node@26.1.0)) - '@types/bun': - specifier: latest - version: 1.3.14 + '@raptorqr/core': + specifier: workspace:* + version: link:packages/raptorqr-core + '@raptorqr/fast-qr-wasm': + specifier: workspace:* + version: link:packages/raptorqr-fast-qr-wasm + '@raptorqr/raptorq-wasm': + specifier: workspace:* + version: link:packages/raptorqr-raptorq-wasm + '@types/node': + specifier: 26.1.0 + version: 26.1.0 esbuild: specifier: ^0.28.1 version: 0.28.1 @@ -35,9 +44,12 @@ importers: '@raptorqr/core': specifier: workspace:* version: link:../../packages/raptorqr-core - '@raptorqr/wasm': + '@raptorqr/fast-qr-wasm': specifier: workspace:* - version: link:../../packages/raptorqr-wasm + version: link:../../packages/raptorqr-fast-qr-wasm + '@raptorqr/raptorq-wasm': + specifier: workspace:* + version: link:../../packages/raptorqr-raptorq-wasm fflate: specifier: ^0.8.2 version: 0.8.3 @@ -54,15 +66,21 @@ importers: '@raptorqr/core': specifier: workspace:* version: link:../raptorqr-core - '@raptorqr/wasm': + '@raptorqr/fast-qr-wasm': specifier: workspace:* - version: link:../raptorqr-wasm + version: link:../raptorqr-fast-qr-wasm + '@raptorqr/raptorq-wasm': + specifier: workspace:* + version: link:../raptorqr-raptorq-wasm packages/raptorqr-core: dependencies: - '@raptorqr/wasm': + '@raptorqr/fast-qr-wasm': specifier: workspace:* - version: link:../raptorqr-wasm + version: link:../raptorqr-fast-qr-wasm + '@raptorqr/raptorq-wasm': + specifier: workspace:* + version: link:../raptorqr-raptorq-wasm fflate: specifier: ^0.8.2 version: 0.8.3 @@ -73,7 +91,9 @@ importers: specifier: ^3.1.0 version: 3.1.0(@types/emscripten@1.41.5) - packages/raptorqr-wasm: {} + packages/raptorqr-fast-qr-wasm: {} + + packages/raptorqr-raptorq-wasm: {} packages: @@ -659,9 +679,6 @@ packages: cpu: [x64] os: [win32] - '@types/bun@1.3.14': - resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -738,9 +755,6 @@ packages: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} - bun-types@1.3.14: - resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1556,10 +1570,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true - '@types/bun@1.3.14': - dependencies: - bun-types: 1.3.14 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1645,10 +1655,6 @@ snapshots: dependencies: '@types/node': 26.1.0 - bun-types@1.3.14: - dependencies: - '@types/node': 26.1.0 - cac@6.7.14: {} caniuse-lite@1.0.30001802: {} diff --git a/smoke.test.ts b/smoke.test.ts new file mode 100644 index 0000000..9f19889 --- /dev/null +++ b/smoke.test.ts @@ -0,0 +1,396 @@ +/** + * Root release smoke test. + * + * Usage: + * pnpm install + * pnpm build + * pnpm test:smoke + * + * What this protects: + * - root-level consumers can resolve the split workspace packages by package name + * - generated WASM sidecars are present, renamed to raptorqr_*, and loadable + * - fast_qr actually renders a QR matrix/RGBA image from its WASM module + * - RaptorQ actually encodes/decodes bytes from its WASM module + * - core packetization emits wasm-raptorq transport packets, not legacy js-rlnc + * - the core Node QR facade can read the packaged fast_qr WASM asset + * - the built CLI and web assets do not carry stale generated sidecars + */ + +import { Buffer } from 'node:buffer'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { + initSync as initFastQrSync, + QrRenderer, +} from '@raptorqr/fast-qr-wasm'; +import { + initSync as initRaptorQSync, + encode_packets, + RaptorQDecoder, +} from '@raptorqr/raptorq-wasm'; +import { DEFAULT_RAPTORQ_REPAIR_PERCENT } from '@raptorqr/core/fec/codec'; +import { MAX_PAYLOAD_SIZE, RAPTORQ_SYMBOL_INDEX } from '@raptorqr/core/protocol/constants'; +import { packetCodec, parsePacket } from '@raptorqr/core/protocol/packet'; +import { RaptorQWasmDecoder } from '@raptorqr/core/fec/raptorq_wasm'; +import { packetizeRaptorQ } from '@raptorqr/core/sender/raptorq_packetizer'; +import { encodeQRCodeMatrix } from '@raptorqr/core/node'; + +const textEncoder = new TextEncoder(); +const WASM_MAGIC = Buffer.from([0x00, 0x61, 0x73, 0x6d]); +const LEGACY_GENERATED_PREFIX = 'qrs' + 'tream'; +const LEGACY_AGGREGATE_PACKAGE = '@raptorqr' + '/wasm'; +const LEGACY_AGGREGATE_DIR = 'packages/raptorqr' + '-wasm'; + +function log(message: string, details?: unknown): void { + if (details === undefined) { + console.info(`[smoke] ${message}`); + return; + } + console.info(`[smoke] ${message}`, details); +} + +function readJson(path: string): Record { + log(`reading manifest: ${path}`); + return JSON.parse(readFileSync(path, 'utf8')); +} + +function packageRoot(packageName: string): string { + const linkPath = join('node_modules', '@raptorqr', packageName); + expect(existsSync(linkPath)).toBe(true); + const realPath = realpathSync(linkPath); + log(`resolved @raptorqr/${packageName}: ${realPath}`); + return realPath; +} + +function packageFile(packageName: string, relativePath: string): string { + const path = join(packageRoot(packageName), relativePath); + log(`resolved @raptorqr/${packageName}/${relativePath}: ${path}`); + return path; +} + +function fastQrWasmPath(): string { + return packageFile('fast-qr-wasm', 'src/wasm/raptorqr_fast_qr_wasm_bg.wasm'); +} + +function raptorqWasmPath(): string { + return packageFile('raptorq-wasm', 'src/wasm/raptorqr_raptorq_wasm_bg.wasm'); +} + +function readBytes(path: string): Buffer { + const bytes = readFileSync(path); + log(`reading binary: ${path} (${bytes.byteLength} bytes)`); + return bytes; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, index) => byte === b[index]); +} + +function expectWasmBinary(path: string, expectedImportName: string): Buffer { + const bytes = readBytes(path); + expect(bytes.subarray(0, 4)).toEqual(WASM_MAGIC); + expect(bytes.includes(Buffer.from(expectedImportName))).toBe(true); + expect(bytes.includes(Buffer.from(LEGACY_GENERATED_PREFIX))).toBe(false); + return bytes; +} + +function expectNoOldPackageText(path: string): void { + const text = readFileSync(path, 'utf8'); + expect(text).not.toContain(LEGACY_GENERATED_PREFIX); + expect(text).not.toContain(LEGACY_AGGREGATE_PACKAGE); + expect(text).not.toContain(LEGACY_AGGREGATE_DIR.replace('packages/', '')); +} + +function expectPackageExport( + manifest: Record, + expectedName: string, +): void { + const exportsField = manifest.exports as Record; + expect(manifest).toMatchObject({ + name: expectedName, + version: '0.1.1', + license: 'MIT', + publishConfig: { access: 'public' }, + }); + expect(exportsField['.']).toMatchObject({ + import: './src/index.js', + types: './src/index.d.ts', + }); + expect(exportsField['./wasm/*']).toBe('./src/wasm/*'); +} + +describe('root release smoke', () => { + test('split package names resolve to real local package entrypoints', () => { + // This is the first thing that breaks when root workspace links are stale. + // The printed paths should point at packages/raptorqr-*-wasm and + // packages/raptorqr-core, never at the removed aggregate package. + const resolved = { + fastQr: packageFile('fast-qr-wasm', 'src/index.js'), + fastQrWasm: fastQrWasmPath(), + raptorq: packageFile('raptorq-wasm', 'src/index.js'), + raptorqWasm: raptorqWasmPath(), + core: packageFile('core', 'src/index.ts'), + coreNode: packageFile('core', 'src/node.ts'), + coreRaptorQ: packageFile('core', 'src/fec/raptorq_wasm.ts'), + }; + log('resolved package paths', resolved); + + expect(resolved.fastQr).toContain('raptorqr-fast-qr-wasm'); + expect(resolved.fastQr).toMatch(/src[\\/]index\.js$/); + expect(resolved.fastQrWasm).toContain('raptorqr_fast_qr_wasm_bg.wasm'); + expect(resolved.raptorq).toContain('raptorqr-raptorq-wasm'); + expect(resolved.raptorq).toMatch(/src[\\/]index\.js$/); + expect(resolved.raptorqWasm).toContain('raptorqr_raptorq_wasm_bg.wasm'); + expect(resolved.core).toContain('raptorqr-core'); + expect(resolved.coreNode).toContain('raptorqr-core'); + expect(resolved.coreRaptorQ).toContain('raptorqr-core'); + Object.values(resolved).forEach((path) => expect(existsSync(path)).toBe(true)); + + expect(existsSync(join('node_modules', '@raptorqr', 'wasm'))).toBe(false); + expect(existsSync(LEGACY_AGGREGATE_DIR)).toBe(false); + }); + + test('publish manifests expose JS runtime entrypoints and renamed WASM subpaths', () => { + const fastQrPkg = readJson('packages/raptorqr-fast-qr-wasm/package.json'); + const raptorqPkg = readJson('packages/raptorqr-raptorq-wasm/package.json'); + const corePkg = readJson('packages/raptorqr-core/package.json'); + const cliPkg = readJson('packages/raptorqr-cli/package.json'); + + expectPackageExport(fastQrPkg, '@raptorqr/fast-qr-wasm'); + expectPackageExport(raptorqPkg, '@raptorqr/raptorq-wasm'); + expect(corePkg).toMatchObject({ + name: '@raptorqr/core', + version: '0.1.1', + license: 'MIT', + publishConfig: { access: 'public' }, + }); + expect(cliPkg).toMatchObject({ + name: '@raptorqr/cli', + version: '0.1.1', + license: 'MIT', + publishConfig: { access: 'public' }, + bin: { raptorqr: 'dist/raptorqr.js' }, + }); + + expectNoOldPackageText('packages/raptorqr-fast-qr-wasm/package.json'); + expectNoOldPackageText('packages/raptorqr-raptorq-wasm/package.json'); + expectNoOldPackageText('packages/raptorqr-core/package.json'); + expectNoOldPackageText('packages/raptorqr-cli/package.json'); + }); + + test('generated WASM glue references raptorqr names, not stale generated names', () => { + // wasm-bindgen stores the import module name in both JS glue and the .wasm + // binary. If only filenames are renamed, this test catches the mismatch. + const fastQrJs = packageFile('fast-qr-wasm', 'src/wasm/raptorqr_fast_qr_wasm.js'); + const fastQrWasm = fastQrWasmPath(); + const raptorqJs = packageFile('raptorq-wasm', 'src/wasm/raptorqr_raptorq_wasm.js'); + const raptorqWasm = raptorqWasmPath(); + + log('checking wasm-bindgen glue paths', { fastQrJs, fastQrWasm, raptorqJs, raptorqWasm }); + + const fastQrGlue = readFileSync(fastQrJs, 'utf8'); + const raptorqGlue = readFileSync(raptorqJs, 'utf8'); + expect(fastQrGlue).toContain('raptorqr_fast_qr_wasm_bg.wasm'); + expect(fastQrGlue).toContain('./raptorqr_fast_qr_wasm_bg.js'); + expect(raptorqGlue).toContain('raptorqr_raptorq_wasm_bg.wasm'); + expect(raptorqGlue).toContain('./raptorqr_raptorq_wasm_bg.js'); + expect(fastQrGlue).not.toContain(LEGACY_GENERATED_PREFIX); + expect(raptorqGlue).not.toContain(LEGACY_GENERATED_PREFIX); + + expectWasmBinary(fastQrWasm, 'raptorqr_fast_qr_wasm_bg.js'); + expectWasmBinary(raptorqWasm, 'raptorqr_raptorq_wasm_bg.js'); + }); + + test('fast QR WASM renders matrix and RGBA output', () => { + const wasmPath = fastQrWasmPath(); + const wasmBytes = readBytes(wasmPath); + const wasm = initFastQrSync({ module: wasmBytes }); + const renderer = new QrRenderer(); + const data = textEncoder.encode('raptorqr fast qr smoke'); + + const sideModules = renderer.render_matrix(data, 10, 1); + const matrix = new Uint8Array( + wasm.memory.buffer, + renderer.matrix_ptr(), + sideModules * sideModules, + ); + + log('fast QR matrix rendered', { + sideModules, + matrixBytes: matrix.byteLength, + matrixPtr: renderer.matrix_ptr(), + }); + + expect(sideModules).toBeGreaterThan(0); + expect(matrix.some((value) => value === 1)).toBe(true); + expect(matrix.some((value) => value === 0)).toBe(true); + + const sidePx = renderer.render_rgba(data, 10, 1, 3); + log('fast QR RGBA rendered', { + sidePx, + rgbaPtr: renderer.rgba_ptr(), + rgbaCapacity: renderer.rgba_len(), + }); + + expect(sidePx).toBeGreaterThan(0); + expect(renderer.rgba_ptr()).toBeGreaterThan(0); + expect(renderer.rgba_len()).toBeGreaterThanOrEqual(sidePx * sidePx * 4); + }); + + test('RaptorQ WASM encodes unique packets and decodes them', () => { + const wasmPath = raptorqWasmPath(); + const wasmBytes = readBytes(wasmPath); + initRaptorQSync({ module: wasmBytes }); + + const original = textEncoder.encode('raptorqr raptorq wasm smoke'.repeat(80)); + const transportPayloadSize = 256; + const packets = Array.from( + encode_packets(original, transportPayloadSize, 25), + (packet) => new Uint8Array(packet), + ); + const uniquePayloadIds = new Set( + packets.map((packet) => Buffer.from(packet.slice(0, 4)).toString('hex')), + ); + + log('RaptorQ raw packets encoded', { + inputBytes: original.length, + packetCount: packets.length, + uniquePayloadIds: uniquePayloadIds.size, + maxPacketBytes: Math.max(...packets.map((packet) => packet.length)), + transportPayloadSize, + }); + + expect(packets.length).toBeGreaterThan(0); + expect(packets.every((packet) => packet.length <= transportPayloadSize)).toBe(true); + expect(uniquePayloadIds.size).toBe(packets.length); + + const decoder = new RaptorQDecoder(original.length, transportPayloadSize); + let decoded: Uint8Array | null = null; + for (const packet of packets) { + decoded = decoder.push(packet); + if (decoded) break; + } + + expect(decoded).not.toBeNull(); + expect(bytesEqual(original, decoded!.slice(0, original.length))).toBe(true); + }); + + test('core packetizer emits real RaptorQ transport packets and decodes back', async () => { + // This test is the important "not secretly js-rlnc" guard: + // 1. every transport packet must carry the RaptorQ sentinel symbol index + // 2. packetCodec must report wasm-raptorq + // 3. the payloads must decode through RaptorQWasmDecoder back to original bytes + const original = textEncoder.encode('raptorqr core packetizer smoke'.repeat(32)); + initRaptorQSync({ module: readBytes(raptorqWasmPath()) }); + const result = await packetizeRaptorQ( + original, + true, + false, + undefined, + undefined, + { + maxTransportPayloadSize: MAX_PAYLOAD_SIZE, + repairPercent: DEFAULT_RAPTORQ_REPAIR_PERCENT, + }, + ); + const parsedPackets = result.packets.map((packet) => parsePacket(packet)); + + log('core packetizer emitted packets', { + packets: result.packets.length, + sourceGenerations: result.sourceGenerations, + totalGenerations: result.totalGenerations, + dataLength: result.dataLength, + symbolSize: result.symbolSize, + firstSymbolIndex: parsedPackets[0]?.header.symbolIndex, + firstCodec: parsedPackets[0] ? packetCodec(parsedPackets[0].header) : null, + }); + + expect(result.packets.length).toBeGreaterThan(0); + expect(result.isCompressed).toBe(false); + expect(result.dataLength).toBe(original.length); + expect(result.symbolSize).toBe(MAX_PAYLOAD_SIZE); + expect(parsedPackets.every((packet) => packetCodec(packet.header) === 'wasm-raptorq')).toBe(true); + expect(parsedPackets.every((packet) => packet.header.symbolIndex === RAPTORQ_SYMBOL_INDEX)).toBe(true); + expect(parsedPackets.every((packet) => packet.payload.length <= MAX_PAYLOAD_SIZE)).toBe(true); + + const decoder = await RaptorQWasmDecoder.create( + parsedPackets[0]!.header.dataLength, + parsedPackets[0]!.payload.length, + ); + + let decoded: Uint8Array | null = null; + for (const packet of parsedPackets) { + decoded = decoder.push(packet.payload); + if (decoded) break; + } + + expect(decoded).not.toBeNull(); + expect(bytesEqual(original, decoded!.slice(0, original.length))).toBe(true); + }); + + test('core Node QR facade renders a RaptorQ packet through fast QR WASM', async () => { + // The web app uses Vite URL loading, but the CLI/Node path reads the + // package/exported sidecar from disk. This catches broken Node asset + // resolution that may not show up in browser-only testing. + initRaptorQSync({ module: readBytes(raptorqWasmPath()) }); + const result = await packetizeRaptorQ( + textEncoder.encode('node qr facade smoke'), + true, + false, + undefined, + undefined, + { + maxTransportPayloadSize: MAX_PAYLOAD_SIZE, + repairPercent: DEFAULT_RAPTORQ_REPAIR_PERCENT, + }, + ); + const matrix = await encodeQRCodeMatrix(result.packets[0]!, 10, 'M'); + + log('core Node QR facade rendered matrix', { + packetBytes: result.packets[0]!.length, + rows: matrix.length, + cols: matrix[0]?.length ?? 0, + }); + + expect(matrix.length).toBeGreaterThan(0); + expect(matrix.every((row) => row.length === matrix.length)).toBe(true); + expect(matrix.some((row) => row.some(Boolean))).toBe(true); + }); + + test('CLI bundle starts and ships only renamed WASM sidecars', () => { + const cliFiles = readdirSync('packages/raptorqr-cli/dist'); + log('CLI dist files', cliFiles); + + expect(cliFiles).toContain('raptorqr.js'); + expect(cliFiles).toContain('raptorqr_fast_qr_wasm_bg.wasm'); + expect(cliFiles).toContain('raptorqr_raptorq_wasm_bg.wasm'); + expect(cliFiles.some((file) => file.includes(LEGACY_GENERATED_PREFIX))).toBe(false); + expectNoOldPackageText('packages/raptorqr-cli/dist/raptorqr.js'); + + const result = spawnSync('node', ['packages/raptorqr-cli/dist/raptorqr.js', '--help'], { + encoding: 'utf8', + }); + log('CLI --help output', result.stdout.trim().split(/\r?\n/).slice(0, 5)); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('raptorqr [file]'); + expect(result.stdout).toContain('raptorqr --serve'); + }); + + test('web build assets include renamed WASM files and no stale generated files', () => { + const assetDir = 'apps/web/dist/assets'; + expect(existsSync(assetDir)).toBe(true); + + const assets = readdirSync(assetDir); + log('web wasm assets', assets.filter((file) => file.endsWith('.wasm'))); + + expect(assets.some((file) => /^raptorqr_fast_qr_wasm_bg-.+\.wasm$/.test(file))).toBe(true); + expect(assets.some((file) => /^raptorqr_raptorq_wasm_bg-.+\.wasm$/.test(file))).toBe(true); + expect(assets.some((file) => file.includes(LEGACY_GENERATED_PREFIX))).toBe(false); + }); +});