@rforum/protocol 0.1.0
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/dist/content/index.d.ts +39 -0
- package/dist/content/index.js +43 -0
- package/dist/envelope/index.d.ts +26 -0
- package/dist/envelope/index.js +65 -0
- package/dist/extensions/index.d.ts +13 -0
- package/dist/extensions/index.js +38 -0
- package/dist/handshake/index.d.ts +39 -0
- package/dist/handshake/index.js +49 -0
- package/dist/identity/index.d.ts +20 -0
- package/dist/identity/index.js +26 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/replication/index.d.ts +43 -0
- package/dist/replication/index.js +29 -0
- package/dist/tests/wire.test.d.ts +1 -0
- package/dist/tests/wire.test.js +50 -0
- package/dist/utils.d.ts +9 -0
- package/dist/utils.js +61 -0
- package/dist/wire/index.d.ts +7 -0
- package/dist/wire/index.js +141 -0
- package/package.json +22 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type ContentType = "text/plain" | "application/json";
|
|
2
|
+
export interface PostContent {
|
|
3
|
+
kind: "post";
|
|
4
|
+
thread_id: string;
|
|
5
|
+
author_id: string;
|
|
6
|
+
created_at: number;
|
|
7
|
+
content_type: ContentType;
|
|
8
|
+
body: string;
|
|
9
|
+
}
|
|
10
|
+
export interface EditContent {
|
|
11
|
+
kind: "edit";
|
|
12
|
+
target_id: string;
|
|
13
|
+
author_id: string;
|
|
14
|
+
created_at: number;
|
|
15
|
+
patch: PatchOperation[];
|
|
16
|
+
}
|
|
17
|
+
export interface ThreadContent {
|
|
18
|
+
kind: "thread";
|
|
19
|
+
thread_id: string;
|
|
20
|
+
root_post_id: string;
|
|
21
|
+
created_at: number;
|
|
22
|
+
title?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ForkContent {
|
|
25
|
+
kind: "fork";
|
|
26
|
+
source_thread_id: string;
|
|
27
|
+
fork_thread_id: string;
|
|
28
|
+
created_at: number;
|
|
29
|
+
}
|
|
30
|
+
export type ContentObject = PostContent | EditContent | ThreadContent | ForkContent;
|
|
31
|
+
export interface PatchOperation {
|
|
32
|
+
op: "add" | "remove" | "replace";
|
|
33
|
+
path: string;
|
|
34
|
+
value?: unknown;
|
|
35
|
+
}
|
|
36
|
+
export declare function createPost(input: Omit<PostContent, "kind">): PostContent;
|
|
37
|
+
export declare function createEdit(input: Omit<EditContent, "kind">): EditContent;
|
|
38
|
+
export declare function hashContent(content: ContentObject): string;
|
|
39
|
+
export declare function applyPatch<T extends Record<string, unknown>>(base: T, patch: PatchOperation[]): T;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
2
|
+
import { bytesToHex, canonicalJson } from "../utils.js";
|
|
3
|
+
export function createPost(input) {
|
|
4
|
+
return { kind: "post", ...input };
|
|
5
|
+
}
|
|
6
|
+
export function createEdit(input) {
|
|
7
|
+
return { kind: "edit", ...input };
|
|
8
|
+
}
|
|
9
|
+
export function hashContent(content) {
|
|
10
|
+
const json = canonicalJson(content);
|
|
11
|
+
return bytesToHex(sha256(new TextEncoder().encode(json)));
|
|
12
|
+
}
|
|
13
|
+
export function applyPatch(base, patch) {
|
|
14
|
+
let output = { ...base };
|
|
15
|
+
for (const op of patch) {
|
|
16
|
+
output = applyOperation(output, op);
|
|
17
|
+
}
|
|
18
|
+
return output;
|
|
19
|
+
}
|
|
20
|
+
function applyOperation(target, op) {
|
|
21
|
+
const pathParts = op.path.split("/").filter((part) => part.length > 0);
|
|
22
|
+
if (pathParts.length === 0) {
|
|
23
|
+
return target;
|
|
24
|
+
}
|
|
25
|
+
const root = { ...target };
|
|
26
|
+
let cursor = root;
|
|
27
|
+
for (let i = 0; i < pathParts.length - 1; i += 1) {
|
|
28
|
+
const key = pathParts[i];
|
|
29
|
+
const next = cursor[key];
|
|
30
|
+
if (typeof next !== "object" || next === null) {
|
|
31
|
+
cursor[key] = {};
|
|
32
|
+
}
|
|
33
|
+
cursor = cursor[key];
|
|
34
|
+
}
|
|
35
|
+
const lastKey = pathParts[pathParts.length - 1];
|
|
36
|
+
if (op.op === "remove") {
|
|
37
|
+
delete cursor[lastKey];
|
|
38
|
+
}
|
|
39
|
+
else if (op.op === "add" || op.op === "replace") {
|
|
40
|
+
cursor[lastKey] = op.value;
|
|
41
|
+
}
|
|
42
|
+
return root;
|
|
43
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Bytes } from "../utils.js";
|
|
2
|
+
export type MessageType = number;
|
|
3
|
+
export interface Envelope {
|
|
4
|
+
version: number;
|
|
5
|
+
message_type: MessageType;
|
|
6
|
+
sender_public_key: Bytes;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
payload: unknown;
|
|
9
|
+
signature: Bytes;
|
|
10
|
+
}
|
|
11
|
+
export interface EnvelopeUnsigned {
|
|
12
|
+
version: number;
|
|
13
|
+
message_type: MessageType;
|
|
14
|
+
sender_public_key: Bytes;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
payload: unknown;
|
|
17
|
+
}
|
|
18
|
+
export declare function createEnvelope(unsigned: EnvelopeUnsigned, signature: Bytes): Envelope;
|
|
19
|
+
export declare function serializeEnvelope(envelope: Envelope): string;
|
|
20
|
+
export declare function deserializeEnvelope(serialized: string): Envelope;
|
|
21
|
+
export declare function envelopeFromObject(obj: Record<string, unknown>): Envelope;
|
|
22
|
+
export declare function unsignedEnvelopeBytes(unsigned: EnvelopeUnsigned): Bytes;
|
|
23
|
+
export declare function signEnvelope(unsigned: EnvelopeUnsigned, secretKey: Bytes): Envelope;
|
|
24
|
+
export declare function verifyEnvelope(envelope: Envelope): boolean;
|
|
25
|
+
export declare function envelopeToBytes(envelope: Envelope): Bytes;
|
|
26
|
+
export declare function envelopeFromBytes(bytes: Bytes): Envelope;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import nacl from "tweetnacl";
|
|
2
|
+
import { canonicalJson, utf8ToBytes, bytesToUtf8 } from "../utils.js";
|
|
3
|
+
export function createEnvelope(unsigned, signature) {
|
|
4
|
+
return { ...unsigned, signature };
|
|
5
|
+
}
|
|
6
|
+
export function serializeEnvelope(envelope) {
|
|
7
|
+
return canonicalJson({
|
|
8
|
+
version: envelope.version,
|
|
9
|
+
message_type: envelope.message_type,
|
|
10
|
+
sender_public_key: Array.from(envelope.sender_public_key),
|
|
11
|
+
timestamp: envelope.timestamp,
|
|
12
|
+
payload: envelope.payload,
|
|
13
|
+
signature: Array.from(envelope.signature)
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function deserializeEnvelope(serialized) {
|
|
17
|
+
const parsed = JSON.parse(serialized);
|
|
18
|
+
return envelopeFromObject(parsed);
|
|
19
|
+
}
|
|
20
|
+
export function envelopeFromObject(obj) {
|
|
21
|
+
const version = Number(obj.version);
|
|
22
|
+
const messageType = Number(obj.message_type);
|
|
23
|
+
const sender = new Uint8Array(obj.sender_public_key);
|
|
24
|
+
const signature = new Uint8Array(obj.signature);
|
|
25
|
+
return {
|
|
26
|
+
version,
|
|
27
|
+
message_type: messageType,
|
|
28
|
+
sender_public_key: sender,
|
|
29
|
+
timestamp: Number(obj.timestamp),
|
|
30
|
+
payload: obj.payload,
|
|
31
|
+
signature
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function unsignedEnvelopeBytes(unsigned) {
|
|
35
|
+
const json = canonicalJson({
|
|
36
|
+
version: unsigned.version,
|
|
37
|
+
message_type: unsigned.message_type,
|
|
38
|
+
sender_public_key: Array.from(unsigned.sender_public_key),
|
|
39
|
+
timestamp: unsigned.timestamp,
|
|
40
|
+
payload: unsigned.payload
|
|
41
|
+
});
|
|
42
|
+
return utf8ToBytes(json);
|
|
43
|
+
}
|
|
44
|
+
export function signEnvelope(unsigned, secretKey) {
|
|
45
|
+
const message = unsignedEnvelopeBytes(unsigned);
|
|
46
|
+
const signature = nacl.sign.detached(message, secretKey);
|
|
47
|
+
return createEnvelope(unsigned, signature);
|
|
48
|
+
}
|
|
49
|
+
export function verifyEnvelope(envelope) {
|
|
50
|
+
const unsigned = {
|
|
51
|
+
version: envelope.version,
|
|
52
|
+
message_type: envelope.message_type,
|
|
53
|
+
sender_public_key: envelope.sender_public_key,
|
|
54
|
+
timestamp: envelope.timestamp,
|
|
55
|
+
payload: envelope.payload
|
|
56
|
+
};
|
|
57
|
+
const message = unsignedEnvelopeBytes(unsigned);
|
|
58
|
+
return nacl.sign.detached.verify(message, envelope.signature, envelope.sender_public_key);
|
|
59
|
+
}
|
|
60
|
+
export function envelopeToBytes(envelope) {
|
|
61
|
+
return utf8ToBytes(serializeEnvelope(envelope));
|
|
62
|
+
}
|
|
63
|
+
export function envelopeFromBytes(bytes) {
|
|
64
|
+
return deserializeEnvelope(bytesToUtf8(bytes));
|
|
65
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ExtensionDefinition {
|
|
2
|
+
namespace: string;
|
|
3
|
+
message_types: string[];
|
|
4
|
+
capabilities: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare class ExtensionRegistry {
|
|
7
|
+
private extensions;
|
|
8
|
+
register(extension: ExtensionDefinition): void;
|
|
9
|
+
list(): ExtensionDefinition[];
|
|
10
|
+
registerMessageType(namespace: string, messageType: string): void;
|
|
11
|
+
registerCapability(namespace: string, capability: string): void;
|
|
12
|
+
private getOrCreate;
|
|
13
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export class ExtensionRegistry {
|
|
2
|
+
extensions = new Map();
|
|
3
|
+
register(extension) {
|
|
4
|
+
const key = extension.namespace;
|
|
5
|
+
if (this.extensions.has(key)) {
|
|
6
|
+
throw new Error("Extension already registered");
|
|
7
|
+
}
|
|
8
|
+
this.extensions.set(key, extension);
|
|
9
|
+
}
|
|
10
|
+
list() {
|
|
11
|
+
return Array.from(this.extensions.values());
|
|
12
|
+
}
|
|
13
|
+
registerMessageType(namespace, messageType) {
|
|
14
|
+
const extension = this.getOrCreate(namespace);
|
|
15
|
+
if (!extension.message_types.includes(messageType)) {
|
|
16
|
+
extension.message_types.push(messageType);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
registerCapability(namespace, capability) {
|
|
20
|
+
const extension = this.getOrCreate(namespace);
|
|
21
|
+
if (!extension.capabilities.includes(capability)) {
|
|
22
|
+
extension.capabilities.push(capability);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
getOrCreate(namespace) {
|
|
26
|
+
const existing = this.extensions.get(namespace);
|
|
27
|
+
if (existing) {
|
|
28
|
+
return existing;
|
|
29
|
+
}
|
|
30
|
+
const created = {
|
|
31
|
+
namespace,
|
|
32
|
+
message_types: [],
|
|
33
|
+
capabilities: []
|
|
34
|
+
};
|
|
35
|
+
this.extensions.set(namespace, created);
|
|
36
|
+
return created;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface Capabilities {
|
|
2
|
+
supported_protocol_versions: number[];
|
|
3
|
+
supported_content_types: string[];
|
|
4
|
+
supported_compression: string[];
|
|
5
|
+
supported_replication_modes: string[];
|
|
6
|
+
supported_extensions: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface NegotiatedCapabilities {
|
|
9
|
+
protocol_version: number | null;
|
|
10
|
+
content_types: string[];
|
|
11
|
+
compression: string[];
|
|
12
|
+
replication_modes: string[];
|
|
13
|
+
extensions: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface HandshakeHello {
|
|
16
|
+
type: "hello";
|
|
17
|
+
timestamp: number;
|
|
18
|
+
capabilities: Capabilities;
|
|
19
|
+
}
|
|
20
|
+
export interface HandshakeAck {
|
|
21
|
+
type: "ack";
|
|
22
|
+
timestamp: number;
|
|
23
|
+
negotiated: NegotiatedCapabilities;
|
|
24
|
+
}
|
|
25
|
+
export type HandshakeMessage = HandshakeHello | HandshakeAck;
|
|
26
|
+
export declare function negotiateCapabilities(local: Capabilities, remote: Capabilities): NegotiatedCapabilities;
|
|
27
|
+
export type HandshakeState = "idle" | "sent" | "received" | "negotiated" | "failed";
|
|
28
|
+
export declare class HandshakeMachine {
|
|
29
|
+
private state;
|
|
30
|
+
private local;
|
|
31
|
+
private remote;
|
|
32
|
+
private negotiated;
|
|
33
|
+
constructor(local: Capabilities);
|
|
34
|
+
getState(): HandshakeState;
|
|
35
|
+
getNegotiated(): NegotiatedCapabilities | null;
|
|
36
|
+
createHello(): HandshakeHello;
|
|
37
|
+
receiveHello(message: HandshakeHello): HandshakeAck;
|
|
38
|
+
receiveAck(message: HandshakeAck): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { nowUnixMillis } from "../utils.js";
|
|
2
|
+
export function negotiateCapabilities(local, remote) {
|
|
3
|
+
const protocol_version = intersectNumbers(local.supported_protocol_versions, remote.supported_protocol_versions)[0] ?? null;
|
|
4
|
+
return {
|
|
5
|
+
protocol_version,
|
|
6
|
+
content_types: intersectStrings(local.supported_content_types, remote.supported_content_types),
|
|
7
|
+
compression: intersectStrings(local.supported_compression, remote.supported_compression),
|
|
8
|
+
replication_modes: intersectStrings(local.supported_replication_modes, remote.supported_replication_modes),
|
|
9
|
+
extensions: intersectStrings(local.supported_extensions, remote.supported_extensions)
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export class HandshakeMachine {
|
|
13
|
+
state = "idle";
|
|
14
|
+
local;
|
|
15
|
+
remote = null;
|
|
16
|
+
negotiated = null;
|
|
17
|
+
constructor(local) {
|
|
18
|
+
this.local = local;
|
|
19
|
+
}
|
|
20
|
+
getState() {
|
|
21
|
+
return this.state;
|
|
22
|
+
}
|
|
23
|
+
getNegotiated() {
|
|
24
|
+
return this.negotiated;
|
|
25
|
+
}
|
|
26
|
+
createHello() {
|
|
27
|
+
this.state = "sent";
|
|
28
|
+
return { type: "hello", timestamp: nowUnixMillis(), capabilities: this.local };
|
|
29
|
+
}
|
|
30
|
+
receiveHello(message) {
|
|
31
|
+
this.remote = message.capabilities;
|
|
32
|
+
this.state = "received";
|
|
33
|
+
this.negotiated = negotiateCapabilities(this.local, message.capabilities);
|
|
34
|
+
this.state = this.negotiated.protocol_version ? "negotiated" : "failed";
|
|
35
|
+
return { type: "ack", timestamp: nowUnixMillis(), negotiated: this.negotiated };
|
|
36
|
+
}
|
|
37
|
+
receiveAck(message) {
|
|
38
|
+
this.negotiated = message.negotiated;
|
|
39
|
+
this.state = this.negotiated.protocol_version ? "negotiated" : "failed";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function intersectStrings(a, b) {
|
|
43
|
+
const set = new Set(b);
|
|
44
|
+
return a.filter((item) => set.has(item));
|
|
45
|
+
}
|
|
46
|
+
function intersectNumbers(a, b) {
|
|
47
|
+
const set = new Set(b);
|
|
48
|
+
return a.filter((item) => set.has(item)).sort((x, y) => y - x);
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Bytes } from "../utils.js";
|
|
2
|
+
export interface Keypair {
|
|
3
|
+
publicKey: Bytes;
|
|
4
|
+
secretKey: Bytes;
|
|
5
|
+
}
|
|
6
|
+
export interface Persona {
|
|
7
|
+
id: string;
|
|
8
|
+
publicKey: Bytes;
|
|
9
|
+
label?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function generateKeypair(): Keypair;
|
|
12
|
+
export declare function deriveIdentityId(publicKey: Bytes): string;
|
|
13
|
+
export declare function createPersona(publicKey: Bytes, label?: string): Persona;
|
|
14
|
+
export interface SerializedIdentity {
|
|
15
|
+
public_key_hex: string;
|
|
16
|
+
secret_key_hex: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function serializeIdentity(keypair: Keypair, label?: string): SerializedIdentity;
|
|
20
|
+
export declare function deserializeIdentity(serialized: SerializedIdentity): Keypair;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import nacl from "tweetnacl";
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
3
|
+
import { bytesToHex, hexToBytes } from "../utils.js";
|
|
4
|
+
export function generateKeypair() {
|
|
5
|
+
const keys = nacl.sign.keyPair();
|
|
6
|
+
return { publicKey: keys.publicKey, secretKey: keys.secretKey };
|
|
7
|
+
}
|
|
8
|
+
export function deriveIdentityId(publicKey) {
|
|
9
|
+
return bytesToHex(sha256(publicKey));
|
|
10
|
+
}
|
|
11
|
+
export function createPersona(publicKey, label) {
|
|
12
|
+
return { id: deriveIdentityId(publicKey), publicKey, label };
|
|
13
|
+
}
|
|
14
|
+
export function serializeIdentity(keypair, label) {
|
|
15
|
+
return {
|
|
16
|
+
public_key_hex: bytesToHex(keypair.publicKey),
|
|
17
|
+
secret_key_hex: bytesToHex(keypair.secretKey),
|
|
18
|
+
label
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function deserializeIdentity(serialized) {
|
|
22
|
+
return {
|
|
23
|
+
publicKey: hexToBytes(serialized.public_key_hex),
|
|
24
|
+
secretKey: hexToBytes(serialized.secret_key_hex)
|
|
25
|
+
};
|
|
26
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./utils.js";
|
|
2
|
+
export * from "./envelope/index.js";
|
|
3
|
+
export * from "./handshake/index.js";
|
|
4
|
+
export * from "./wire/index.js";
|
|
5
|
+
export * from "./identity/index.js";
|
|
6
|
+
export * from "./content/index.js";
|
|
7
|
+
export * from "./replication/index.js";
|
|
8
|
+
export * from "./extensions/index.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./utils.js";
|
|
2
|
+
export * from "./envelope/index.js";
|
|
3
|
+
export * from "./handshake/index.js";
|
|
4
|
+
export * from "./wire/index.js";
|
|
5
|
+
export * from "./identity/index.js";
|
|
6
|
+
export * from "./content/index.js";
|
|
7
|
+
export * from "./replication/index.js";
|
|
8
|
+
export * from "./extensions/index.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface GossipHave {
|
|
2
|
+
type: "have";
|
|
3
|
+
topic_id: string;
|
|
4
|
+
post_ids: string[];
|
|
5
|
+
timestamp: number;
|
|
6
|
+
}
|
|
7
|
+
export interface GossipWant {
|
|
8
|
+
type: "want";
|
|
9
|
+
topic_id: string;
|
|
10
|
+
post_ids: string[];
|
|
11
|
+
timestamp: number;
|
|
12
|
+
}
|
|
13
|
+
export type GossipMessage = GossipHave | GossipWant;
|
|
14
|
+
export interface PullRequest {
|
|
15
|
+
type: "pull";
|
|
16
|
+
topic_id: string;
|
|
17
|
+
since_ts: number;
|
|
18
|
+
until_ts: number;
|
|
19
|
+
max_items: number;
|
|
20
|
+
}
|
|
21
|
+
export interface PushOffer {
|
|
22
|
+
type: "push";
|
|
23
|
+
topic_id: string;
|
|
24
|
+
post_ids: string[];
|
|
25
|
+
}
|
|
26
|
+
export interface PullPushNegotiation {
|
|
27
|
+
request: PullRequest;
|
|
28
|
+
offers: PushOffer[];
|
|
29
|
+
}
|
|
30
|
+
export declare class NeighborhoodCache {
|
|
31
|
+
private peers;
|
|
32
|
+
private maxPeers;
|
|
33
|
+
constructor(maxPeers?: number);
|
|
34
|
+
recordPeer(peerId: string, topics: string[]): void;
|
|
35
|
+
getPeersForTopic(topicId: string): string[];
|
|
36
|
+
listPeers(): PeerEntry[];
|
|
37
|
+
}
|
|
38
|
+
interface PeerEntry {
|
|
39
|
+
peer_id: string;
|
|
40
|
+
topics: string[];
|
|
41
|
+
last_seen: number;
|
|
42
|
+
}
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { nowUnixMillis } from "../utils.js";
|
|
2
|
+
export class NeighborhoodCache {
|
|
3
|
+
peers = new Map();
|
|
4
|
+
maxPeers;
|
|
5
|
+
constructor(maxPeers = 128) {
|
|
6
|
+
this.maxPeers = maxPeers;
|
|
7
|
+
}
|
|
8
|
+
recordPeer(peerId, topics) {
|
|
9
|
+
if (!this.peers.has(peerId) && this.peers.size >= this.maxPeers) {
|
|
10
|
+
const oldest = Array.from(this.peers.values()).sort((a, b) => a.last_seen - b.last_seen)[0];
|
|
11
|
+
if (oldest) {
|
|
12
|
+
this.peers.delete(oldest.peer_id);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
this.peers.set(peerId, {
|
|
16
|
+
peer_id: peerId,
|
|
17
|
+
topics,
|
|
18
|
+
last_seen: nowUnixMillis()
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
getPeersForTopic(topicId) {
|
|
22
|
+
return Array.from(this.peers.values())
|
|
23
|
+
.filter((peer) => peer.topics.includes(topicId))
|
|
24
|
+
.map((peer) => peer.peer_id);
|
|
25
|
+
}
|
|
26
|
+
listPeers() {
|
|
27
|
+
return Array.from(this.peers.values());
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { encodeEnvelope, decodeEnvelope, encodeHandshake, decodeHandshake } from "../wire/index.js";
|
|
3
|
+
import { generateKeypair } from "../identity/index.js";
|
|
4
|
+
import { signEnvelope } from "../envelope/index.js";
|
|
5
|
+
function bytesEqual(a, b) {
|
|
6
|
+
if (a.length !== b.length)
|
|
7
|
+
return false;
|
|
8
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
9
|
+
if (a[i] !== b[i])
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
describe("wire", () => {
|
|
15
|
+
it("round-trips envelope", () => {
|
|
16
|
+
const keys = generateKeypair();
|
|
17
|
+
const unsigned = {
|
|
18
|
+
version: 1,
|
|
19
|
+
message_type: 16,
|
|
20
|
+
sender_public_key: keys.publicKey,
|
|
21
|
+
timestamp: 1700000000000,
|
|
22
|
+
payload: { hello: "world", count: 2 }
|
|
23
|
+
};
|
|
24
|
+
const envelope = signEnvelope(unsigned, keys.secretKey);
|
|
25
|
+
const encoded = encodeEnvelope(envelope);
|
|
26
|
+
const decoded = decodeEnvelope(encoded);
|
|
27
|
+
expect(decoded.version).toBe(envelope.version);
|
|
28
|
+
expect(decoded.message_type).toBe(envelope.message_type);
|
|
29
|
+
expect(decoded.timestamp).toBe(envelope.timestamp);
|
|
30
|
+
expect(bytesEqual(decoded.sender_public_key, envelope.sender_public_key)).toBe(true);
|
|
31
|
+
expect(bytesEqual(decoded.signature, envelope.signature)).toBe(true);
|
|
32
|
+
expect(decoded.payload).toEqual(envelope.payload);
|
|
33
|
+
});
|
|
34
|
+
it("round-trips handshake", () => {
|
|
35
|
+
const message = {
|
|
36
|
+
type: "hello",
|
|
37
|
+
timestamp: 1700000000000,
|
|
38
|
+
capabilities: {
|
|
39
|
+
supported_protocol_versions: [1],
|
|
40
|
+
supported_content_types: ["text/plain"],
|
|
41
|
+
supported_compression: ["none"],
|
|
42
|
+
supported_replication_modes: ["gossip"],
|
|
43
|
+
supported_extensions: ["rforum/core"]
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const encoded = encodeHandshake(message);
|
|
47
|
+
const decoded = decodeHandshake(encoded);
|
|
48
|
+
expect(decoded).toEqual(message);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Bytes = Uint8Array;
|
|
2
|
+
export declare function bytesToHex(bytes: Bytes): string;
|
|
3
|
+
export declare function hexToBytes(hex: string): Bytes;
|
|
4
|
+
export declare function concatBytes(parts: Bytes[]): Bytes;
|
|
5
|
+
export declare function utf8ToBytes(text: string): Bytes;
|
|
6
|
+
export declare function bytesToUtf8(bytes: Bytes): string;
|
|
7
|
+
export declare function canonicalize(value: unknown): unknown;
|
|
8
|
+
export declare function canonicalJson(value: unknown): string;
|
|
9
|
+
export declare function nowUnixMillis(): number;
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function bytesToHex(bytes) {
|
|
2
|
+
return Array.from(bytes)
|
|
3
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
4
|
+
.join("");
|
|
5
|
+
}
|
|
6
|
+
export function hexToBytes(hex) {
|
|
7
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
8
|
+
if (clean.length % 2 !== 0) {
|
|
9
|
+
throw new Error("Invalid hex length");
|
|
10
|
+
}
|
|
11
|
+
const out = new Uint8Array(clean.length / 2);
|
|
12
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
13
|
+
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
export function concatBytes(parts) {
|
|
18
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
19
|
+
const out = new Uint8Array(total);
|
|
20
|
+
let offset = 0;
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
out.set(part, offset);
|
|
23
|
+
offset += part.length;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
export function utf8ToBytes(text) {
|
|
28
|
+
return new TextEncoder().encode(text);
|
|
29
|
+
}
|
|
30
|
+
export function bytesToUtf8(bytes) {
|
|
31
|
+
return new TextDecoder().decode(bytes);
|
|
32
|
+
}
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
if (value === null || typeof value !== "object") {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return Object.getPrototypeOf(value) === Object.prototype;
|
|
38
|
+
}
|
|
39
|
+
export function canonicalize(value) {
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return value.map((item) => canonicalize(item));
|
|
42
|
+
}
|
|
43
|
+
if (isPlainObject(value)) {
|
|
44
|
+
const entries = Object.entries(value)
|
|
45
|
+
.filter(([, v]) => v !== undefined)
|
|
46
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
47
|
+
.map(([k, v]) => [k, canonicalize(v)]);
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const [k, v] of entries) {
|
|
50
|
+
out[k] = v;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
export function canonicalJson(value) {
|
|
57
|
+
return JSON.stringify(canonicalize(value));
|
|
58
|
+
}
|
|
59
|
+
export function nowUnixMillis() {
|
|
60
|
+
return Date.now();
|
|
61
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Bytes } from "../utils.js";
|
|
2
|
+
import { Envelope } from "../envelope/index.js";
|
|
3
|
+
import { HandshakeMessage } from "../handshake/index.js";
|
|
4
|
+
export declare function encodeEnvelope(envelope: Envelope): Bytes;
|
|
5
|
+
export declare function decodeEnvelope(bytes: Bytes): Envelope;
|
|
6
|
+
export declare function encodeHandshake(message: HandshakeMessage): Bytes;
|
|
7
|
+
export declare function decodeHandshake(bytes: Bytes): HandshakeMessage;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { canonicalJson, bytesToUtf8, utf8ToBytes, concatBytes } from "../utils.js";
|
|
2
|
+
import { envelopeFromObject } from "../envelope/index.js";
|
|
3
|
+
const MAGIC = new Uint8Array([0x52, 0x46, 0x4f, 0x52]);
|
|
4
|
+
const KIND_ENVELOPE = 1;
|
|
5
|
+
const KIND_HANDSHAKE = 2;
|
|
6
|
+
export function encodeEnvelope(envelope) {
|
|
7
|
+
const payloadJson = canonicalJson(envelope.payload);
|
|
8
|
+
const payloadBytes = utf8ToBytes(payloadJson);
|
|
9
|
+
const header = new ByteWriter();
|
|
10
|
+
header.writeBytes(MAGIC);
|
|
11
|
+
header.writeU8(KIND_ENVELOPE);
|
|
12
|
+
header.writeU8(envelope.version);
|
|
13
|
+
header.writeU16(envelope.message_type);
|
|
14
|
+
header.writeU64(envelope.timestamp);
|
|
15
|
+
header.writeU16(envelope.sender_public_key.length);
|
|
16
|
+
header.writeBytes(envelope.sender_public_key);
|
|
17
|
+
header.writeU32(payloadBytes.length);
|
|
18
|
+
header.writeBytes(payloadBytes);
|
|
19
|
+
header.writeU16(envelope.signature.length);
|
|
20
|
+
header.writeBytes(envelope.signature);
|
|
21
|
+
return header.toBytes();
|
|
22
|
+
}
|
|
23
|
+
export function decodeEnvelope(bytes) {
|
|
24
|
+
const reader = new ByteReader(bytes);
|
|
25
|
+
reader.expectBytes(MAGIC);
|
|
26
|
+
reader.expectU8(KIND_ENVELOPE);
|
|
27
|
+
const version = reader.readU8();
|
|
28
|
+
const messageType = reader.readU16();
|
|
29
|
+
const timestamp = reader.readU64();
|
|
30
|
+
const pubLen = reader.readU16();
|
|
31
|
+
const publicKey = reader.readBytes(pubLen);
|
|
32
|
+
const payloadLen = reader.readU32();
|
|
33
|
+
const payloadJson = bytesToUtf8(reader.readBytes(payloadLen));
|
|
34
|
+
const signatureLen = reader.readU16();
|
|
35
|
+
const signature = reader.readBytes(signatureLen);
|
|
36
|
+
const payload = JSON.parse(payloadJson);
|
|
37
|
+
return envelopeFromObject({
|
|
38
|
+
version,
|
|
39
|
+
message_type: messageType,
|
|
40
|
+
sender_public_key: Array.from(publicKey),
|
|
41
|
+
timestamp,
|
|
42
|
+
payload,
|
|
43
|
+
signature: Array.from(signature)
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function encodeHandshake(message) {
|
|
47
|
+
const payloadJson = canonicalJson(message);
|
|
48
|
+
const payloadBytes = utf8ToBytes(payloadJson);
|
|
49
|
+
const header = new ByteWriter();
|
|
50
|
+
header.writeBytes(MAGIC);
|
|
51
|
+
header.writeU8(KIND_HANDSHAKE);
|
|
52
|
+
header.writeU16(payloadBytes.length);
|
|
53
|
+
header.writeBytes(payloadBytes);
|
|
54
|
+
return header.toBytes();
|
|
55
|
+
}
|
|
56
|
+
export function decodeHandshake(bytes) {
|
|
57
|
+
const reader = new ByteReader(bytes);
|
|
58
|
+
reader.expectBytes(MAGIC);
|
|
59
|
+
reader.expectU8(KIND_HANDSHAKE);
|
|
60
|
+
const len = reader.readU16();
|
|
61
|
+
const payload = bytesToUtf8(reader.readBytes(len));
|
|
62
|
+
return JSON.parse(payload);
|
|
63
|
+
}
|
|
64
|
+
class ByteWriter {
|
|
65
|
+
parts = [];
|
|
66
|
+
writeU8(value) {
|
|
67
|
+
this.parts.push(new Uint8Array([value & 0xff]));
|
|
68
|
+
}
|
|
69
|
+
writeU16(value) {
|
|
70
|
+
const buf = new Uint8Array(2);
|
|
71
|
+
const view = new DataView(buf.buffer);
|
|
72
|
+
view.setUint16(0, value, false);
|
|
73
|
+
this.parts.push(buf);
|
|
74
|
+
}
|
|
75
|
+
writeU32(value) {
|
|
76
|
+
const buf = new Uint8Array(4);
|
|
77
|
+
const view = new DataView(buf.buffer);
|
|
78
|
+
view.setUint32(0, value, false);
|
|
79
|
+
this.parts.push(buf);
|
|
80
|
+
}
|
|
81
|
+
writeU64(value) {
|
|
82
|
+
const buf = new Uint8Array(8);
|
|
83
|
+
const view = new DataView(buf.buffer);
|
|
84
|
+
view.setBigUint64(0, BigInt(value), false);
|
|
85
|
+
this.parts.push(buf);
|
|
86
|
+
}
|
|
87
|
+
writeBytes(bytes) {
|
|
88
|
+
this.parts.push(bytes);
|
|
89
|
+
}
|
|
90
|
+
toBytes() {
|
|
91
|
+
return concatBytes(this.parts);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
class ByteReader {
|
|
95
|
+
offset = 0;
|
|
96
|
+
view;
|
|
97
|
+
bytes;
|
|
98
|
+
constructor(bytes) {
|
|
99
|
+
this.bytes = bytes;
|
|
100
|
+
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
101
|
+
}
|
|
102
|
+
readU8() {
|
|
103
|
+
const value = this.view.getUint8(this.offset);
|
|
104
|
+
this.offset += 1;
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
readU16() {
|
|
108
|
+
const value = this.view.getUint16(this.offset, false);
|
|
109
|
+
this.offset += 2;
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
readU32() {
|
|
113
|
+
const value = this.view.getUint32(this.offset, false);
|
|
114
|
+
this.offset += 4;
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
readU64() {
|
|
118
|
+
const value = Number(this.view.getBigUint64(this.offset, false));
|
|
119
|
+
this.offset += 8;
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
readBytes(length) {
|
|
123
|
+
const out = this.bytes.slice(this.offset, this.offset + length);
|
|
124
|
+
this.offset += length;
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
expectBytes(expected) {
|
|
128
|
+
const actual = this.readBytes(expected.length);
|
|
129
|
+
for (let i = 0; i < expected.length; i += 1) {
|
|
130
|
+
if (actual[i] !== expected[i]) {
|
|
131
|
+
throw new Error("Invalid magic bytes");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
expectU8(expected) {
|
|
136
|
+
const actual = this.readU8();
|
|
137
|
+
if (actual !== expected) {
|
|
138
|
+
throw new Error("Unexpected message kind");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rforum/protocol",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RForum protocol library (app-agnostic)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": ["dist"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"test": "vitest run"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@noble/hashes": "^1.4.0",
|
|
15
|
+
"tweetnacl": "^1.0.3"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^20.11.30",
|
|
19
|
+
"typescript": "^5.4.5",
|
|
20
|
+
"vitest": "^1.5.0"
|
|
21
|
+
}
|
|
22
|
+
}
|