Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
11 changes: 6 additions & 5 deletions packages/codec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"node": "./dist/index.node.js",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist/",
"pkg/",
"pkg-node/",
"README.md",
"LICENSE",
"REGISTRY.md",
Expand Down Expand Up @@ -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",
Expand Down
137 changes: 137 additions & 0 deletions packages/codec/scripts/test-pack.mjs
Original file line number Diff line number Diff line change
@@ -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 ===')
59 changes: 59 additions & 0 deletions packages/codec/src/index.node.ts
Original file line number Diff line number Diff line change
@@ -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<BrotliWasmType> }

let _brotli: BrotliWasmType | null = null

async function getBrotli(): Promise<BrotliWasmType> {
if (!_brotli) {
_brotli = await brotliNodeMod.default
}
return _brotli
}

export async function encodeInvoiceWire(invoice: Invoice): Promise<Uint8Array> {
return encodeWire(invoice, encodeInvoiceCanonical, getBrotli)
}

export async function decodeInvoiceWire(bytes: Uint8Array): Promise<Invoice> {
return decodeWire(bytes, decodeInvoiceCanonical, getBrotli)
}
Loading