package optimsation and adding benchmark and more tests

This commit is contained in:
infrost
2026-07-09 00:42:33 +01:00
parent c25569fb1a
commit ca56109b05
56 changed files with 1634 additions and 817 deletions
+13 -6
View File
@@ -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
+3 -2
View File
@@ -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
+8 -7
View File
@@ -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.
+2 -1
View File
@@ -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"
},
+2 -2
View File
@@ -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,
+31 -10
View File
@@ -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<Response> {
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;
}
}
-438
View File
@@ -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 做系统型 RLNC16 个 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 的硬边界。这个是最需要优先处理的问题。
+277
View File
@@ -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<number> {
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<string>();
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);
});
+83
View File
@@ -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.
+353
View File
@@ -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<T>(work: () => Promise<T>): Promise<[T, number]> {
const startedAt = performance.now();
const value = await work();
return [value, performance.now() - startedAt];
}
async function renderFrames(packets: Uint8Array[], scale: number): Promise<ImageData[]> {
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<ParsedPacket[]> {
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<Uint8Array> {
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<BenchRun> {
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<string, string | number> {
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);
});
+10 -4
View File
@@ -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",
+2 -2
View File
@@ -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:
+8 -3
View File
@@ -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"
}
}
@@ -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)}`);
+7 -2
View File
@@ -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"
@@ -1,12 +1,12 @@
import init, {
RaptorQDecoder,
encode_packets,
} from '@raptorqr/wasm/raptorq';
} from '@raptorqr/raptorq-wasm';
let initPromise: Promise<unknown> | 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<void> {
@@ -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.'
);
}
@@ -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',
};
@@ -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;
+109
View File
@@ -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<InitOutput>
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
```
@@ -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"
}
}
@@ -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:
+6
View File
@@ -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';
@@ -0,0 +1,5 @@
export {
default,
initSync,
QrRenderer,
} from './wasm/raptorqr_fast_qr_wasm.js';
@@ -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"
}
@@ -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/*"
]
}
@@ -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();
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.d.ts"]
}
+21
View File
@@ -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.
+98
View File
@@ -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<InitOutput>
initSync(module) -> InitOutput
encode_packets(data, max_transport_payload_size, repair_percent) -> Array<any>
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
```
@@ -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"
}
}
@@ -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;
@@ -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:
@@ -3,4 +3,4 @@ export {
initSync,
RaptorQDecoder,
encode_packets,
} from './wasm/qrstream_raptorq_wasm.js';
} from './wasm/raptorqr_raptorq_wasm.js';
@@ -0,0 +1,6 @@
export {
default,
initSync,
RaptorQDecoder,
encode_packets,
} from './wasm/raptorqr_raptorq_wasm.js';
@@ -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"
}
@@ -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/*"
]
}
@@ -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();
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.d.ts"]
}
-183
View File
@@ -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<InitOutput>
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<InitOutput>
initSync(module) -> InitOutput
encode_packets(data, max_transport_payload_size, repair_percent) -> Array<any>
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
```
-28
View File
@@ -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"
}
}
@@ -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';
@@ -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"
}
@@ -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/*"
]
}
@@ -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"
}
@@ -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/*"
]
}
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+30 -24
View File
@@ -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: {}
+396
View File
@@ -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<string, unknown> {
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<string, unknown>,
expectedName: string,
): void {
const exportsField = manifest.exports as Record<string, { import: string; types: string }>;
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);
});
});