@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 +21 -0
- package/README.md +163 -0
- package/build.js +25 -0
- package/dist/index.browser.js +1 -0
- package/dist/index.browser.js.map +7 -0
- package/dist/index.node.js +1 -0
- package/dist/index.node.js.map +7 -0
- package/package.json +15 -0
- package/src/ClientHandler.test.ts +34 -0
- package/src/ClientHandler.ts +145 -0
- package/src/ServerHandler.test.ts +36 -0
- package/src/ServerHandler.ts +165 -0
- package/src/index.ts +0 -0
- package/src/transports/Transport.ts +4 -0
- package/src/transports/WebSocket.ts +19 -0
- package/src/transports/Websocket/Browser.ts +21 -0
- package/src/transports/Websocket/Node.ts +32 -0
- package/src/types.ts +13 -0
- package/src/utils/encoding.ts +114 -0
- package/src/utils/helpers.ts +30 -0
- package/src/utils/hex.ts +16 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/textEncoding.ts +7 -0
- package/src/version.ts +2 -0
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
|
+
[](https://www.npmjs.com/package/@nostrwatch/negentropy-utils)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/sandwichfarm/nostr-watch)
|
|
8
|
+
[](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 @@
|
|
|
1
|
+
//# sourceMappingURL=index.node.js.map
|
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,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
|
+
}
|
package/src/utils/hex.ts
ADDED
|
@@ -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
|
+
}
|
package/src/version.ts
ADDED