From 74040ae69858c868201ca6d9afdc7bd24c802f87 Mon Sep 17 00:00:00 2001 From: zcoolz Date: Tue, 30 Jun 2026 01:20:51 -0500 Subject: [PATCH 1/3] feat(ts-p2p): add typed decoder for the two-layer JSON wire format Adds optional, typed decoding of the two-layer JSON wire format Teranode broadcasts over GossipSub. New decodeMessage()/tryDecodeMessage() helpers and topic payload interfaces (BlockMessage, SubtreeMessage, RejectedTxMessage, NodeStatusMessage, FeePolicy). Opt in via decodeMessages: true; callbacks then receive a typed DecodedMessage instead of raw bytes. Backward compatible. Payload interfaces verified against teranode/services/p2p/message_types.go. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/network/ts-p2p/CHANGELOG.md | 2 +- packages/network/ts-p2p/README.md | 21 +++ packages/network/ts-p2p/src/index.ts | 37 ++++- packages/network/ts-p2p/src/messages.ts | 207 ++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 packages/network/ts-p2p/src/messages.ts diff --git a/packages/network/ts-p2p/CHANGELOG.md b/packages/network/ts-p2p/CHANGELOG.md index 122f6af13..84198cd6f 100644 --- a/packages/network/ts-p2p/CHANGELOG.md +++ b/packages/network/ts-p2p/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] ### Added -- (Include new features or significant user-visible enhancements here.) +- Optional typed decoder for the two-layer JSON wire format. New `decodeMessage()` / `tryDecodeMessage()` helpers and topic payload interfaces (`MessageEnvelope`, `BlockMessage`, `SubtreeMessage`, `RejectedTxMessage`, `NodeStatusMessage`, `FeePolicy`). Set `decodeMessages: true` on the listener to receive a typed `DecodedMessage` instead of raw `Uint8Array`. Backward compatible (defaults to off). ### Changed - (Detail modifications that are non-breaking but relevant to the end-users.) diff --git a/packages/network/ts-p2p/README.md b/packages/network/ts-p2p/README.md index 7abf17e28..73ab23af7 100644 --- a/packages/network/ts-p2p/README.md +++ b/packages/network/ts-p2p/README.md @@ -70,6 +70,27 @@ await listener.start(); console.log('Listener started and waiting for messages...'); ``` +### Decoding messages + +By default, callbacks receive the raw GossipSub bytes (`Uint8Array`). Pass `decodeMessages: true` to have the listener decode the two-layer JSON wire format for you. Callbacks then receive a typed `DecodedMessage` (the sender name plus a typed payload): + +```typescript +import { TeranodeListener, type BlockMessage, type DecodedMessage } from '@bsv/teranode-listener'; + +const listener = new TeranodeListener( + { + 'bitcoin/mainnet-block': (msg: DecodedMessage, topic, from) => { + console.log(`Block #${msg.payload.Height} (${msg.payload.Hash}) from ${msg.sender}`); + } + }, + { decodeMessages: true } +); + +await listener.start(); +``` + +The exported `decodeMessage()` / `tryDecodeMessage()` helpers can also be used to decode a message manually. Frames that are not valid JSON (e.g. libp2p control frames) are skipped when `decodeMessages` is on. + ### Function-Based API Alternatively, you can use the original function-based API: diff --git a/packages/network/ts-p2p/src/index.ts b/packages/network/ts-p2p/src/index.ts index 6ddd56708..faa0c2b78 100644 --- a/packages/network/ts-p2p/src/index.ts +++ b/packages/network/ts-p2p/src/index.ts @@ -14,9 +14,20 @@ import { multiaddr } from '@multiformats/multiaddr'; import { generateKeyPair } from '@libp2p/crypto/keys'; import type { PrivateKey } from '@libp2p/interface'; +import { tryDecodeMessage, type DecodedMessage } from './messages.js'; + +// Re-export the wire-format message types and decoders for consumers. +export * from './messages.js'; + // Type definitions type MessageCallback = (data: Uint8Array, topic: Topic, from: string) => void; +/** + * Callback that receives a fully decoded message instead of raw bytes. + * Used when `decodeMessages: true` is set in the listener config. + */ +type DecodedMessageCallback = (message: DecodedMessage, topic: Topic, from: string) => void; + /** * Topic types for Teranode P2P messages * @@ -41,7 +52,7 @@ export type Topic = 'bitcoin/testnet-handshake' | 'bitcoin/testnet-rejected_tx' -type TopicCallbacks = Partial>; +type TopicCallbacks = Partial>; interface SubscriberConfig { bootstrapPeers?: string[]; // Array of bootstrap peer multiaddrs @@ -51,6 +62,14 @@ interface SubscriberConfig { topics?: Topic[]; // Array of topics to subscribe to listenAddresses?: string[]; // Listening addresses usePrivateDHT?: boolean; // Whether to use private DHT + /** + * When true, raw GossipSub bytes are decoded from the two-layer JSON wire + * format before being handed to callbacks. Callbacks then receive a + * {@link DecodedMessage} (sender + typed payload) instead of a Uint8Array. + * Frames that fail to decode (e.g. libp2p control frames) are skipped. + * Defaults to false for backward compatibility. + */ + decodeMessages?: boolean; } interface TeranodeListenerConfig extends Omit { @@ -66,6 +85,7 @@ export class TeranodeListener { private readonly topicCallbacks: TopicCallbacks; private readonly config: TeranodeListenerConfig; private reconnectionInterval?: NodeJS.Timeout; + private readonly decodeMessages: boolean; /** * Creates a new TeranodeListener instance. @@ -84,6 +104,7 @@ export class TeranodeListener { constructor(topicCallbacks: TopicCallbacks, config: TeranodeListenerConfig = {}) { this.topicCallbacks = topicCallbacks; this.config = config; + this.decodeMessages = config.decodeMessages ?? false; } /** @@ -202,7 +223,7 @@ export class TeranodeListener { /** * Add a new topic callback */ - addTopicCallback(topic: Topic, callback: MessageCallback): void { + addTopicCallback(topic: Topic, callback: MessageCallback | DecodedMessageCallback): void { this.topicCallbacks[topic] = callback; if (this.node) { @@ -266,7 +287,17 @@ export class TeranodeListener { if (callback) { try { - callback(msg.data, topicKey, evt.detail.propagationSource.toString()); + const from = evt.detail.propagationSource.toString(); + if (this.decodeMessages) { + // Decode the two-layer JSON wire format before dispatch. Non-JSON + // frames (e.g. libp2p discovery probes) decode to null and are skipped. + const decoded = tryDecodeMessage(msg.data); + if (decoded) { + (callback as DecodedMessageCallback)(decoded, topicKey, from); + } + } else { + (callback as MessageCallback)(msg.data, topicKey, from); + } } catch (error) { console.error(`Error in callback for topic ${topicKey}:`, error); } diff --git a/packages/network/ts-p2p/src/messages.ts b/packages/network/ts-p2p/src/messages.ts new file mode 100644 index 000000000..b5fe7a7a3 --- /dev/null +++ b/packages/network/ts-p2p/src/messages.ts @@ -0,0 +1,207 @@ +/** + * Teranode P2P message types and decoder. + * + * Messages arrive on the GossipSub wire in a two-layer JSON format: + * + * Layer 1 (Message Bus Envelope): + * { "name": "", "data": "" } + * + * Layer 2 (Topic Payload): + * JSON object whose schema depends on the topic. + * + * The envelope format comes from go-p2p-message-bus (client.go). + * The payload schemas come from teranode/services/p2p (message_types.go). + * + * Key quirk: block/subtree/rejected-tx use PascalCase keys (no Go json tags), + * while node_status uses snake_case keys (explicit Go json tags). + */ + +// --------------------------------------------------------------------------- +// Message Bus Envelope (Layer 1) +// --------------------------------------------------------------------------- + +/** Outer envelope wrapping every GossipSub message. */ +export interface MessageEnvelope { + /** Sender node name (e.g. "GorillaPool-mainnet-1"). */ + name: string + /** Base64-encoded inner JSON payload. */ + data: string +} + +// --------------------------------------------------------------------------- +// Topic Payloads (Layer 2) — PascalCase topics +// --------------------------------------------------------------------------- + +/** Block announcement from a miner. */ +export interface BlockMessage { + PeerID: string + ClientName: string + DataHubURL: string + Hash: string + Height: number + Header: string + Coinbase: string +} + +/** Subtree (transaction batch) from a miner. */ +export interface SubtreeMessage { + PeerID: string + ClientName: string + DataHubURL: string + Hash: string +} + +/** Rejected transaction notification. */ +export interface RejectedTxMessage { + PeerID: string + ClientName: string + TxID: string + Reason: string +} + +// --------------------------------------------------------------------------- +// Fee policy (carried inside node_status; snake_case via Go json tags) +// --------------------------------------------------------------------------- + +/** A fee rate expressed as satoshis per number of bytes. */ +export interface FeeAmount { + satoshis: number + bytes: number +} + +/** Full fee policy a node advertises to peers. */ +export interface FeePolicy { + miningFee: FeeAmount + maxscriptsizepolicy: number + maxtxsizepolicy: number + maxtxsigopscountspolicy: number +} + +// --------------------------------------------------------------------------- +// Topic Payload — snake_case (node_status has explicit Go json tags) +// --------------------------------------------------------------------------- + +/** Node status broadcast from a Teranode peer. */ +export interface NodeStatusMessage { + peer_id: string + client_name: string + type: string + base_url: string + propagation_url?: string + version: string + commit_hash: string + best_block_hash: string + best_height: number + tx_count?: number + subtree_count?: number + fsm_state: string + start_time: number + uptime: number + miner_name: string + listen_mode: string + chain_work: string + sync_peer_id?: string + sync_peer_height?: number + sync_peer_block_hash?: string + sync_connected_at?: number + min_mining_tx_fee?: number | null + /** Full fee policy advertised to peers (omitted by older peers). */ + fee_policy?: FeePolicy + connected_peers_count?: number + storage?: string +} + +// --------------------------------------------------------------------------- +// Union type for any decoded message +// --------------------------------------------------------------------------- + +export type TeranodeMessage = + | BlockMessage + | SubtreeMessage + | RejectedTxMessage + | NodeStatusMessage + +// --------------------------------------------------------------------------- +// Decoded result (envelope + typed payload) +// --------------------------------------------------------------------------- + +/** Fully decoded message: envelope metadata + typed inner payload. */ +export interface DecodedMessage { + /** Sender node name from the envelope. */ + sender: string + /** Decoded inner payload, typed per topic. */ + payload: T +} + +// --------------------------------------------------------------------------- +// Decoder +// --------------------------------------------------------------------------- + +const decoder = new TextDecoder() + +/** + * Decode a raw GossipSub message (Uint8Array) into a typed object. + * + * Two-layer decode: + * 1. UTF-8 decode bytes -> JSON.parse -> MessageEnvelope { name, data } + * 2. Base64 decode envelope.data -> JSON.parse -> topic-specific payload + * + * @param data - Raw bytes from the GossipSub callback + * @returns DecodedMessage with sender name and typed payload + * @throws If the bytes are not valid two-layer JSON + */ +export function decodeMessage(data: Uint8Array): DecodedMessage { + const text = decoder.decode(data) + const envelope: MessageEnvelope = JSON.parse(text) + + // Decode the base64 inner payload + const innerBytes = base64ToBytes(envelope.data) + const innerText = decoder.decode(innerBytes) + const payload: T = JSON.parse(innerText) + + return { sender: envelope.name, payload } +} + +/** + * Try to decode a message, returning null on failure instead of throwing. + * Useful for high-volume topics (subtrees) where parse failures shouldn't crash, + * and for skipping non-JSON frames such as libp2p discovery probes. + */ +export function tryDecodeMessage(data: Uint8Array): DecodedMessage | null { + try { + return decodeMessage(data) + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// Base64 helpers (no Node.js dependency — works in any JS runtime) +// --------------------------------------------------------------------------- + +/** Standard base64 alphabet lookup table, built once. */ +const B64: Record = {} +const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +for (let i = 0; i < alphabet.length; i++) B64[alphabet[i]] = i + +/** Decode a base64 string to Uint8Array without depending on Buffer or atob. */ +function base64ToBytes(b64: string): Uint8Array { + // Strip padding + const clean = b64.replace(/=+$/, '') + const len = (clean.length * 3) >>> 2 + const out = new Uint8Array(len) + let pos = 0 + + for (let i = 0; i < clean.length; i += 4) { + const a = B64[clean[i]] ?? 0 + const b = B64[clean[i + 1]] ?? 0 + const c = B64[clean[i + 2]] ?? 0 + const d = B64[clean[i + 3]] ?? 0 + const bits = (a << 18) | (b << 12) | (c << 6) | d + if (pos < len) out[pos++] = (bits >>> 16) & 0xff + if (pos < len) out[pos++] = (bits >>> 8) & 0xff + if (pos < len) out[pos++] = bits & 0xff + } + + return out +} From 09527137241a6f07aac6f32969b5695d89015596 Mon Sep 17 00:00:00 2001 From: zcoolz Date: Tue, 30 Jun 2026 02:42:28 -0500 Subject: [PATCH 2/3] fix(ts-p2p): strip base64 padding without a backtracking-prone regex Resolves the SonarCloud S8786 super-linear-regex finding on messages.ts. Replaces the trailing-padding regex with a plain trailing-'=' scan: provably linear and behavior-identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/network/ts-p2p/src/messages.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/network/ts-p2p/src/messages.ts b/packages/network/ts-p2p/src/messages.ts index b5fe7a7a3..2fb2f974b 100644 --- a/packages/network/ts-p2p/src/messages.ts +++ b/packages/network/ts-p2p/src/messages.ts @@ -186,8 +186,10 @@ for (let i = 0; i < alphabet.length; i++) B64[alphabet[i]] = i /** Decode a base64 string to Uint8Array without depending on Buffer or atob. */ function base64ToBytes(b64: string): Uint8Array { - // Strip padding - const clean = b64.replace(/=+$/, '') + // Strip trailing '=' padding (plain scan, no backtracking-prone regex) + let end = b64.length + while (end > 0 && b64[end - 1] === '=') end-- + const clean = b64.slice(0, end) const len = (clean.length * 3) >>> 2 const out = new Uint8Array(len) let pos = 0 From 6e69594d31b3796de3d12b4c86b0a08b890679db Mon Sep 17 00:00:00 2001 From: zcoolz Date: Tue, 30 Jun 2026 03:40:53 -0500 Subject: [PATCH 3/3] test(ts-p2p): add Jest suite for the two-layer JSON decoder Wire up the package's first test harness (ESM ts-jest, mirroring the overlays/topics config) and cover decodeMessage/tryDecodeMessage: PascalCase block and snake_case node_status payloads, nested fee_policy, multi-byte UTF-8, all base64 padding lengths, malformed input, and the null-on-bad-frame paths. Adds the test/test:coverage scripts plus jest, ts-jest, @jest/globals and @types/jest dev deps, a README testing section and project-structure update, and the ts-p2p importer entries in the lockfile. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/network/ts-p2p/README.md | 18 ++- packages/network/ts-p2p/jest.config.js | 30 +++++ packages/network/ts-p2p/package.json | 8 +- packages/network/ts-p2p/test/messages.test.ts | 123 ++++++++++++++++++ pnpm-lock.yaml | 12 ++ 5 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 packages/network/ts-p2p/jest.config.js create mode 100644 packages/network/ts-p2p/test/messages.test.ts diff --git a/packages/network/ts-p2p/README.md b/packages/network/ts-p2p/README.md index 73ab23af7..80502caba 100644 --- a/packages/network/ts-p2p/README.md +++ b/packages/network/ts-p2p/README.md @@ -421,16 +421,30 @@ npm install npm run build ``` +### Testing + +This package uses [Jest](https://jestjs.io/) with `ts-jest`. Run the suite with: + +```bash +npm test +``` + +The tests exercise the message decoder (`decodeMessage` / `tryDecodeMessage`) end to end, building real two-layer wire frames and decoding them back: the PascalCase (block) and snake_case (node_status) payload shapes, multi-byte UTF-8, every base64 padding length, and the malformed / non-JSON frame paths. + ### Project Structure ``` ts-p2p/ ├── src/ -│ └── index.ts # Main library code +│ ├── index.ts # Main library and listener +│ └── messages.ts # Wire-format types and decoder +├── test/ +│ └── messages.test.ts # Decoder test suite ├── dist/ # Compiled JavaScript output +├── jest.config.js # Jest (ts-jest) configuration ├── package.json # Package configuration ├── tsconfig.json # TypeScript configuration -└── README.md # This file +└── README.md # This file ``` ### Dependencies diff --git a/packages/network/ts-p2p/jest.config.js b/packages/network/ts-p2p/jest.config.js new file mode 100644 index 000000000..58a3685d9 --- /dev/null +++ b/packages/network/ts-p2p/jest.config.js @@ -0,0 +1,30 @@ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + }, + transform: { + '^.+\\.ts$': ['ts-jest', { + useESM: true, + tsconfig: { + module: 'ESNext', + moduleResolution: 'bundler', + esModuleInterop: true, + allowSyntheticDefaultImports: true + } + }] + }, + transformIgnorePatterns: [ + 'node_modules/(?!(@bsv)/)' + ], + testMatch: [ + '**/__tests__/**/*.test.ts', + '**/?(*.)+(spec|test).ts' + ], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts' + ] +} diff --git a/packages/network/ts-p2p/package.json b/packages/network/ts-p2p/package.json index 431b5d125..9ca77b8e3 100644 --- a/packages/network/ts-p2p/package.json +++ b/packages/network/ts-p2p/package.json @@ -12,7 +12,9 @@ ], "scripts": { "build": "tsc", - "demo": "tsx demo.ts" + "demo": "tsx demo.ts", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage" }, "dependencies": { "@chainsafe/libp2p-gossipsub": "^14.1.1", @@ -32,7 +34,11 @@ "libp2p": "^3.3.4" }, "devDependencies": { + "@jest/globals": "^30.4.1", + "@types/jest": "^30.0.0", "@types/node": "^26.0.0", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", "tsx": "^4.22.4", "typescript": "^6.0.3", "@bsv/sdk": "workspace:^" diff --git a/packages/network/ts-p2p/test/messages.test.ts b/packages/network/ts-p2p/test/messages.test.ts new file mode 100644 index 000000000..e4fdbfdef --- /dev/null +++ b/packages/network/ts-p2p/test/messages.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from '@jest/globals' +import { + decodeMessage, + tryDecodeMessage, + type BlockMessage, + type NodeStatusMessage +} from '../src/messages.js' + +const textEncoder = new TextEncoder() + +/** + * Build a raw GossipSub frame exactly the way the message bus does: + * inner payload JSON -> base64 -> { name, data } envelope JSON -> UTF-8 bytes. + * + * Encoding here uses Node's Buffer (Go-compatible standard base64) so the read + * side genuinely exercises the package's own hand-rolled base64 decoder rather + * than round-tripping through the same implementation. + */ +function frame (sender: string, payload: unknown): Uint8Array { + const data = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') + return textEncoder.encode(JSON.stringify({ name: sender, data })) +} + +const blockPayload: BlockMessage = { + PeerID: '12D3KooWBlockPeer', + ClientName: 'GorillaPool-mainnet-1', + DataHubURL: 'https://datahub.example/mainnet', + Hash: '0000000000000000041f0a9c2b3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f', + Height: 866000, + Header: 'deadbeef', + Coinbase: 'cafebabe' +} + +const nodeStatusPayload: NodeStatusMessage = { + peer_id: '12D3KooWStatusPeer', + client_name: 'TeraNode-Ünïcode-miner', + type: 'node_status', + base_url: 'https://node.example', + version: '0.5.1', + commit_hash: 'abc1234', + best_block_hash: '0000000000000000abc', + best_height: 866001, + fsm_state: 'RUNNING', + start_time: 1751000000, + uptime: 12345, + miner_name: 'TeraMiner', + listen_mode: 'full', + chain_work: '00000000000000000000000000000001', + fee_policy: { + miningFee: { satoshis: 1, bytes: 1000 }, + maxscriptsizepolicy: 100000000, + maxtxsizepolicy: 1000000000, + maxtxsigopscountspolicy: 4294967295 + } +} + +describe('decodeMessage', () => { + it('decodes a PascalCase block message into sender + typed payload', () => { + const decoded = decodeMessage(frame('miner-A', blockPayload)) + expect(decoded.sender).toBe('miner-A') + expect(decoded.payload).toEqual(blockPayload) + expect(decoded.payload.Height).toBe(866000) + }) + + it('decodes a snake_case node_status message including the nested fee_policy', () => { + const decoded = decodeMessage(frame('miner-B', nodeStatusPayload)) + expect(decoded.sender).toBe('miner-B') + expect(decoded.payload).toEqual(nodeStatusPayload) + expect(decoded.payload.fee_policy?.miningFee.satoshis).toBe(1) + }) + + it('preserves multi-byte UTF-8 content through the base64 and utf8 round trip', () => { + const decoded = decodeMessage(frame('miner-B', nodeStatusPayload)) + expect(decoded.payload.client_name).toBe('TeraNode-Ünïcode-miner') + }) + + it('decodes correctly across all base64 padding lengths', () => { + // Stepping the inner length by one byte each time walks the byte length + // through every value mod 3, so the encoder emits 0, 1 and 2 trailing '=' + // chars. This directly exercises the trailing-padding scan in base64ToBytes. + const paddingsSeen = new Set() + for (let n = 0; n < 6; n++) { + const payload = { + PeerID: 'p', + ClientName: 'c', + DataHubURL: 'd', + Hash: 'x'.repeat(n), + Height: n, + Header: '', + Coinbase: '' + } + const data = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') + paddingsSeen.add(data.length - data.replace(/=/g, '').length) + const decoded = decodeMessage(frame('len-' + n, payload)) + expect(decoded.payload).toEqual(payload) + } + expect(paddingsSeen.has(0)).toBe(true) + expect(paddingsSeen.has(1)).toBe(true) + expect(paddingsSeen.has(2)).toBe(true) + }) + + it('throws on a malformed outer envelope', () => { + const notJson = textEncoder.encode('this is not json {') + expect(() => decodeMessage(notJson)).toThrow() + }) +}) + +describe('tryDecodeMessage', () => { + it('returns the same result as decodeMessage for a valid frame', () => { + const bytes = frame('miner-A', blockPayload) + expect(tryDecodeMessage(bytes)).toEqual(decodeMessage(bytes)) + }) + + it('returns null instead of throwing on a non-JSON control frame', () => { + const controlFrame = Uint8Array.from([0x13, 0x37, 0x00, 0xff, 0x42]) + expect(tryDecodeMessage(controlFrame)).toBeNull() + }) + + it('returns null when the envelope carries an empty data payload', () => { + const emptyData = textEncoder.encode(JSON.stringify({ name: 'x', data: '' })) + expect(tryDecodeMessage(emptyData)).toBeNull() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79c9248e4..9bd30c982 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -812,9 +812,21 @@ importers: '@bsv/sdk': specifier: 2.1.3 version: 2.1.3 + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 '@types/node': specifier: ^26.0.0 version: 26.0.0 + jest: + specifier: ^30.4.2 + version: 30.4.2(@types/node@26.0.0)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@26.0.0)(typescript@6.0.3)) + ts-jest: + specifier: ^29.4.11 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.27.7)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.0.0)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@26.0.0)(typescript@6.0.3)))(typescript@6.0.3) tsx: specifier: ^4.22.4 version: 4.22.4