mirror of
https://github.com/infrost/RaptorQR.git
synced 2026-09-05 01:17:48 +08:00
package optimsation and adding benchmark and more tests
This commit is contained in:
+13
-6
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 的硬边界。这个是最需要优先处理的问题。
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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:
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -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();
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
+7
-7
@@ -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:
|
||||
Vendored
+1
-1
@@ -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/*"
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -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();
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
Generated
+30
-24
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user