@nostrwatch/negentropy 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Sandwich Farm LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # @nostrwatch/negentropy-utils
2
+
3
+ Utilities for [NIP-77](https://github.com/nostr-protocol/nips/blob/master/77.md) Negentropy set reconciliation between Nostr clients and relays.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@nostrwatch/negentropy-utils?style=flat-square&label=npm)](https://www.npmjs.com/package/@nostrwatch/negentropy-utils)
6
+ [![License](https://img.shields.io/github/license/sandwichfarm/nostr-watch?style=flat-square)](LICENSE)
7
+ [![Status](https://img.shields.io/badge/status-alpha-orange?style=flat-square)](https://github.com/sandwichfarm/nostr-watch)
8
+ [![Runtime](https://img.shields.io/badge/runtime-node%20%7C%20browser-blue?style=flat-square)](https://github.com/sandwichfarm/nostr-watch)
9
+
10
+ ## Overview
11
+
12
+ `@nostrwatch/negentropy-utils` implements the [NIP-77](https://github.com/nostr-protocol/nips/blob/master/77.md) Negentropy protocol — a set reconciliation algorithm that efficiently identifies which Nostr events (signed JSON objects that are the fundamental unit of the Nostr protocol) a client and relay do not have in common. Instead of transferring full event lists, Negentropy uses fingerprint-based range comparisons to minimize data transferred during sync. The package provides `ClientHandler` for use in Nostr clients and `ServerHandler` for use in relay implementations, both operating over a pluggable `Transport` interface.
13
+
14
+ ## Prerequisites
15
+
16
+ Node.js >=20 and pnpm >=9.
17
+
18
+ ## Installation
19
+
20
+ ```sh
21
+ pnpm add @nostrwatch/negentropy-utils
22
+ ```
23
+
24
+ Or with npm:
25
+
26
+ ```sh
27
+ npm install @nostrwatch/negentropy-utils
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```ts
33
+ import {ClientHandler} from '@nostrwatch/negentropy-utils'
34
+ import {WebSocketTransport} from '@nostrwatch/negentropy-utils/transports/WebSocket'
35
+ import type {RecordItem} from '@nostrwatch/negentropy-utils'
36
+
37
+ // Local event records (timestamp + 32-byte ID)
38
+ const localRecords: RecordItem[] = [
39
+ {timestamp: BigInt(1700000000), id: new Uint8Array(32).fill(1)},
40
+ {timestamp: BigInt(1700000001), id: new Uint8Array(32).fill(2)}
41
+ ]
42
+
43
+ const ws = new WebSocket('wss://relay.damus.io')
44
+ const transport = new WebSocketTransport(ws)
45
+
46
+ // Subscribe to kind 1 events and start sync
47
+ const client = new ClientHandler(localRecords, transport, {kinds: [1]}, 'sync-1')
48
+
49
+ ws.onopen = () => {
50
+ client.startSync()
51
+ // Sends NEG-OPEN to the relay and begins reconciliation
52
+ }
53
+ ```
54
+
55
+ ## API
56
+
57
+ ### `ClientHandler`
58
+
59
+ Used on the client side to synchronize a local event set with a relay.
60
+
61
+ ```ts
62
+ class ClientHandler {
63
+ constructor(
64
+ records: RecordItem[],
65
+ transport: Transport,
66
+ filter: object,
67
+ subscriptionId: string
68
+ )
69
+ startSync(): void
70
+ handleMessage(message: string): void
71
+ }
72
+ ```
73
+
74
+ **`constructor(records, transport, filter, subscriptionId)`**
75
+
76
+ | Parameter | Type | Description |
77
+ |-----------|------|-------------|
78
+ | `records` | `RecordItem[]` | Local event records to synchronize |
79
+ | `transport` | `Transport` | Message transport (e.g., `WebSocketTransport`) |
80
+ | `filter` | `object` | Nostr filter (e.g., `{kinds: [1]}`) scoping which events to sync |
81
+ | `subscriptionId` | `string` | Unique identifier for this sync session |
82
+
83
+ **`startSync()`**
84
+
85
+ Initiates the NIP-77 handshake by sending a `NEG-OPEN` message to the relay. Call this after the transport connection is open.
86
+
87
+ **`handleMessage(message: string)`**
88
+
89
+ Processes an incoming relay message (`NEG-MSG`, `NEG-ERR`, or `NEG-CLOSE`). The `Transport` implementation calls this automatically via `onMessage`.
90
+
91
+ ### `ServerHandler`
92
+
93
+ Used on the relay side to respond to client sync requests.
94
+
95
+ ```ts
96
+ class ServerHandler {
97
+ constructor(
98
+ records: RecordItem[],
99
+ transport: Transport,
100
+ applyFilter: (records: RecordItem[], filter: object) => Promise<RecordItem[]>
101
+ )
102
+ handleMessage(message: string): Promise<void>
103
+ }
104
+ ```
105
+
106
+ **`constructor(records, transport, applyFilter)`**
107
+
108
+ | Parameter | Type | Description |
109
+ |-----------|------|-------------|
110
+ | `records` | `RecordItem[]` | All event records stored on the relay |
111
+ | `transport` | `Transport` | Message transport connected to the client |
112
+ | `applyFilter` | `function` | Async function that filters `records` by a Nostr filter object |
113
+
114
+ **`handleMessage(message: string)`**
115
+
116
+ Handles incoming client messages (`NEG-OPEN`, `NEG-MSG`, `NEG-CLOSE`). The `Transport` implementation calls this automatically.
117
+
118
+ ### `Transport`
119
+
120
+ An abstract interface that both handlers communicate through. Implement it for any transport layer (WebSocket, stdio, etc.).
121
+
122
+ ```ts
123
+ abstract class Transport {
124
+ abstract send(message: string): void
125
+ abstract onMessage(handler: (message: string) => void): void
126
+ }
127
+ ```
128
+
129
+ ### Types
130
+
131
+ ```ts
132
+ type RecordItem = {timestamp: bigint; id: Uint8Array}
133
+
134
+ type RangeMode = 0 | 1 | 2
135
+
136
+ interface Range {
137
+ upperBound: Bound
138
+ mode: RangeMode
139
+ payload: Uint8Array
140
+ }
141
+
142
+ interface Bound {
143
+ timestampOffset: bigint
144
+ idPrefix: Uint8Array
145
+ }
146
+ ```
147
+
148
+ ## Known Limitations
149
+
150
+ No known limitations at this time.
151
+
152
+ ## Agent Skills
153
+
154
+ No agent skills defined yet for this package.
155
+
156
+ ## Related Packages
157
+
158
+ - [`@nostrwatch/worker-relay`](../../libraries/worker-relay/README.md) — in-browser relay; negentropy-utils can be used with it for client-side event sync
159
+ - [`@nostrwatch/route66`](../../libraries/route66/README.md) — relay monitoring library that uses negentropy for event set synchronization
160
+
161
+ ## License
162
+
163
+ [MIT](../../LICENSE)
package/build.js ADDED
@@ -0,0 +1,25 @@
1
+ // build.js
2
+ const esbuild = require('esbuild');
3
+
4
+ const commonOptions = {
5
+ entryPoints: ['src/index.ts'],
6
+ bundle: true,
7
+ sourcemap: true,
8
+ minify: true,
9
+ };
10
+
11
+ esbuild.build({
12
+ ...commonOptions,
13
+ platform: 'node',
14
+ target: ['node14'],
15
+ outfile: 'dist/index.node.js',
16
+ format: 'cjs',
17
+ });
18
+
19
+ esbuild.build({
20
+ ...commonOptions,
21
+ platform: 'browser',
22
+ target: ['es2020'],
23
+ outfile: 'dist/index.browser.js',
24
+ format: 'esm',
25
+ });
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=index.browser.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=index.node.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@nostrwatch/negentropy",
3
+ "version": "0.0.1",
4
+ "devDependencies": {
5
+ "@types/node": "^22.9.0",
6
+ "esbuild": "^0.25.0",
7
+ "typescript": "^5.6.3",
8
+ "vitest": "^2.1.5"
9
+ },
10
+ "scripts": {
11
+ "build": "npm run build:node && npm run build:browser",
12
+ "build:node": "esbuild src/index.ts --bundle --platform=node --target=node14 --outfile=dist/index.node.js --format=cjs --sourcemap",
13
+ "build:browser": "esbuild src/index.ts --bundle --platform=browser --target=es2020 --outfile=dist/index.browser.js --format=esm --sourcemap"
14
+ }
15
+ }
@@ -0,0 +1,34 @@
1
+ // src/ClientHandler.test.ts
2
+
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { NegentropyClient } from './ClientHandler';
5
+ import { RecordItem } from './types';
6
+
7
+ describe('NegentropyClient', () => {
8
+ describe('startSync', () => {
9
+ it('should send NEG-OPEN message with initial message', async () => {
10
+ // Arrange
11
+ const records: RecordItem[] = []; // Provide test records
12
+ const websocket = {
13
+ send: vi.fn(),
14
+ onmessage: null,
15
+ onopen: null,
16
+ };
17
+ const filter = {};
18
+ const subscriptionId = 'test-subscription';
19
+
20
+ const client = new NegentropyClient(records, websocket as any, filter, subscriptionId);
21
+
22
+ // Act
23
+ await client.startSync();
24
+
25
+ // Assert
26
+ expect(websocket.send).toHaveBeenCalled();
27
+ const sentMessage = JSON.parse(websocket.send.mock.calls[0][0]);
28
+ expect(sentMessage[0]).toBe('NEG-OPEN');
29
+ expect(sentMessage[1]).toBe(subscriptionId);
30
+ // Further assertions as needed
31
+ });
32
+ });
33
+
34
+ });
@@ -0,0 +1,145 @@
1
+ // src/ClientHandler.ts
2
+
3
+ import { Transport } from './transports/Transport';
4
+ import { hexToUint8Array, uint8ArrayToHex } from './utils/hex';
5
+ import { decodeMessage, decodeVarint, encodeMessage } from './utils/encoding';
6
+ import { compareUint8Arrays, computeFingerprint } from './utils/helpers';
7
+ import type { RecordItem, Range } from './types';
8
+
9
+ export class ClientHandler {
10
+ private records: RecordItem[];
11
+ private transport: Transport;
12
+ private subscriptionId: string;
13
+ private filter: any;
14
+ private serverRecords: RecordItem[] = [];
15
+ private clientNeedsRecords: RecordItem[] = [];
16
+
17
+ constructor(records: RecordItem[], transport: Transport, filter: any, subscriptionId: string) {
18
+ this.records = records;
19
+ this.transport = transport;
20
+ this.filter = filter;
21
+ this.subscriptionId = subscriptionId;
22
+
23
+ // Set up message handling
24
+ this.transport.onMessage((message) => this.handleMessage(message));
25
+ }
26
+
27
+ public startSync() {
28
+ const initialMessage = this.generateInitialMessage();
29
+ const messageHex = uint8ArrayToHex(initialMessage);
30
+
31
+ const negOpenMessage = [
32
+ "NEG-OPEN",
33
+ this.subscriptionId,
34
+ this.filter,
35
+ messageHex,
36
+ ];
37
+
38
+ this.transport.send(JSON.stringify(negOpenMessage));
39
+ }
40
+
41
+ public handleMessage(message: string) {
42
+ const parsed = JSON.parse(message);
43
+
44
+ const messageType = parsed[0];
45
+
46
+ switch (messageType) {
47
+ case "NEG-MSG":
48
+ this.handleNegMsg(parsed);
49
+ break;
50
+ case "NEG-ERR":
51
+ this.handleNegErr(parsed);
52
+ break;
53
+ case "NEG-CLOSE":
54
+ this.handleNegClose(parsed);
55
+ break;
56
+ default:
57
+ break;
58
+ }
59
+ }
60
+
61
+ private handleNegMsg(parsed: any[]) {
62
+ const subscriptionId = parsed[1];
63
+ const messageHex = parsed[2];
64
+ if (subscriptionId !== this.subscriptionId) return;
65
+
66
+ const messageBuffer = hexToUint8Array(messageHex);
67
+ const ranges = decodeMessage(messageBuffer);
68
+
69
+ this.processRanges(ranges);
70
+
71
+ const nextMessage = this.generateNextMessage();
72
+ if (nextMessage) {
73
+ const messageHex = uint8ArrayToHex(nextMessage);
74
+ const negMsg = ["NEG-MSG", this.subscriptionId, messageHex];
75
+ this.transport.send(JSON.stringify(negMsg));
76
+ } else {
77
+ this.sendNegClose();
78
+ }
79
+ }
80
+
81
+ private handleNegErr(parsed: any[]) {
82
+ const subscriptionId = parsed[1];
83
+ const reason = parsed[2];
84
+ if (subscriptionId !== this.subscriptionId) return;
85
+
86
+ console.error(`NEG-ERR received: ${reason}`);
87
+ }
88
+
89
+ private handleNegClose(parsed: any[]) {
90
+ const subscriptionId = parsed[1];
91
+ if (subscriptionId !== this.subscriptionId) return;
92
+
93
+ console.log(`NEG-CLOSE received for subscription ${subscriptionId}`);
94
+ }
95
+
96
+ private generateInitialMessage(): Uint8Array {
97
+ this.records.sort((a, b) => {
98
+ if (a.timestamp !== b.timestamp) {
99
+ return a.timestamp < b.timestamp ? -1 : 1;
100
+ }
101
+ return compareUint8Arrays(a.id, b.id);
102
+ });
103
+
104
+ const fingerprint = computeFingerprint(this.records);
105
+
106
+ const ranges: Range[] = [
107
+ {
108
+ upperBound: {
109
+ timestampOffset: BigInt(0),
110
+ idPrefix: new Uint8Array(),
111
+ },
112
+ mode: 1,
113
+ payload: fingerprint,
114
+ },
115
+ ];
116
+
117
+ return encodeMessage(ranges);
118
+ }
119
+
120
+ private generateNextMessage(): Uint8Array | null {
121
+ // Implement logic to generate next message
122
+ return null;
123
+ }
124
+
125
+ private processRanges(ranges: Range[]) {
126
+ for (const range of ranges) {
127
+ if (range.mode === 2) {
128
+ let offset = 0;
129
+ const lengthResult = decodeVarint(range.payload, offset);
130
+ offset += lengthResult.bytesRead;
131
+ const idCount = lengthResult.value;
132
+ for (let i = 0; i < idCount; i++) {
133
+ const id = range.payload.subarray(offset, offset + 32);
134
+ offset += 32;
135
+ this.clientNeedsRecords.push({ timestamp: BigInt(0), id });
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ private sendNegClose() {
142
+ const negCloseMessage = ["NEG-CLOSE", this.subscriptionId];
143
+ this.transport.send(JSON.stringify(negCloseMessage));
144
+ }
145
+ }
@@ -0,0 +1,36 @@
1
+ // src/ServerHandler.test.ts
2
+
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import { NegentropyServer } from './ServerHandler';
5
+ import { RecordItem } from './types';
6
+
7
+ describe('NegentropyServer', () => {
8
+ describe('handleMessage', () => {
9
+ it('should handle NEG-OPEN message and send NEG-MSG response', async () => {
10
+ // Arrange
11
+ const records: RecordItem[] = []; // Provide test records
12
+ const websocket = {
13
+ send: vi.fn(),
14
+ onmessage: null,
15
+ onopen: null,
16
+ };
17
+ const server = new NegentropyServer(records, websocket as any);
18
+
19
+ const subscriptionId = 'test-subscription';
20
+ const filter = {};
21
+ const initialMessageHex = '00'; // Provide a valid hex string
22
+
23
+ const message = JSON.stringify(['NEG-OPEN', subscriptionId, filter, initialMessageHex]);
24
+
25
+ // Act
26
+ await server.handleMessage(message);
27
+
28
+ // Assert
29
+ expect(websocket.send).toHaveBeenCalled();
30
+ const sentMessage = JSON.parse(websocket.send.mock.calls[0][0]);
31
+ expect(sentMessage[0]).toBe('NEG-MSG');
32
+ expect(sentMessage[1]).toBe(subscriptionId);
33
+ // Further assertions as needed
34
+ });
35
+ });
36
+ });
@@ -0,0 +1,165 @@
1
+ // src/ServerHandler.ts
2
+
3
+ import { Range, RecordItem } from './types';
4
+ import { decodeMessage, encodeMessage, encodeVarint } from './utils/encoding';
5
+ import { Transport } from './transports/Transport';
6
+ import { hexToUint8Array, uint8ArrayToHex } from './utils/hex';
7
+
8
+ type ApplyFilterFunction = (records: RecordItem[], filter: any) => Promise<RecordItem[]>;
9
+
10
+ export class ServerHandler {
11
+ private records: RecordItem[];
12
+ private transport: Transport;
13
+ private subscriptions: Map<string, any>;
14
+ private applyFilter: ApplyFilterFunction;
15
+
16
+ constructor(records: RecordItem[], transport: Transport, applyFilter: ApplyFilterFunction) {
17
+ this.records = records;
18
+ this.transport = transport;
19
+ this.subscriptions = new Map();
20
+ this.applyFilter = applyFilter;
21
+
22
+ // Set up message handling
23
+ this.transport.onMessage((message) => this.handleMessage(message));
24
+ }
25
+
26
+ public async handleMessage(message: string) {
27
+ const parsed = JSON.parse(message);
28
+
29
+ const messageType = parsed[0];
30
+
31
+ switch (messageType) {
32
+ case 'NEG-OPEN':
33
+ await this.handleNegOpen(parsed);
34
+ break;
35
+ case 'NEG-MSG':
36
+ await this.handleNegMsg(parsed);
37
+ break;
38
+ case 'NEG-CLOSE':
39
+ this.handleNegClose(parsed);
40
+ break;
41
+ default:
42
+ break;
43
+ }
44
+ }
45
+
46
+ private async handleNegOpen(parsed: any[]) {
47
+ const subscriptionId = parsed[1];
48
+ const filter = parsed[2];
49
+ const initialMessageHex = parsed[3];
50
+
51
+ if (this.subscriptions.has(subscriptionId)) {
52
+ this.subscriptions.delete(subscriptionId);
53
+ }
54
+
55
+ // Apply filter to server records (asynchronous)
56
+ let filteredRecords: RecordItem[];
57
+ try {
58
+ filteredRecords = await this.applyFilter(this.records, filter);
59
+ } catch (error) {
60
+ // Send NEG-ERR if filter application fails
61
+ const negErr = ['NEG-ERR', subscriptionId, 'error: failed to apply filter'];
62
+ this.transport.send(JSON.stringify(negErr));
63
+ return;
64
+ }
65
+
66
+ // Decode client's initial message
67
+ let clientRanges: Range[];
68
+ try {
69
+ const clientMessageBuffer = hexToUint8Array(initialMessageHex);
70
+ clientRanges = decodeMessage(clientMessageBuffer);
71
+ } catch (error) {
72
+ // Send NEG-ERR if message cannot be decoded
73
+ const negErr = ['NEG-ERR', subscriptionId, 'invalid: failed to decode initial message'];
74
+ this.transport.send(JSON.stringify(negErr));
75
+ return;
76
+ }
77
+
78
+ // Generate server response
79
+ const serverMessageBuffer = await this.generateResponseMessage(filteredRecords, clientRanges);
80
+ const messageHex = uint8ArrayToHex(serverMessageBuffer);
81
+
82
+ // Send NEG-MSG back to client
83
+ const negMsg = ['NEG-MSG', subscriptionId, messageHex];
84
+ this.transport.send(JSON.stringify(negMsg));
85
+
86
+ // Store subscription state
87
+ this.subscriptions.set(subscriptionId, {
88
+ filteredRecords,
89
+ clientRanges,
90
+ });
91
+ }
92
+
93
+ private async handleNegMsg(parsed: any[]) {
94
+ const subscriptionId = parsed[1];
95
+ const messageHex = parsed[2];
96
+
97
+ if (!this.subscriptions.has(subscriptionId)) {
98
+ // Send NEG-ERR: closed
99
+ const negErr = ['NEG-ERR', subscriptionId, 'closed: subscription not found'];
100
+ this.transport.send(JSON.stringify(negErr));
101
+ return;
102
+ }
103
+
104
+ const subscription = this.subscriptions.get(subscriptionId);
105
+
106
+ // Decode client's message
107
+ let clientRanges: Range[];
108
+ try {
109
+ const clientMessageBuffer = hexToUint8Array(messageHex);
110
+ clientRanges = decodeMessage(clientMessageBuffer);
111
+ } catch (error) {
112
+ // Send NEG-ERR if message cannot be decoded
113
+ const negErr = ['NEG-ERR', subscriptionId, 'invalid: failed to decode message'];
114
+ this.transport.send(JSON.stringify(negErr));
115
+ return;
116
+ }
117
+
118
+ // Generate server response
119
+ const serverMessageBuffer = await this.generateResponseMessage(subscription.filteredRecords, clientRanges);
120
+ const messageHexResponse = uint8ArrayToHex(serverMessageBuffer);
121
+
122
+ // Send NEG-MSG back to client
123
+ const negMsg = ['NEG-MSG', subscriptionId, messageHexResponse];
124
+ this.transport.send(JSON.stringify(negMsg));
125
+
126
+ // Update subscription state
127
+ subscription.clientRanges = clientRanges;
128
+ }
129
+
130
+ private handleNegClose(parsed: any[]) {
131
+ const subscriptionId = parsed[1];
132
+ if (this.subscriptions.has(subscriptionId)) {
133
+ this.subscriptions.delete(subscriptionId);
134
+ }
135
+ }
136
+
137
+ private async generateResponseMessage(serverRecords: RecordItem[], clientRanges: Range[]): Promise<Uint8Array> {
138
+ // For simplicity, we'll send an IdList of all server records
139
+ const idCount = serverRecords.length;
140
+ const idCountVarint = encodeVarint(idCount);
141
+
142
+ // Concatenate all IDs
143
+ const idsBuffer = new Uint8Array(32 * idCount);
144
+ for (let i = 0; i < idCount; i++) {
145
+ idsBuffer.set(serverRecords[i].id, i * 32);
146
+ }
147
+
148
+ const payload = new Uint8Array(idCountVarint.length + idsBuffer.length);
149
+ payload.set(idCountVarint, 0);
150
+ payload.set(idsBuffer, idCountVarint.length);
151
+
152
+ const ranges: Range[] = [
153
+ {
154
+ upperBound: {
155
+ timestampOffset: BigInt(0), // Infinity timestamp encoded as 0
156
+ idPrefix: new Uint8Array(),
157
+ },
158
+ mode: 2, // IdList
159
+ payload: payload,
160
+ },
161
+ ];
162
+
163
+ return encodeMessage(ranges);
164
+ }
165
+ }
package/src/index.ts ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ export interface Transport {
2
+ send(message: string): void;
3
+ onMessage(callback: (message: string) => void): void;
4
+ }
@@ -0,0 +1,19 @@
1
+ import { Transport } from './Transport';
2
+
3
+ export class BrowserWebSocketTransport implements Transport {
4
+ private websocket: WebSocket;
5
+
6
+ constructor(websocket: WebSocket) {
7
+ this.websocket = websocket;
8
+ }
9
+
10
+ send(message: string): void {
11
+ this.websocket.send(message);
12
+ }
13
+
14
+ onMessage(callback: (message: string) => void): void {
15
+ this.websocket.onmessage = (event: MessageEvent) => {
16
+ callback(event.data);
17
+ };
18
+ }
19
+ }
@@ -0,0 +1,21 @@
1
+ // src/transports/BrowserWebSocketTransport.ts
2
+
3
+ import { Transport } from '../Transport';
4
+
5
+ export class BrowserWebSocketTransport implements Transport {
6
+ private websocket: WebSocket;
7
+
8
+ constructor(websocket: WebSocket) {
9
+ this.websocket = websocket;
10
+ }
11
+
12
+ send(message: string): void {
13
+ this.websocket.send(message);
14
+ }
15
+
16
+ onMessage(callback: (message: string) => void): void {
17
+ this.websocket.onmessage = (event: MessageEvent) => {
18
+ callback(event.data);
19
+ };
20
+ }
21
+ }
@@ -0,0 +1,32 @@
1
+ import { Transport } from '../Transport';
2
+ import WebSocket from 'ws';
3
+
4
+ export class NodeWebSocketTransport implements Transport {
5
+ private websocket: WebSocket;
6
+
7
+ constructor(websocket: WebSocket) {
8
+ this.websocket = websocket;
9
+ }
10
+
11
+ send(message: string): void {
12
+ this.websocket.send(message);
13
+ }
14
+
15
+ onMessage(callback: (message: string) => void): void {
16
+ this.websocket.on('message', (data: WebSocket.Data) => {
17
+ if (typeof data === 'string') {
18
+ callback(data);
19
+ } else if (data instanceof Buffer) {
20
+ callback(data.toString());
21
+ } else if (data instanceof ArrayBuffer) {
22
+ callback(Buffer.from(data).toString());
23
+ } else if (Array.isArray(data)) {
24
+ // Handle array of Buffers (rare case)
25
+ callback(Buffer.concat(data).toString());
26
+ } else {
27
+ // Handle other types if necessary
28
+ callback(String(data));
29
+ }
30
+ });
31
+ }
32
+ }
package/src/types.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type RecordItem = { timestamp: bigint; id: Uint8Array };
2
+ export type RangeMode = 0 | 1 | 2;
3
+
4
+ export interface Range {
5
+ upperBound: Bound;
6
+ mode: RangeMode;
7
+ payload: Uint8Array;
8
+ }
9
+
10
+ export interface Bound {
11
+ timestampOffset: bigint;
12
+ idPrefix: Uint8Array;
13
+ }
@@ -0,0 +1,114 @@
1
+ import PROTOCOL_VERSION from '../version';
2
+ import type { Range, Bound, RangeMode } from '../types';
3
+
4
+ export function encodeMessage(ranges: Range[]): Uint8Array {
5
+ const buffers: Uint8Array[] = [];
6
+ buffers.push(Uint8Array.from([PROTOCOL_VERSION]));
7
+ let prevTimestamp = BigInt(0);
8
+
9
+ for (const range of ranges) {
10
+ const timestampOffset = range.upperBound.timestampOffset;
11
+ const timestampVarint = encodeVarint(Number(timestampOffset));
12
+ const idPrefixLengthVarint = encodeVarint(range.upperBound.idPrefix.length);
13
+ const upperBoundBuffer = Buffer.concat([timestampVarint, idPrefixLengthVarint, range.upperBound.idPrefix]);
14
+
15
+ const modeVarint = encodeVarint(range.mode);
16
+
17
+ buffers.push(upperBoundBuffer);
18
+ buffers.push(modeVarint);
19
+
20
+ if (range.mode === 0) {
21
+ } else if (range.mode === 1) {
22
+ buffers.push(range.payload);
23
+ } else if (range.mode === 2) {
24
+ buffers.push(range.payload);
25
+ }
26
+ prevTimestamp += timestampOffset;
27
+ }
28
+ return Buffer.concat(buffers);
29
+ }
30
+
31
+ export function decodeMessage(buffer: Uint8Array): Range[] {
32
+ let offset = 0;
33
+ const ranges: Range[] = [];
34
+ const protocolVersion = buffer[offset++];
35
+ if (protocolVersion !== PROTOCOL_VERSION) {
36
+ throw new Error(`Unsupported protocol version: ${protocolVersion}`);
37
+ }
38
+ let prevTimestamp = BigInt(0);
39
+
40
+ while (offset < buffer.length) {
41
+ const timestampResult = decodeVarint(buffer, offset);
42
+ offset += timestampResult.bytesRead;
43
+ const timestampOffset = BigInt(timestampResult.value);
44
+ const idPrefixLengthResult = decodeVarint(buffer, offset);
45
+ offset += idPrefixLengthResult.bytesRead;
46
+ const idPrefixLength = idPrefixLengthResult.value;
47
+ const idPrefix = buffer.subarray(offset, offset + idPrefixLength);
48
+ offset += idPrefixLength;
49
+
50
+ const upperBound: Bound = {
51
+ timestampOffset,
52
+ idPrefix,
53
+ };
54
+
55
+ const modeResult = decodeVarint(buffer, offset);
56
+ offset += modeResult.bytesRead;
57
+ const mode = modeResult.value as RangeMode;
58
+
59
+ let payload: Uint8Array = new Uint8Array();
60
+ if (mode === 0) {
61
+ } else if (mode === 1) {
62
+ payload = buffer.subarray(offset, offset + 16);
63
+ offset += 16;
64
+ } else if (mode === 2) {
65
+ const lengthResult = decodeVarint(buffer, offset);
66
+ offset += lengthResult.bytesRead;
67
+ const idCount = lengthResult.value;
68
+ const ids: Uint8Array[] = [];
69
+ for (let i = 0; i < idCount; i++) {
70
+ const id = buffer.subarray(offset, offset + 32);
71
+ ids.push(id);
72
+ offset += 32;
73
+ }
74
+ payload = Buffer.concat([encodeVarint(idCount), ...ids]);
75
+ } else {
76
+ throw new Error(`Unknown mode: ${mode}`);
77
+ }
78
+
79
+ ranges.push({
80
+ upperBound,
81
+ mode,
82
+ payload,
83
+ });
84
+ }
85
+ return ranges;
86
+ }
87
+
88
+ export function encodeVarint(value: number): Uint8Array {
89
+ const buffer: number[] = [];
90
+ while (value >= 0x80) {
91
+ buffer.push((value & 0x7f) | 0x80);
92
+ value >>>= 7;
93
+ }
94
+ buffer.push(value & 0x7f);
95
+ return new Uint8Array(buffer);
96
+ }
97
+
98
+ export function decodeVarint(buffer: Uint8Array, offset: number = 0): { value: number; bytesRead: number } {
99
+ let value = 0;
100
+ let shift = 0;
101
+ let bytesRead = 0;
102
+
103
+ while (true) {
104
+ if (offset + bytesRead >= buffer.length) {
105
+ throw new Error("Buffer underflow during varint decoding");
106
+ }
107
+ const byte = buffer[offset + bytesRead];
108
+ value |= (byte & 0x7f) << shift;
109
+ bytesRead++;
110
+ if ((byte & 0x80) === 0) break;
111
+ shift += 7;
112
+ }
113
+ return { value, bytesRead };
114
+ }
@@ -0,0 +1,30 @@
1
+ import crypto from 'crypto';
2
+ import { RecordItem } from '../types';
3
+ import { encodeVarint } from './encoding';
4
+
5
+ export function compareUint8Arrays(a: Uint8Array, b: Uint8Array): number {
6
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
7
+ if (a[i] !== b[i]) return a[i] - b[i];
8
+ }
9
+ return a.length - b.length;
10
+ }
11
+
12
+ export function computeFingerprint(rangeItems: RecordItem[]): Uint8Array {
13
+ const sum = rangeItems.reduce((acc, item) => {
14
+ const idBigInt = BigInt('0x' + Buffer.from(item.id).reverse().toString('hex'));
15
+ return (acc + idBigInt) % (BigInt(1) << BigInt(256));
16
+ }, BigInt(0));
17
+
18
+ const sumBuffer = Buffer.alloc(32);
19
+ sumBuffer.writeBigUInt64LE(sum & BigInt('0xffffffffffffffff'), 0);
20
+ sumBuffer.writeBigUInt64LE((sum >> BigInt(64)) & BigInt('0xffffffffffffffff'), 8);
21
+ sumBuffer.writeBigUInt64LE((sum >> BigInt(128)) & BigInt('0xffffffffffffffff'), 16);
22
+ sumBuffer.writeBigUInt64LE((sum >> BigInt(192)) & BigInt('0xffffffffffffffff'), 24);
23
+
24
+ const lengthVarint = encodeVarint(rangeItems.length);
25
+
26
+ const concatBuffer = Buffer.concat([sumBuffer, lengthVarint]);
27
+
28
+ const hash = crypto.createHash('sha256').update(concatBuffer).digest();
29
+ return hash.subarray(0, 16);
30
+ }
@@ -0,0 +1,16 @@
1
+ export function hexToUint8Array(hex: string): Uint8Array {
2
+ if (hex.length % 2 !== 0) {
3
+ throw new Error('Invalid hex string');
4
+ }
5
+ const array = new Uint8Array(hex.length / 2);
6
+ for (let i = 0; i < array.length; i++) {
7
+ array[i] = parseInt(hex.substr(i * 2, 2), 16);
8
+ }
9
+ return array;
10
+ }
11
+
12
+ export function uint8ArrayToHex(array: Uint8Array): string {
13
+ return Array.from(array)
14
+ .map((b) => b.toString(16).padStart(2, '0'))
15
+ .join('');
16
+ }
@@ -0,0 +1,2 @@
1
+ export * from './encoding';
2
+ export * from './helpers';
@@ -0,0 +1,7 @@
1
+ export function stringToUint8Array(str: string): Uint8Array {
2
+ return new TextEncoder().encode(str);
3
+ }
4
+
5
+ export function uint8ArrayToString(array: Uint8Array): string {
6
+ return new TextDecoder().decode(array);
7
+ }
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const PROTOCOL_VERSION = 0x61; // Version 1
2
+ export default PROTOCOL_VERSION;