Skip to content

Commit 5d5e403

Browse files
authored
Merge pull request #251 from zcoolz/ts-p2p-message-decoder
feat(ts-p2p): add typed decoder for the two-layer JSON wire format
2 parents 44f984a + 6e69594 commit 5d5e403

8 files changed

Lines changed: 453 additions & 7 deletions

File tree

packages/network/ts-p2p/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ All notable changes to this project will be documented in this file. The format
1010
## [Unreleased]
1111

1212
### Added
13-
- (Include new features or significant user-visible enhancements here.)
13+
- 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).
1414

1515
### Changed
1616
- (Detail modifications that are non-breaking but relevant to the end-users.)

packages/network/ts-p2p/README.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,27 @@ await listener.start();
7070
console.log('Listener started and waiting for messages...');
7171
```
7272

73+
### Decoding messages
74+
75+
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):
76+
77+
```typescript
78+
import { TeranodeListener, type BlockMessage, type DecodedMessage } from '@bsv/teranode-listener';
79+
80+
const listener = new TeranodeListener(
81+
{
82+
'bitcoin/mainnet-block': (msg: DecodedMessage<BlockMessage>, topic, from) => {
83+
console.log(`Block #${msg.payload.Height} (${msg.payload.Hash}) from ${msg.sender}`);
84+
}
85+
},
86+
{ decodeMessages: true }
87+
);
88+
89+
await listener.start();
90+
```
91+
92+
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.
93+
7394
### Function-Based API
7495

7596
Alternatively, you can use the original function-based API:
@@ -400,16 +421,30 @@ npm install
400421
npm run build
401422
```
402423

424+
### Testing
425+
426+
This package uses [Jest](https://jestjs.io/) with `ts-jest`. Run the suite with:
427+
428+
```bash
429+
npm test
430+
```
431+
432+
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.
433+
403434
### Project Structure
404435

405436
```
406437
ts-p2p/
407438
├── src/
408-
│ └── index.ts # Main library code
439+
│ ├── index.ts # Main library and listener
440+
│ └── messages.ts # Wire-format types and decoder
441+
├── test/
442+
│ └── messages.test.ts # Decoder test suite
409443
├── dist/ # Compiled JavaScript output
444+
├── jest.config.js # Jest (ts-jest) configuration
410445
├── package.json # Package configuration
411446
├── tsconfig.json # TypeScript configuration
412-
└── README.md # This file
447+
└── README.md # This file
413448
```
414449

415450
### Dependencies
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export default {
2+
preset: 'ts-jest/presets/default-esm',
3+
testEnvironment: 'node',
4+
extensionsToTreatAsEsm: ['.ts'],
5+
moduleNameMapper: {
6+
'^(\\.{1,2}/.*)\\.js$': '$1'
7+
},
8+
transform: {
9+
'^.+\\.ts$': ['ts-jest', {
10+
useESM: true,
11+
tsconfig: {
12+
module: 'ESNext',
13+
moduleResolution: 'bundler',
14+
esModuleInterop: true,
15+
allowSyntheticDefaultImports: true
16+
}
17+
}]
18+
},
19+
transformIgnorePatterns: [
20+
'node_modules/(?!(@bsv)/)'
21+
],
22+
testMatch: [
23+
'**/__tests__/**/*.test.ts',
24+
'**/?(*.)+(spec|test).ts'
25+
],
26+
collectCoverageFrom: [
27+
'src/**/*.ts',
28+
'!src/**/*.d.ts'
29+
]
30+
}

packages/network/ts-p2p/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
],
1313
"scripts": {
1414
"build": "tsc",
15-
"demo": "tsx demo.ts"
15+
"demo": "tsx demo.ts",
16+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand",
17+
"test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage"
1618
},
1719
"dependencies": {
1820
"@chainsafe/libp2p-gossipsub": "^14.1.1",
@@ -32,7 +34,11 @@
3234
"libp2p": "^3.3.4"
3335
},
3436
"devDependencies": {
37+
"@jest/globals": "^30.4.1",
38+
"@types/jest": "^30.0.0",
3539
"@types/node": "^26.0.0",
40+
"jest": "^30.4.2",
41+
"ts-jest": "^29.4.11",
3642
"tsx": "^4.22.4",
3743
"typescript": "^6.0.3",
3844
"@bsv/sdk": "workspace:^"

packages/network/ts-p2p/src/index.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,20 @@ import { multiaddr } from '@multiformats/multiaddr';
1414
import { generateKeyPair } from '@libp2p/crypto/keys';
1515
import type { PrivateKey } from '@libp2p/interface';
1616

17+
import { tryDecodeMessage, type DecodedMessage } from './messages.js';
18+
19+
// Re-export the wire-format message types and decoders for consumers.
20+
export * from './messages.js';
21+
1722
// Type definitions
1823
type MessageCallback = (data: Uint8Array, topic: Topic, from: string) => void;
1924

25+
/**
26+
* Callback that receives a fully decoded message instead of raw bytes.
27+
* Used when `decodeMessages: true` is set in the listener config.
28+
*/
29+
type DecodedMessageCallback = (message: DecodedMessage, topic: Topic, from: string) => void;
30+
2031
/**
2132
* Topic types for Teranode P2P messages
2233
*
@@ -41,7 +52,7 @@ export type Topic =
4152
'bitcoin/testnet-handshake' |
4253
'bitcoin/testnet-rejected_tx'
4354

44-
type TopicCallbacks = Partial<Record<Topic, MessageCallback>>;
55+
type TopicCallbacks = Partial<Record<Topic, MessageCallback | DecodedMessageCallback>>;
4556

4657
interface SubscriberConfig {
4758
bootstrapPeers?: string[]; // Array of bootstrap peer multiaddrs
@@ -51,6 +62,14 @@ interface SubscriberConfig {
5162
topics?: Topic[]; // Array of topics to subscribe to
5263
listenAddresses?: string[]; // Listening addresses
5364
usePrivateDHT?: boolean; // Whether to use private DHT
65+
/**
66+
* When true, raw GossipSub bytes are decoded from the two-layer JSON wire
67+
* format before being handed to callbacks. Callbacks then receive a
68+
* {@link DecodedMessage} (sender + typed payload) instead of a Uint8Array.
69+
* Frames that fail to decode (e.g. libp2p control frames) are skipped.
70+
* Defaults to false for backward compatibility.
71+
*/
72+
decodeMessages?: boolean;
5473
}
5574

