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/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..10b2dc2 --- /dev/null +++ b/packages/codec/scripts/test-pack.mjs @@ -0,0 +1,137 @@ +#!/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 } 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 { + // 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; 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 { + // intentional: cleanup failure must not mask smoke failure +} + +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..9f5ca4a --- /dev/null +++ b/packages/codec/src/index.node.ts @@ -0,0 +1,59 @@ +/** + * @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). + * + * 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 '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: +// - 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) + +const nodeWasm = require('../pkg-node/void_layer_codec.js') as { + encodeInvoiceCanonical: (invoice: Invoice) => Uint8Array + decodeInvoiceCanonical: (bytes: Uint8Array) => Invoice + receiptHash: (canonical_bytes: Uint8Array) => Uint8Array +} + +export const encodeInvoiceCanonical = nodeWasm.encodeInvoiceCanonical +export const decodeInvoiceCanonical = nodeWasm.decodeInvoiceCanonical +export const receiptHash = nodeWasm.receiptHash + +// --------------------------------------------------------------------------- +// Brotli lazy init — uses require to force `require` condition (no fetch) +// --------------------------------------------------------------------------- + +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 +} + +export async function encodeInvoiceWire(invoice: Invoice): Promise { + return encodeWire(invoice, encodeInvoiceCanonical, getBrotli) +} + +export async function decodeInvoiceWire(bytes: Uint8Array): Promise { + 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) +} 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"] 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, 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