Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/network/ts-p2p/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
39 changes: 37 additions & 2 deletions packages/network/ts-p2p/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlockMessage>, 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:
Expand Down Expand Up @@ -400,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
Expand Down
30 changes: 30 additions & 0 deletions packages/network/ts-p2p/jest.config.js
Original file line number Diff line number Diff line change
@@ -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'
]
}
8 changes: 7 additions & 1 deletion packages/network/ts-p2p/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:^"
Expand Down
37 changes: 34 additions & 3 deletions packages/network/ts-p2p/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -41,7 +52,7 @@ export type Topic =
'bitcoin/testnet-handshake' |
'bitcoin/testnet-rejected_tx'

type TopicCallbacks = Partial<Record<Topic, MessageCallback>>;
type TopicCallbacks = Partial<Record<Topic, MessageCallback | DecodedMessageCallback>>;

interface SubscriberConfig {
bootstrapPeers?: string[]; // Array of bootstrap peer multiaddrs
Expand All @@ -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<SubscriberConfig, 'topics'> {
Expand All @@ -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.
Expand All @@ -84,6 +104,7 @@ export class TeranodeListener {
constructor(topicCallbacks: TopicCallbacks, config: TeranodeListenerConfig = {}) {
this.topicCallbacks = topicCallbacks;
this.config = config;
this.decodeMessages = config.decodeMessages ?? false;
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
Loading