From 74f8eee4d4cce3da847c89eb32a2f897d42578fa Mon Sep 17 00:00:00 2001 From: Ignat Date: Fri, 29 May 2026 22:55:08 -0300 Subject: [PATCH 1/4] fix(codec): wire Node-target artifact into published package (B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move @void-layer/types to devDependencies (import type only, zero runtime presence) - Add src/index.node.ts: ESM wrapper using createRequire to load pkg-node CJS glue + brotli-wasm index.node.js (require condition, fs-based, no fetch) - Wire `node` export condition → dist/index.node.js in package.json#exports - Add pkg-node/ to package.json#files - Update build script to run wasm-pack --target nodejs + rm pkg-node/.gitignore - Add test:pack script (scripts/test-pack.mjs): pnpm-pack → tmp-install → Node ESM import - Exclude src/index.node.ts from vitest coverage (exercised by test:pack, not vitest) Bundler path (pkg/ via `import` condition) retained alongside node path. WASM gzip: 79,264 bytes (cap 81,920; headroom ~2.7 KB). Smoke test: pnpm test:pack --- packages/codec/package.json | 11 ++- packages/codec/scripts/test-pack.mjs | 127 +++++++++++++++++++++++++ packages/codec/src/index.node.ts | 135 +++++++++++++++++++++++++++ packages/codec/vitest.config.ts | 4 + 4 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 packages/codec/scripts/test-pack.mjs create mode 100644 packages/codec/src/index.node.ts diff --git a/packages/codec/package.json b/packages/codec/package.json index 19345bb..f4d7756 100644 --- a/packages/codec/package.json +++ b/packages/codec/package.json @@ -8,6 +8,7 @@ "types": "./dist/index.d.ts", "exports": { ".": { + "node": "./dist/index.node.js", "types": "./dist/index.d.ts", "import": "./dist/index.js" } @@ -15,6 +16,7 @@ "files": [ "dist/", "pkg/", + "pkg-node/", "README.md", "LICENSE", "REGISTRY.md", @@ -46,25 +48,24 @@ "provenance": true }, "scripts": { - "build": "wasm-pack build --target bundler --release --out-dir pkg && rm -f pkg/.gitignore && tsc", + "build": "wasm-pack build --target bundler --release --out-dir pkg && rm -f pkg/.gitignore && wasm-pack build --target nodejs --release --out-dir pkg-node && rm -f pkg-node/.gitignore && tsc", "build:wasm": "wasm-pack build --target bundler --release --out-dir pkg && rm -f pkg/.gitignore", + "build:nodejs": "wasm-pack build --target nodejs --release --out-dir pkg-node && rm -f pkg-node/.gitignore", "build:web": "wasm-pack build --target web --release --out-dir pkg-web", - "build:nodejs": "wasm-pack build --target nodejs --release --out-dir pkg-node", "test": "vitest run", "test:rust": "cargo test --manifest-path Cargo.toml", "test:wasm": "wasm-pack test --node", + "test:pack": "node scripts/test-pack.mjs", "generate-vectors": "vitest run --config scripts/generate-vectors.config.ts", "test:ts-rust-parity": "tsx scripts/ts-rust-parity.ts", "lint": "cargo clippy --all-targets --all-features --locked -- -D warnings && cargo fmt --manifest-path Cargo.toml -- --check && eslint src", "size": "ls -la pkg/void_layer_codec_bg.wasm" }, - "dependencies": { - "@void-layer/types": "workspace:^" - }, "peerDependencies": { "brotli-wasm": "^3.0.1" }, "devDependencies": { + "@void-layer/types": "workspace:^", "@types/node": "25.9.1", "@vitest/coverage-v8": "4.1.8", "brotli-wasm": "^3.0.1", diff --git a/packages/codec/scripts/test-pack.mjs b/packages/codec/scripts/test-pack.mjs new file mode 100644 index 0000000..af4f7ea --- /dev/null +++ b/packages/codec/scripts/test-pack.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * Pack-and-import smoke test — B2 acceptance gate. + * + * Proves the *published* package imports cleanly in plain Node ESM + * (no bundler, no vitest resolver magic). + * + * Steps: + * 1. pnpm pack → tarball + * 2. Install tarball into a clean tmp dir (type:module) via pnpm + * 3. node --input-type=module runs the import + encode→decode round-trip + * 4. Assert round-trip equality; exit 1 on any failure + * + * Run: node scripts/test-pack.mjs (from packages/codec/) + */ + +import { execSync, execFileSync } from 'node:child_process' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const PACKAGE_DIR = resolve(__dirname, '..') + +console.log('=== B2 pack-and-import smoke test ===') +console.log(`Package dir: ${PACKAGE_DIR}`) + +// 1. Build + pack (pnpm rewrites workspace:^ → ^x.y.z) +console.log('\n[1] pnpm pack ...') +execSync('pnpm pack', { + cwd: PACKAGE_DIR, + stdio: 'inherit', +}) +const tarballName = 'void-layer-codec-0.1.0.tgz' +const tarballPath = join(PACKAGE_DIR, tarballName) +console.log(` tarball: ${tarballName}`) + +// 2. Set up clean tmp consumer dir +const tmpDir = mkdtempSync(join(tmpdir(), 'vl-codec-smoke-')) +console.log(`\n[2] Installing tarball into ${tmpDir} ...`) + +// @void-layer/types is import-type-only in the codec source; not needed at runtime. +// brotli-wasm is a peerDep of @void-layer/codec that the consumer must provide. +writeFileSync( + join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'smoke-consumer', + version: '1.0.0', + type: 'module', + dependencies: { + '@void-layer/codec': `file:${tarballPath}`, + 'brotli-wasm': '3.0.1', + }, + }), +) + +execSync('pnpm install --no-frozen-lockfile 2>&1', { + cwd: tmpDir, + stdio: 'inherit', +}) + +// 3. Run the round-trip import in plain Node ESM +console.log('\n[3] Running Node ESM import + encode→decode round-trip ...') + +const smokeScript = /* js */ ` +import { encodeInvoiceWire, decodeInvoiceWire, encodeInvoiceCanonical, decodeInvoiceCanonical } from '@void-layer/codec'; + +const invoice = { + invoice_id: 'B2-SMOKE', + issued_at: 1_700_000_000, + due_at: 1_700_086_400, + network_id: 8453, + currency: 'USDC', + decimals: 6, + from: { name: 'Alice', wallet_address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' }, + client: { name: 'Bob' }, + items: [{ description: 'Smoke test', quantity: 1.0, rate: '1000000' }], + total: '1000000', + salt: 'deadbeefdeadbeefdeadbeefdeadbeef', +}; + +// Canonical round-trip +const canonicalBytes = encodeInvoiceCanonical(invoice); +if (!(canonicalBytes instanceof Uint8Array)) throw new Error('encodeInvoiceCanonical did not return Uint8Array'); +if (canonicalBytes[0] !== 0x56) throw new Error('Missing MAGIC byte 0x56'); + +const canonicalDecoded = decodeInvoiceCanonical(canonicalBytes); +if (canonicalDecoded.invoice_id !== 'B2-SMOKE') throw new Error('canonical round-trip invoice_id mismatch: ' + canonicalDecoded.invoice_id); +if (canonicalDecoded.currency !== 'USDC') throw new Error('canonical round-trip currency mismatch'); +console.log(' canonical round-trip: OK'); + +// Wire round-trip +const wireBytes = await encodeInvoiceWire(invoice); +if (!(wireBytes instanceof Uint8Array)) throw new Error('encodeInvoiceWire did not return Uint8Array'); + +const wireDecoded = await decodeInvoiceWire(wireBytes); +if (wireDecoded.invoice_id !== 'B2-SMOKE') throw new Error('wire round-trip invoice_id mismatch: ' + wireDecoded.invoice_id); +if (wireDecoded.currency !== 'USDC') throw new Error('wire round-trip currency mismatch'); +console.log(' wire round-trip: OK'); + +console.log(''); +console.log('B2 SMOKE PASS'); +` + +let smokePassed = false +try { + execFileSync(process.execPath, ['--input-type=module'], { + cwd: tmpDir, + input: smokeScript, + stdio: ['pipe', 'inherit', 'inherit'], + env: { ...process.env, NODE_OPTIONS: '' }, + }) + smokePassed = true +} catch (err) { + console.error('\nB2 SMOKE FAIL — Node ESM import failed (see above)') +} finally { + // Always clean up tarball + try { execSync(`rm -f "${tarballPath}"`) } catch (_) {} +} + +// Clean up tmp dir +try { execSync(`rm -rf "${tmpDir}"`) } catch (_) {} + +if (!smokePassed) process.exit(1) + +console.log('\n=== B2 DONE ===') diff --git a/packages/codec/src/index.node.ts b/packages/codec/src/index.node.ts new file mode 100644 index 0000000..4901537 --- /dev/null +++ b/packages/codec/src/index.node.ts @@ -0,0 +1,135 @@ +/** + * @void-layer/codec — Node.js entry point (plain Node ESM, no bundler). + * + * Loads the wasm-pack --target nodejs CJS glue via createRequire (which + * uses fs.readFileSync + WebAssembly.Module — no ESM .wasm import needed). + * Re-exports the same public API as index.ts so the `node` export condition + * resolves to a working module for plain `node` consumers. + * + * The bundler entry (index.ts / pkg/) remains available via the `import` + * export condition for bundler consumers (e.g. vl/app via webpack/turbopack). + */ + +import { createRequire } from 'node:module' +import type { BrotliWasmType } from 'brotli-wasm' +import type { Invoice } from '@void-layer/types' + +// createRequire lets ESM load CJS modules — necessary because wasm-pack +// --target nodejs emits CommonJS (exports.xxx + require('fs') + __dirname). +const require = createRequire(import.meta.url) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const nodeWasm = require('../pkg-node/void_layer_codec.js') as { + encodeInvoiceCanonical: (invoice: unknown) => Uint8Array + decodeInvoiceCanonical: (bytes: Uint8Array) => unknown + receiptHash: (canonical_bytes: Uint8Array) => Uint8Array +} + +export const encodeInvoiceCanonical = nodeWasm.encodeInvoiceCanonical +export const decodeInvoiceCanonical = nodeWasm.decodeInvoiceCanonical +export const receiptHash = nodeWasm.receiptHash + +// --------------------------------------------------------------------------- +// Brotli lazy init — mirrors index.ts exactly +// --------------------------------------------------------------------------- + +const COMPRESSED_FLAG = 0x80 +const MAX_DECOMPRESSED_BYTES = 262144 + +// brotli-wasm exports map: `require` condition → index.node.js (fs + sync WASM init, no fetch). +// ESM `import` condition → index.web.js which uses fetch() and fails in plain Node. +// We must use createRequire to force the `require` condition. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const brotliNodeMod = require('brotli-wasm') as { default: Promise } + +let _brotli: BrotliWasmType | null = null + +async function getBrotli(): Promise { + if (!_brotli) { + _brotli = await brotliNodeMod.default + } + return _brotli +} + +// --------------------------------------------------------------------------- +// decompressBounded — mirrors index.ts exactly +// --------------------------------------------------------------------------- + +function decompressBounded( + brotli: BrotliWasmType, + input: Uint8Array, + maxBytes: number, +): Uint8Array { + const CHUNK = maxBytes + const stream = new brotli.DecompressStream() + const chunks: Uint8Array[] = [] + let total = 0 + let inputOffset = 0 + + while (true) { + const slice = input.slice(inputOffset) + const result = stream.decompress(slice, CHUNK) + inputOffset += result.input_offset + + if (result.buf.length === 0 && result.input_offset === 0) { + throw new Error('truncated or corrupt brotli stream (no progress)') + } + + if (result.buf.length > 0) { + total += result.buf.length + if (total > maxBytes) { + throw new Error( + `decompressed wire body exceeds MAX_DECOMPRESSED_BYTES (${maxBytes})`, + ) + } + chunks.push(result.buf) + } + + if (result.code === 0) break + if (result.code === 1) break + } + + const out = new Uint8Array(total) + let pos = 0 + for (const chunk of chunks) { + out.set(chunk, pos) + pos += chunk.length + } + return out +} + +// --------------------------------------------------------------------------- +// Wire encode / decode — mirrors index.ts exactly +// --------------------------------------------------------------------------- + +export async function encodeInvoiceWire(invoice: Invoice): Promise { + const canonical: Uint8Array = encodeInvoiceCanonical(invoice) + const brotli = await getBrotli() + const body = canonical.slice(2) + const compressed = brotli.compress(body, { quality: 11 }) + + if (compressed.length >= body.length) return canonical + + const result = new Uint8Array(2 + compressed.length) + result[0] = canonical[0]! + result[1] = canonical[1]! | COMPRESSED_FLAG + result.set(compressed, 2) + return result +} + +export async function decodeInvoiceWire(bytes: Uint8Array): Promise { + if (bytes.length < 3 || !(bytes[1]! & COMPRESSED_FLAG)) { + return decodeInvoiceCanonical(bytes) as Invoice + } + + const brotli = await getBrotli() + const compressedBody = bytes.slice(2) + const decompressed = decompressBounded(brotli, compressedBody, MAX_DECOMPRESSED_BYTES) + + const canonical = new Uint8Array(2 + decompressed.length) + canonical[0] = bytes[0]! + canonical[1] = bytes[1]! & 0x7f + canonical.set(decompressed, 2) + + return decodeInvoiceCanonical(canonical) as Invoice +} diff --git a/packages/codec/vitest.config.ts b/packages/codec/vitest.config.ts index 4fc9f5c..0cb1996 100644 --- a/packages/codec/vitest.config.ts +++ b/packages/codec/vitest.config.ts @@ -25,6 +25,10 @@ export default defineConfig({ 'dist/**', 'docs/**', 'scripts/**', + // index.node.ts is the Node-target entry (node export condition). + // It is exercised by pnpm test:pack (pack-and-import smoke test), not + // by vitest which runs under the bundler resolver path via vite-plugin-wasm. + 'src/index.node.ts', ], thresholds: { lines: 80, From 7cebf1dcf22f63370813ce788e444cdf09531be2 Mon Sep 17 00:00:00 2001 From: Ignat Date: Fri, 29 May 2026 23:04:29 -0300 Subject: [PATCH 2/4] refactor(codec): extract wire logic to wire.ts; fix lint in test-pack.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 — de-duplication: - Extract src/wire.ts: single source for COMPRESSED_FLAG, MAX_DECOMPRESSED_BYTES, decompressBounded (DoS guard from d83cef9), encodeWire, decodeWire - Both index.ts (bundler) and index.node.ts (Node) import from wire.ts; the only per-entry difference is the brotli-wasm loader (ESM import() vs require) - Remove no-explicit-any eslint-disable directives (as{} cast satisfies the rule) P1 — ESLint: - scripts/test-pack.mjs: remove unused rmSync import, use bare catch{} for intentional empty cleanup handlers, fix unused err catch param - eslint.config.mjs: add scripts/**/*.mjs override declaring Node globals (console/process/URL) so no-undef does not fire on tooling scripts --- eslint.config.mjs | 18 +++ packages/codec/scripts/test-pack.mjs | 20 +++- packages/codec/src/index.node.ts | 106 +++--------------- packages/codec/src/index.ts | 142 ++---------------------- packages/codec/src/wire.ts | 158 +++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 231 deletions(-) create mode 100644 packages/codec/src/wire.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index c687183..4865a4a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,24 @@ export default tseslint.config( }, js.configs.recommended, ...tseslint.configs.recommended, + { + // scripts/ are Node.js ESM tooling (not published). Declare Node globals so + // no-undef doesn't fire on console/process/URL/etc., and relax TS rules + // that are irrelevant for plain .mjs scripts. + files: ['packages/*/scripts/**/*.mjs', 'packages/*/scripts/**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + URL: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, { files: ['packages/*/src/**/*.ts'], languageOptions: { diff --git a/packages/codec/scripts/test-pack.mjs b/packages/codec/scripts/test-pack.mjs index af4f7ea..10b2dc2 100644 --- a/packages/codec/scripts/test-pack.mjs +++ b/packages/codec/scripts/test-pack.mjs @@ -15,7 +15,7 @@ */ import { execSync, execFileSync } from 'node:child_process' -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { mkdtempSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -112,15 +112,25 @@ try { env: { ...process.env, NODE_OPTIONS: '' }, }) smokePassed = true -} catch (err) { +} catch { + // execFileSync throws on non-zero exit; the child's stderr is already + // forwarded via stdio:'inherit' above, so no additional logging needed. console.error('\nB2 SMOKE FAIL — Node ESM import failed (see above)') } finally { - // Always clean up tarball - try { execSync(`rm -f "${tarballPath}"`) } catch (_) {} + // Always clean up tarball; ignore errors (e.g. already deleted) + try { + execSync(`rm -f "${tarballPath}"`) + } catch { + // intentional: cleanup failure must not mask smoke failure + } } // Clean up tmp dir -try { execSync(`rm -rf "${tmpDir}"`) } catch (_) {} +try { + execSync(`rm -rf "${tmpDir}"`) +} catch { + // intentional: cleanup failure must not mask smoke failure +} if (!smokePassed) process.exit(1) diff --git a/packages/codec/src/index.node.ts b/packages/codec/src/index.node.ts index 4901537..9f5ca4a 100644 --- a/packages/codec/src/index.node.ts +++ b/packages/codec/src/index.node.ts @@ -8,20 +8,26 @@ * * The bundler entry (index.ts / pkg/) remains available via the `import` * export condition for bundler consumers (e.g. vl/app via webpack/turbopack). + * + * Wire logic lives in wire.ts (shared with index.ts). The only difference + * from index.ts is how brotli-wasm is loaded: createRequire forces the + * `require` export condition → index.node.js (fs-based, no fetch). The ESM + * `import` condition → index.web.js which calls fetch() and fails in Node. */ -import { createRequire } from 'node:module' +import { createRequire } from 'module' import type { BrotliWasmType } from 'brotli-wasm' import type { Invoice } from '@void-layer/types' +import { encodeWire, decodeWire } from './wire.js' -// createRequire lets ESM load CJS modules — necessary because wasm-pack -// --target nodejs emits CommonJS (exports.xxx + require('fs') + __dirname). +// createRequire lets ESM load CJS modules: +// - wasm-pack --target nodejs glue (exports.xxx + require('fs') + __dirname) +// - brotli-wasm: `require` condition → index.node.js (fs-based, no fetch) const require = createRequire(import.meta.url) -// eslint-disable-next-line @typescript-eslint/no-explicit-any const nodeWasm = require('../pkg-node/void_layer_codec.js') as { - encodeInvoiceCanonical: (invoice: unknown) => Uint8Array - decodeInvoiceCanonical: (bytes: Uint8Array) => unknown + encodeInvoiceCanonical: (invoice: Invoice) => Uint8Array + decodeInvoiceCanonical: (bytes: Uint8Array) => Invoice receiptHash: (canonical_bytes: Uint8Array) => Uint8Array } @@ -30,16 +36,9 @@ export const decodeInvoiceCanonical = nodeWasm.decodeInvoiceCanonical export const receiptHash = nodeWasm.receiptHash // --------------------------------------------------------------------------- -// Brotli lazy init — mirrors index.ts exactly +// Brotli lazy init — uses require to force `require` condition (no fetch) // --------------------------------------------------------------------------- -const COMPRESSED_FLAG = 0x80 -const MAX_DECOMPRESSED_BYTES = 262144 - -// brotli-wasm exports map: `require` condition → index.node.js (fs + sync WASM init, no fetch). -// ESM `import` condition → index.web.js which uses fetch() and fails in plain Node. -// We must use createRequire to force the `require` condition. -// eslint-disable-next-line @typescript-eslint/no-explicit-any const brotliNodeMod = require('brotli-wasm') as { default: Promise } let _brotli: BrotliWasmType | null = null @@ -51,85 +50,10 @@ async function getBrotli(): Promise { return _brotli } -// --------------------------------------------------------------------------- -// decompressBounded — mirrors index.ts exactly -// --------------------------------------------------------------------------- - -function decompressBounded( - brotli: BrotliWasmType, - input: Uint8Array, - maxBytes: number, -): Uint8Array { - const CHUNK = maxBytes - const stream = new brotli.DecompressStream() - const chunks: Uint8Array[] = [] - let total = 0 - let inputOffset = 0 - - while (true) { - const slice = input.slice(inputOffset) - const result = stream.decompress(slice, CHUNK) - inputOffset += result.input_offset - - if (result.buf.length === 0 && result.input_offset === 0) { - throw new Error('truncated or corrupt brotli stream (no progress)') - } - - if (result.buf.length > 0) { - total += result.buf.length - if (total > maxBytes) { - throw new Error( - `decompressed wire body exceeds MAX_DECOMPRESSED_BYTES (${maxBytes})`, - ) - } - chunks.push(result.buf) - } - - if (result.code === 0) break - if (result.code === 1) break - } - - const out = new Uint8Array(total) - let pos = 0 - for (const chunk of chunks) { - out.set(chunk, pos) - pos += chunk.length - } - return out -} - -// --------------------------------------------------------------------------- -// Wire encode / decode — mirrors index.ts exactly -// --------------------------------------------------------------------------- - export async function encodeInvoiceWire(invoice: Invoice): Promise { - const canonical: Uint8Array = encodeInvoiceCanonical(invoice) - const brotli = await getBrotli() - const body = canonical.slice(2) - const compressed = brotli.compress(body, { quality: 11 }) - - if (compressed.length >= body.length) return canonical - - const result = new Uint8Array(2 + compressed.length) - result[0] = canonical[0]! - result[1] = canonical[1]! | COMPRESSED_FLAG - result.set(compressed, 2) - return result + return encodeWire(invoice, encodeInvoiceCanonical, getBrotli) } export async function decodeInvoiceWire(bytes: Uint8Array): Promise { - if (bytes.length < 3 || !(bytes[1]! & COMPRESSED_FLAG)) { - return decodeInvoiceCanonical(bytes) as Invoice - } - - const brotli = await getBrotli() - const compressedBody = bytes.slice(2) - const decompressed = decompressBounded(brotli, compressedBody, MAX_DECOMPRESSED_BYTES) - - const canonical = new Uint8Array(2 + decompressed.length) - canonical[0] = bytes[0]! - canonical[1] = bytes[1]! & 0x7f - canonical.set(decompressed, 2) - - return decodeInvoiceCanonical(canonical) as Invoice + return decodeWire(bytes, decodeInvoiceCanonical, getBrotli) } diff --git a/packages/codec/src/index.ts b/packages/codec/src/index.ts index 83297a0..178094f 100644 --- a/packages/codec/src/index.ts +++ b/packages/codec/src/index.ts @@ -1,17 +1,19 @@ /** - * @void-layer/codec JS shim — public entry point. + * @void-layer/codec JS shim — public entry point (bundler / ESM). * * Exposes 5 functions: * - encodeInvoiceCanonical / decodeInvoiceCanonical (WASM canonical, no Brotli) * - receiptHash (keccak-256 of canonical bytes) * - encodeInvoiceWire / decodeInvoiceWire (Brotli-compressed wire format) * - * Brotli compression is handled here via `brotli-wasm` peerDependency. - * COMPRESSED_FLAG logic mirrors vl/app/src/shared/lib/tlv-codec/compress.ts §compressPayload. + * Wire logic lives in wire.ts (shared with index.node.ts). The only difference + * between this entry and index.node.ts is how brotli-wasm is loaded: + * ESM dynamic import() here vs createRequire (require condition) in index.node.ts. */ import type { BrotliWasmType } from 'brotli-wasm' import type { Invoice } from '@void-layer/types' +import { encodeWire, decodeWire } from './wire.js' // Import the canonical WASM functions for use in the wire shim below, and // re-export them as part of the public API. @@ -27,19 +29,6 @@ export { encodeInvoiceCanonical, decodeInvoiceCanonical, receiptHash } // Brotli lazy init (mirrors compressPayload reference pattern) // --------------------------------------------------------------------------- -const COMPRESSED_FLAG = 0x80 - -/** - * Hard cap on the size of a Brotli-decompressed wire body. A small (~1 KB) - * compressed payload can otherwise expand to hundreds of MB — a decompression - * bomb that OOMs the client. - * - * = MAX_TLV_COUNT(64) * MAX_VALUE_SIZE(4096) — must accept any valid canonical payload. - * A valid invoice is bounded well below the ~2 KB URL budget in practice; - * this cap exists to reject decompression bombs, not to restrict valid payloads. - */ -const MAX_DECOMPRESSED_BYTES = 262144 - let _brotli: BrotliWasmType | null = null async function getBrotli(): Promise { @@ -51,127 +40,10 @@ async function getBrotli(): Promise { return _brotli } -// --------------------------------------------------------------------------- -// Wire encode — MAGIC + (VERSION | COMPRESSED_FLAG) + brotli(body) -// Falls back to uncompressed if Brotli expands the payload. -// -// Input: invoice object (same shape as encodeInvoiceCanonical) -// Output: [MAGIC][VERSION | 0x80][brotli([COUNT][TLV records...])] -// OR uncompressed canonical bytes if Brotli would expand. -// -// Mirrors: compressPayload() in tlv-codec/compress.ts -// --------------------------------------------------------------------------- - export async function encodeInvoiceWire(invoice: Invoice): Promise { - // encodeInvoiceCanonical is statically re-exported above — no dynamic import. - const canonical: Uint8Array = encodeInvoiceCanonical(invoice) - - const brotli = await getBrotli() - const body = canonical.slice(2) // [COUNT][TLV records...] - const compressed = brotli.compress(body, { quality: 11 }) - - if (compressed.length >= body.length) return canonical - - const result = new Uint8Array(2 + compressed.length) - result[0] = canonical[0]! // MAGIC - result[1] = canonical[1]! | COMPRESSED_FLAG // VERSION | 0x80 - result.set(compressed, 2) - return result -} - -// --------------------------------------------------------------------------- -// Wire decode — detects COMPRESSED_FLAG and decompresses if set. -// Accepts both compressed wire bytes and uncompressed canonical bytes. -// -// Mirrors: decompressPayload() in tlv-codec/compress.ts -// --------------------------------------------------------------------------- - -/** - * Bounded streaming Brotli decompression. - * - * Uses `DecompressStream` to decompress in chunks of `chunkSize` bytes, - * checking the accumulated total BEFORE appending each chunk. Aborts as soon - * as `total > MAX_DECOMPRESSED_BYTES` — the bomb never fully materialises in - * memory. - */ -function decompressBounded( - brotli: BrotliWasmType, - input: Uint8Array, - maxBytes: number, -): Uint8Array { - // Output chunk size: use the cap itself as the chunk size so we can detect - // overrun in a single iteration for valid payloads, while still catching - // multi-chunk bombs on the second iteration. - const CHUNK = maxBytes - const stream = new brotli.DecompressStream() - const chunks: Uint8Array[] = [] - let total = 0 - let inputOffset = 0 - - // Feed all input; loop over output chunks. - // BrotliStreamResultCode: ResultSuccess=0, NeedsMoreInput=1, NeedsMoreOutput=2 - // The brotli-wasm DecompressStream API: corrupt input throws synchronously. - // code=1 (NeedsMoreInput) with all input consumed = terminal success state. - // code=2 (NeedsMoreOutput) = more output available; loop with same/empty input. - while (true) { - const slice = input.slice(inputOffset) - const result = stream.decompress(slice, CHUNK) - inputOffset += result.input_offset - - if (result.buf.length === 0 && result.input_offset === 0) { - throw new Error('truncated or corrupt brotli stream (no progress)') - } - - if (result.buf.length > 0) { - total += result.buf.length - // Check BEFORE accumulating this chunk — bomb guard fires here. - if (total > maxBytes) { - throw new Error( - `decompressed wire body exceeds MAX_DECOMPRESSED_BYTES (${maxBytes})`, - ) - } - chunks.push(result.buf) - } - - // code=0 (ResultSuccess) — stream fully closed. - if (result.code === 0) break - - // code=1 (NeedsMoreInput) — all input consumed; this is the normal terminal - // state for a single-chunk decompress (ResultSuccess is only emitted when - // the underlying Brotli stream closes, which may not happen here). - if (result.code === 1) break - - // code=2 (NeedsMoreOutput) — continue the loop to drain more output chunks. - } - - // Concatenate all chunks into a single Uint8Array. - const out = new Uint8Array(total) - let pos = 0 - for (const chunk of chunks) { - out.set(chunk, pos) - pos += chunk.length - } - return out + return encodeWire(invoice, encodeInvoiceCanonical, getBrotli) } export async function decodeInvoiceWire(bytes: Uint8Array): Promise { - // decodeInvoiceCanonical is statically re-exported above — no dynamic import. - if (bytes.length < 3 || !(bytes[1]! & COMPRESSED_FLAG)) { - return decodeInvoiceCanonical(bytes) - } - - const brotli = await getBrotli() - const compressedBody = bytes.slice(2) - - // Decompression-bomb guard: streaming bounded decompress — the check fires - // INSIDE the loop before each chunk is accumulated, so the bomb never fully - // allocates. JS Error (not CodecError — this is the JS shim layer). - const decompressed = decompressBounded(brotli, compressedBody, MAX_DECOMPRESSED_BYTES) - - const canonical = new Uint8Array(2 + decompressed.length) - canonical[0] = bytes[0]! // MAGIC - canonical[1] = bytes[1]! & 0x7f // VERSION without COMPRESSED_FLAG - canonical.set(decompressed, 2) - - return decodeInvoiceCanonical(canonical) + return decodeWire(bytes, decodeInvoiceCanonical, getBrotli) } diff --git a/packages/codec/src/wire.ts b/packages/codec/src/wire.ts new file mode 100644 index 0000000..9982ee9 --- /dev/null +++ b/packages/codec/src/wire.ts @@ -0,0 +1,158 @@ +/** + * Loader-agnostic wire encode/decode logic. + * + * Both entry points (index.ts bundler path, index.node.ts Node path) share + * this module. The ONLY per-entry difference is how brotli-wasm is obtained: + * callers pass a `getBrotli` factory; this module owns all wire framing logic. + * + * Security note: decompressBounded implements the truncated-stream DoS guard + * (d83cef9). Any change here applies to both entry points simultaneously — + * that is the point of this extraction. + */ + +import type { BrotliWasmType } from 'brotli-wasm' +import type { Invoice } from '@void-layer/types' + +export const COMPRESSED_FLAG = 0x80 + +/** + * Hard cap on the size of a Brotli-decompressed wire body. A small (~1 KB) + * compressed payload can otherwise expand to hundreds of MB — a decompression + * bomb that OOMs the client. + * + * = MAX_TLV_COUNT(64) * MAX_VALUE_SIZE(4096) — must accept any valid canonical payload. + * A valid invoice is bounded well below the ~2 KB URL budget in practice; + * this cap exists to reject decompression bombs, not to restrict valid payloads. + */ +export const MAX_DECOMPRESSED_BYTES = 262144 + +/** + * Bounded streaming Brotli decompression. + * + * Uses `DecompressStream` to decompress in chunks of `chunkSize` bytes, + * checking the accumulated total BEFORE appending each chunk. Aborts as soon + * as `total > MAX_DECOMPRESSED_BYTES` — the bomb never fully materialises in + * memory. + */ +export function decompressBounded( + brotli: BrotliWasmType, + input: Uint8Array, + maxBytes: number, +): Uint8Array { + // Output chunk size: use the cap itself as the chunk size so we can detect + // overrun in a single iteration for valid payloads, while still catching + // multi-chunk bombs on the second iteration. + const CHUNK = maxBytes + const stream = new brotli.DecompressStream() + const chunks: Uint8Array[] = [] + let total = 0 + let inputOffset = 0 + + // Feed all input; loop over output chunks. + // BrotliStreamResultCode: ResultSuccess=0, NeedsMoreInput=1, NeedsMoreOutput=2 + // The brotli-wasm DecompressStream API: corrupt input throws synchronously. + // code=1 (NeedsMoreInput) with all input consumed = terminal success state. + // code=2 (NeedsMoreOutput) = more output available; loop with same/empty input. + while (true) { + const slice = input.slice(inputOffset) + const result = stream.decompress(slice, CHUNK) + inputOffset += result.input_offset + + if (result.buf.length === 0 && result.input_offset === 0) { + throw new Error('truncated or corrupt brotli stream (no progress)') + } + + if (result.buf.length > 0) { + total += result.buf.length + // Check BEFORE accumulating this chunk — bomb guard fires here. + if (total > maxBytes) { + throw new Error( + `decompressed wire body exceeds MAX_DECOMPRESSED_BYTES (${maxBytes})`, + ) + } + chunks.push(result.buf) + } + + // code=0 (ResultSuccess) — stream fully closed. + if (result.code === 0) break + + // code=1 (NeedsMoreInput) — all input consumed; this is the normal terminal + // state for a single-chunk decompress (ResultSuccess is only emitted when + // the underlying Brotli stream closes, which may not happen here). + if (result.code === 1) break + + // code=2 (NeedsMoreOutput) — continue the loop to drain more output chunks. + } + + // Concatenate all chunks into a single Uint8Array. + const out = new Uint8Array(total) + let pos = 0 + for (const chunk of chunks) { + out.set(chunk, pos) + pos += chunk.length + } + return out +} + +// --------------------------------------------------------------------------- +// Wire encode — MAGIC + (VERSION | COMPRESSED_FLAG) + brotli(body) +// Falls back to uncompressed if Brotli expands the payload. +// +// Input: invoice object + per-entry WASM encode fn + getBrotli factory +// Output: [MAGIC][VERSION | 0x80][brotli([COUNT][TLV records...])] +// OR uncompressed canonical bytes if Brotli would expand. +// +// Mirrors: compressPayload() in tlv-codec/compress.ts +// --------------------------------------------------------------------------- + +export async function encodeWire( + invoice: Invoice, + encodeCanonical: (inv: Invoice) => Uint8Array, + getBrotli: () => Promise, +): Promise { + const canonical: Uint8Array = encodeCanonical(invoice) + + const brotli = await getBrotli() + const body = canonical.slice(2) // [COUNT][TLV records...] + const compressed = brotli.compress(body, { quality: 11 }) + + if (compressed.length >= body.length) return canonical + + const result = new Uint8Array(2 + compressed.length) + result[0] = canonical[0]! // MAGIC + result[1] = canonical[1]! | COMPRESSED_FLAG // VERSION | 0x80 + result.set(compressed, 2) + return result +} + +// --------------------------------------------------------------------------- +// Wire decode — detects COMPRESSED_FLAG and decompresses if set. +// Accepts both compressed wire bytes and uncompressed canonical bytes. +// +// Mirrors: decompressPayload() in tlv-codec/compress.ts +// --------------------------------------------------------------------------- + +export async function decodeWire( + bytes: Uint8Array, + decodeCanonical: (b: Uint8Array) => Invoice, + getBrotli: () => Promise, +): Promise { + if (bytes.length < 3 || !(bytes[1]! & COMPRESSED_FLAG)) { + return decodeCanonical(bytes) + } + + const brotli = await getBrotli() + const compressedBody = bytes.slice(2) + + // Decompression-bomb guard: streaming bounded decompress — the check fires + // INSIDE the loop before each chunk is accumulated, so the bomb never fully + // allocates. JS Error (not CodecError — this is the JS shim layer). + const decompressed = decompressBounded(brotli, compressedBody, MAX_DECOMPRESSED_BYTES) + + const canonical = new Uint8Array(2 + decompressed.length) + canonical[0] = bytes[0]! // MAGIC + canonical[1] = bytes[1]! & 0x7f // VERSION without COMPRESSED_FLAG + canonical.set(decompressed, 2) + + return decodeCanonical(canonical) +} From d2221cca04b79cf6bebddc726738ff4908413e3e Mon Sep 17 00:00:00 2001 From: Ignat Date: Wed, 3 Jun 2026 16:25:40 -0300 Subject: [PATCH 3/4] =?UTF-8?q?chore(deps):=20reconcile=20lockfile=20after?= =?UTF-8?q?=20@void-layer/types=20deps=E2=86=92devDeps=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2d64e3..733c758 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,10 +28,6 @@ importers: version: 8.60.1(eslint@10.4.1)(typescript@6.0.3) packages/codec: - dependencies: - '@void-layer/types': - specifier: workspace:^ - version: link:../types devDependencies: '@types/node': specifier: 25.9.1 @@ -39,6 +35,9 @@ importers: '@vitest/coverage-v8': specifier: 4.1.8 version: 4.1.8(vitest@4.1.8) + '@void-layer/types': + specifier: workspace:^ + version: link:../types brotli-wasm: specifier: ^3.0.1 version: 3.0.1 From 8bc4261617f183349dcf180c446663466b551e27 Mon Sep 17 00:00:00 2001 From: Ignat Date: Wed, 3 Jun 2026 16:27:45 -0300 Subject: [PATCH 4/4] fix(codec/ts): add types:["node"] to tsconfig for TS6 + moduleResolution:bundler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.node.ts uses createRequire from 'module' and import.meta.url — both require @types/node to resolve under moduleResolution:"bundler". TS6 enforces this more strictly. @types/node is already in devDependencies. --- packages/codec/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/codec/tsconfig.json b/packages/codec/tsconfig.json index 7196b94..87020c4 100644 --- a/packages/codec/tsconfig.json +++ b/packages/codec/tsconfig.json @@ -12,7 +12,8 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "types": ["node"] }, "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules", "pkg", "target", "src/**/*.test.ts"]