5675
interface TeranodeListenerConfig extends Omit<SubscriberConfig, 'topics'> {
@@ -66,6 +85,7 @@ export class TeranodeListener {
6685
private readonly topicCallbacks: TopicCallbacks;
6786
private readonly config: TeranodeListenerConfig;
6887
private reconnectionInterval?: NodeJS.Timeout;
88+
private readonly decodeMessages: boolean;
6989

7090
/**
7191
* Creates a new TeranodeListener instance.
@@ -84,6 +104,7 @@ export class TeranodeListener {
84104
constructor(topicCallbacks: TopicCallbacks, config: TeranodeListenerConfig = {}) {
85105
this.topicCallbacks = topicCallbacks;
86106
this.config = config;
107+
this.decodeMessages = config.decodeMessages ?? false;
87108
}
88109

89110
/**
@@ -202,7 +223,7 @@ export class TeranodeListener {
202223
/**
203224
* Add a new topic callback
204225
*/
205-
addTopicCallback(topic: Topic, callback: MessageCallback): void {
226+
addTopicCallback(topic: Topic, callback: MessageCallback | DecodedMessageCallback): void {
206227
this.topicCallbacks[topic] = callback;
207228

208229
if (this.node) {
@@ -266,7 +287,17 @@ export class TeranodeListener {
266287

267288
if (callback) {
268289
try {
269-
callback(msg.data, topicKey, evt.detail.propagationSource.toString());
290+
const from = evt.detail.propagationSource.toString();
291+
if (this.decodeMessages) {
292+
// Decode the two-layer JSON wire format before dispatch. Non-JSON
293+
// frames (e.g. libp2p discovery probes) decode to null and are skipped.
294+
const decoded = tryDecodeMessage(msg.data);
295+
if (decoded) {
296+
(callback as DecodedMessageCallback)(decoded, topicKey, from);
297+
}
298+
} else {
299+
(callback as MessageCallback)(msg.data, topicKey, from);
300+
}
270301
} catch (error) {
271302
console.error(`Error in callback for topic ${topicKey}:`, error);
272303
}

0 commit comments

Comments
 (0)