@cero-base/core 1.3.0 → 1.5.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/package.json +32 -15
- package/src/database/bootstrap.js +62 -8
- package/src/database/changes.js +120 -0
- package/src/database/dispatch.js +6 -6
- package/src/database/envelope.js +32 -0
- package/src/database/index.js +109 -7
- package/src/lib/spec/index.js +35 -2
- package/src/lib/spec/schema.json +23 -1
- package/src/network/index.js +63 -53
- package/src/network/{bluetooth.js → transports/ble.js} +152 -167
- package/src/network/transports/dht.js +110 -0
- package/src/network/{gatt-stream.js → transports/gatt.js} +4 -6
- package/src/rpc/index.js +23 -0
- package/types/database/changes.d.ts +15 -0
- package/types/database/envelope.d.ts +19 -0
- package/types/database/index.d.ts +32 -0
- package/types/lib/spec/index.d.ts +25 -12
- package/types/network/index.d.ts +21 -4
- package/types/network/{bluetooth.d.ts → transports/ble.d.ts} +25 -51
- package/types/network/transports/dht.d.ts +75 -0
- package/types/network/{gatt-stream.d.ts → transports/gatt.d.ts} +2 -3
- package/types/rpc/index.d.ts +10 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import Hyperswarm from 'hyperswarm'
|
|
2
|
+
import b4a from 'b4a'
|
|
3
|
+
import { hash } from 'hypercore-crypto'
|
|
4
|
+
import safetyCatch from 'safety-catch'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {object} DHTTransportOpts
|
|
8
|
+
* @property {import('../../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
|
|
9
|
+
* @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
|
|
10
|
+
* @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
|
|
11
|
+
* @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
|
|
12
|
+
* @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The internet transport: a Hyperswarm that finds peers over the DHT. Owns the
|
|
17
|
+
* swarm's whole lifecycle — construction (identity keyPair, bootstrap, firewall,
|
|
18
|
+
* relay), the channel-topic join/leave wrapping, flush, suspend/resume, and
|
|
19
|
+
* teardown. {@link Network} subscribes to `this.swarm`'s connection/peer events
|
|
20
|
+
* and drives topic joins; the swarm-specific wiring lives here.
|
|
21
|
+
*/
|
|
22
|
+
export class DHTTransport {
|
|
23
|
+
/** @param {DHTTransportOpts} [opts] */
|
|
24
|
+
constructor({ identity, bootstrap, firewall, relayThrough, channel } = {}) {
|
|
25
|
+
const opts = {}
|
|
26
|
+
if (identity) opts.keyPair = { publicKey: identity.publicKey, secretKey: identity.secretKey }
|
|
27
|
+
if (bootstrap) opts.bootstrap = bootstrap
|
|
28
|
+
if (firewall) opts.firewall = firewall
|
|
29
|
+
if (relayThrough) opts.relayThrough = relayThrough
|
|
30
|
+
|
|
31
|
+
this.swarm = new Hyperswarm(opts)
|
|
32
|
+
|
|
33
|
+
if (channel) {
|
|
34
|
+
const join = this.swarm.join.bind(this.swarm)
|
|
35
|
+
const leave = this.swarm.leave.bind(this.swarm)
|
|
36
|
+
this.swarm.join = (topic, opts) => join(channelTopic(topic, channel), opts)
|
|
37
|
+
this.swarm.leave = (topic) => leave(channelTopic(topic, channel))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {boolean} */
|
|
42
|
+
get suspended() {
|
|
43
|
+
return this.swarm?.suspended === true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wait for pending DHT announces and lookups to settle, bounded by timeout.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ timeout?: number }} [opts]
|
|
50
|
+
* @returns {Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
async flush({ timeout = 500 } = {}) {
|
|
53
|
+
if (!this.swarm) return
|
|
54
|
+
await Promise.race([this.swarm.flush(), new Promise((r) => setTimeout(r, timeout))])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pause the swarm — keeps state, drops sockets. Idempotent.
|
|
59
|
+
*
|
|
60
|
+
* @returns {Promise<void>}
|
|
61
|
+
*/
|
|
62
|
+
async suspend() {
|
|
63
|
+
if (!this.swarm || this.swarm.suspended) return
|
|
64
|
+
try {
|
|
65
|
+
await this.swarm.suspend()
|
|
66
|
+
} catch (err) {
|
|
67
|
+
safetyCatch(err)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resume a suspended swarm. Idempotent.
|
|
73
|
+
*
|
|
74
|
+
* @returns {Promise<void>}
|
|
75
|
+
*/
|
|
76
|
+
async resume() {
|
|
77
|
+
if (!this.swarm || !this.swarm.suspended) return
|
|
78
|
+
try {
|
|
79
|
+
await this.swarm.resume()
|
|
80
|
+
} catch (err) {
|
|
81
|
+
safetyCatch(err)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Flush pending discovery, then tear down the swarm. Idempotent.
|
|
87
|
+
*
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
async destroy() {
|
|
91
|
+
if (!this.swarm) return
|
|
92
|
+
try {
|
|
93
|
+
await this.flush()
|
|
94
|
+
} catch (err) {
|
|
95
|
+
safetyCatch(err)
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await this.swarm.destroy()
|
|
99
|
+
} catch (err) {
|
|
100
|
+
safetyCatch(err)
|
|
101
|
+
}
|
|
102
|
+
this.swarm = null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A channel re-namespaces every swarm topic so only same-channel peers meet.
|
|
107
|
+
// No channel → identity (unchanged, back-compat).
|
|
108
|
+
export function channelTopic(topic, channel) {
|
|
109
|
+
return channel ? hash([topic, b4a.from(channel)]) : topic
|
|
110
|
+
}
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { Duplex } from 'streamx'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// via the peripheral's maximumWriteValueLength + write-without-response.
|
|
3
|
+
// A single GATT write/notify caps at ATT_MTU − 3 ≈ 182 bytes; 150 stays under
|
|
4
|
+
// that without negotiating an MTU.
|
|
6
5
|
const PAYLOAD = 150
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* A dumb byte-carrying duplex for the GATT transport. Framing and session logic
|
|
10
|
-
* live in
|
|
11
|
-
* write and pushes inbound payload bytes.
|
|
12
|
-
* duplex, exactly like the old L2CAP channel.
|
|
9
|
+
* live in BLETransport; this only fragments outbound writes to fit a GATT
|
|
10
|
+
* write and pushes inbound payload bytes.
|
|
13
11
|
*
|
|
14
12
|
* @extends Duplex
|
|
15
13
|
*/
|
package/src/rpc/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const EMPTY = b4a.alloc(0)
|
|
|
12
12
|
// Default envelope encodings for the @cero namespace, used when the supplied
|
|
13
13
|
// spec does not provide its own rows/query/create types in its schema.
|
|
14
14
|
const DEFAULT_ROWS = getEncoding('@cero/rows')
|
|
15
|
+
const DEFAULT_CHANGES = getEncoding('@cero/changes')
|
|
15
16
|
const DEFAULT_QUERY = getEncoding('@cero/query')
|
|
16
17
|
const DEFAULT_CREATE = getEncoding('@cero/create')
|
|
17
18
|
|
|
@@ -26,6 +27,8 @@ const DEFAULT_CREATE = getEncoding('@cero/create')
|
|
|
26
27
|
* @property {(type: string, row: any) => Uint8Array} encodeRow
|
|
27
28
|
* @property {(type: string, buf: Uint8Array) => any} decodeRow
|
|
28
29
|
* @property {(type: string, rows: any[]) => Uint8Array} encodeRows
|
|
30
|
+
* @property {(type: string, changes: Array<{ prev: any, next: any }>) => Uint8Array} encodeChanges
|
|
31
|
+
* @property {(type: string, buf: Uint8Array) => Array<{ prev: any, next: any }>} decodeChanges
|
|
29
32
|
* @property {(type: string, buf: Uint8Array) => any[]} decodeRows
|
|
30
33
|
* @property {(q: any) => Uint8Array} encodeQuery
|
|
31
34
|
* @property {(buf: Uint8Array) => any} decodeQuery
|
|
@@ -51,6 +54,7 @@ export function bindCodec(spec) {
|
|
|
51
54
|
|
|
52
55
|
const ns = spec.meta?.ns
|
|
53
56
|
const ROWS = ns ? `@${ns}/rows` : null
|
|
57
|
+
const CHANGES = ns ? `@${ns}/changes` : null
|
|
54
58
|
const QUERY = ns ? `@${ns}/query` : null
|
|
55
59
|
const CREATE = ns ? `@${ns}/create` : null
|
|
56
60
|
|
|
@@ -73,6 +77,25 @@ export function bindCodec(spec) {
|
|
|
73
77
|
const data = env?.data || []
|
|
74
78
|
return data.map((b) => schema.decode(type, b))
|
|
75
79
|
},
|
|
80
|
+
encodeChanges(type, changes) {
|
|
81
|
+
const prev = changes.map((x) => (x.prev ? schema.encode(type, x.prev) : EMPTY))
|
|
82
|
+
const next = changes.map((x) => (x.next ? schema.encode(type, x.next) : EMPTY))
|
|
83
|
+
return encodeEnvelope(schema, CHANGES, DEFAULT_CHANGES, { prev, next })
|
|
84
|
+
},
|
|
85
|
+
decodeChanges(type, buf) {
|
|
86
|
+
if (!buf || buf.length === 0) return []
|
|
87
|
+
const env = decodeEnvelope(schema, CHANGES, DEFAULT_CHANGES, buf)
|
|
88
|
+
const prev = env?.prev || []
|
|
89
|
+
const next = env?.next || []
|
|
90
|
+
const out = []
|
|
91
|
+
for (let i = 0; i < Math.max(prev.length, next.length); i++) {
|
|
92
|
+
out.push({
|
|
93
|
+
prev: prev[i]?.length ? schema.decode(type, prev[i]) : null,
|
|
94
|
+
next: next[i]?.length ? schema.decode(type, next[i]) : null
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
},
|
|
76
99
|
encodeQuery(q) {
|
|
77
100
|
if (q == null) return encodeEnvelope(schema, QUERY, DEFAULT_QUERY, {})
|
|
78
101
|
const { gt, gte, lt, lte, limit, reverse, ...rest } = q
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull-driven delta stream over a collection: batches of `{ prev, next }`
|
|
3
|
+
* pairs diffed between per-stream snapshot cursors. Update ticks only mark
|
|
4
|
+
* the stream dirty — the diff runs when the reader demands, so backpressure
|
|
5
|
+
* folds bursts into one bigger batch and nothing is ever dropped. The first
|
|
6
|
+
* batch, and any batch after the view is swapped or fast-forwarded to a new
|
|
7
|
+
* core, carries the full matching state as inserts with `reset: true`.
|
|
8
|
+
*
|
|
9
|
+
* @param {import('./index.js').Database} db
|
|
10
|
+
* @param {string} name Ref name (scopes the update ticks).
|
|
11
|
+
* @param {string} col Collection path (`@ns/name`).
|
|
12
|
+
* @param {(row: any) => boolean} matches
|
|
13
|
+
* @returns {import('streamx').Readable}
|
|
14
|
+
*/
|
|
15
|
+
export function makeChanges(db: import("./index.js").Database, name: string, col: string, matches: (row: any) => boolean): import("streamx").Readable;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix an encoded op with the app's contract version.
|
|
3
|
+
*
|
|
4
|
+
* @param {number} version
|
|
5
|
+
* @param {Uint8Array} body
|
|
6
|
+
* @returns {Uint8Array}
|
|
7
|
+
*/
|
|
8
|
+
export function wrap(version: number, body: Uint8Array): Uint8Array;
|
|
9
|
+
/**
|
|
10
|
+
* Split an op into contract version and payload. Ops written before the
|
|
11
|
+
* envelope existed carry no sentinel and read as version 0.
|
|
12
|
+
*
|
|
13
|
+
* @param {Uint8Array} buf
|
|
14
|
+
* @returns {{ version: number, body: Uint8Array }}
|
|
15
|
+
*/
|
|
16
|
+
export function unwrap(buf: Uint8Array): {
|
|
17
|
+
version: number;
|
|
18
|
+
body: Uint8Array;
|
|
19
|
+
};
|
|
@@ -54,6 +54,8 @@ export class Database extends ReadyResource {
|
|
|
54
54
|
kind?: string;
|
|
55
55
|
verb?: string;
|
|
56
56
|
}>;
|
|
57
|
+
version: any;
|
|
58
|
+
behind: any;
|
|
57
59
|
routes: Record<string, Function>;
|
|
58
60
|
namespace: string;
|
|
59
61
|
encryptionKey: Uint8Array<ArrayBufferLike>;
|
|
@@ -216,6 +218,24 @@ export class Database extends ReadyResource {
|
|
|
216
218
|
* @returns {Promise<void>}
|
|
217
219
|
*/
|
|
218
220
|
write(ops: Array<[string, any]>): Promise<void>;
|
|
221
|
+
/**
|
|
222
|
+
* Record that the log contains ops from a newer app version than this
|
|
223
|
+
* peer understands. Fires `'behind'` once per version so apps can prompt
|
|
224
|
+
* an upgrade; the marker survives restarts via local core userData.
|
|
225
|
+
*
|
|
226
|
+
* @param {number} version
|
|
227
|
+
*/
|
|
228
|
+
_onFuture(version: number): void;
|
|
229
|
+
/**
|
|
230
|
+
* Validate ops against the committed view before appending: each op runs in
|
|
231
|
+
* a throwaway transaction with host effects stubbed, so a throwing handler
|
|
232
|
+
* rejects the write and nothing enters the permanent log. Permission gates
|
|
233
|
+
* that `return` are not rejections — apply stays the authority at
|
|
234
|
+
* linearization time.
|
|
235
|
+
*
|
|
236
|
+
* @param {Uint8Array[]} encoded
|
|
237
|
+
*/
|
|
238
|
+
_dryRun(encoded: Uint8Array[]): Promise<void>;
|
|
219
239
|
/**
|
|
220
240
|
* Read a row. With no `query`: list all (collection) or fetch the one
|
|
221
241
|
* record (single). With a string id: fetch that specific row.
|
|
@@ -247,6 +267,18 @@ export class Database extends ReadyResource {
|
|
|
247
267
|
* @returns {import('streamx').Readable}
|
|
248
268
|
*/
|
|
249
269
|
watch(name: string, query?: Query): import("streamx").Readable;
|
|
270
|
+
/**
|
|
271
|
+
* Delta subscription: batches of `{ prev, next }` row pairs instead of
|
|
272
|
+
* full snapshots. The first batch (and any batch after a view swap) carries
|
|
273
|
+
* the current matching rows as inserts with `reset: true` — replaying every
|
|
274
|
+
* batch into a Map keyed by row id always reconstructs current state.
|
|
275
|
+
* `limit`/`reverse` are not applied; deltas are unwindowed by design.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} name
|
|
278
|
+
* @param {Query} [query]
|
|
279
|
+
* @returns {import('streamx').Readable}
|
|
280
|
+
*/
|
|
281
|
+
changes(name: string, query?: Query): import("streamx").Readable;
|
|
250
282
|
/**
|
|
251
283
|
* First-run bootstrap: create the device writer, save it, and swap into it.
|
|
252
284
|
*
|
|
@@ -2,12 +2,8 @@ export function resolveStruct(name: any, v?: number): {
|
|
|
2
2
|
preencode(state: any, m: any): void;
|
|
3
3
|
encode(state: any, m: any): void;
|
|
4
4
|
decode(state: any): {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
blockLength: any;
|
|
8
|
-
byteOffset: any;
|
|
9
|
-
byteLength: any;
|
|
10
|
-
type: any;
|
|
5
|
+
prev: any;
|
|
6
|
+
next: any;
|
|
11
7
|
} | {
|
|
12
8
|
status: any;
|
|
13
9
|
reason: any;
|
|
@@ -21,18 +17,21 @@ export function resolveStruct(name: any, v?: number): {
|
|
|
21
17
|
name: any;
|
|
22
18
|
role: any;
|
|
23
19
|
noAccept: boolean;
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
export function getStruct(name: any, v?: number): {
|
|
27
|
-
preencode(state: any, m: any): void;
|
|
28
|
-
encode(state: any, m: any): void;
|
|
29
|
-
decode(state: any): {
|
|
20
|
+
} | {
|
|
30
21
|
coreKey: any;
|
|
31
22
|
blockOffset: any;
|
|
32
23
|
blockLength: any;
|
|
33
24
|
byteOffset: any;
|
|
34
25
|
byteLength: any;
|
|
35
26
|
type: any;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export function getStruct(name: any, v?: number): {
|
|
30
|
+
preencode(state: any, m: any): void;
|
|
31
|
+
encode(state: any, m: any): void;
|
|
32
|
+
decode(state: any): {
|
|
33
|
+
prev: any;
|
|
34
|
+
next: any;
|
|
36
35
|
} | {
|
|
37
36
|
status: any;
|
|
38
37
|
reason: any;
|
|
@@ -46,6 +45,13 @@ export function getStruct(name: any, v?: number): {
|
|
|
46
45
|
name: any;
|
|
47
46
|
role: any;
|
|
48
47
|
noAccept: boolean;
|
|
48
|
+
} | {
|
|
49
|
+
coreKey: any;
|
|
50
|
+
blockOffset: any;
|
|
51
|
+
blockLength: any;
|
|
52
|
+
byteOffset: any;
|
|
53
|
+
byteLength: any;
|
|
54
|
+
type: any;
|
|
49
55
|
};
|
|
50
56
|
};
|
|
51
57
|
export function getEnum(name: any): void;
|
|
@@ -85,6 +91,13 @@ export function getEncoding(name: any): {
|
|
|
85
91
|
byteLength: any;
|
|
86
92
|
type: any;
|
|
87
93
|
};
|
|
94
|
+
} | {
|
|
95
|
+
preencode(state: any, m: any): void;
|
|
96
|
+
encode(state: any, m: any): void;
|
|
97
|
+
decode(state: any): {
|
|
98
|
+
prev: any;
|
|
99
|
+
next: any;
|
|
100
|
+
};
|
|
88
101
|
};
|
|
89
102
|
export function encode(name: any, value: any, v?: number): any;
|
|
90
103
|
export function decode(name: any, buffer: any, v?: number): any;
|
package/types/network/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { channelTopic };
|
|
2
2
|
/**
|
|
3
3
|
* @typedef {object} NetworkOpts
|
|
4
4
|
* @property {import('../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
|
|
@@ -6,6 +6,8 @@ export function channelTopic(topic: any, channel: any): any;
|
|
|
6
6
|
* @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
|
|
7
7
|
* @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
|
|
8
8
|
* @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
|
|
9
|
+
* @property {any} [store] Corestore; required for mirrors (blind peers replicate its cores).
|
|
10
|
+
* @property {Array<string | Uint8Array>} [mirrors] Blind-peer public keys; each attached room/blob core is mirrored through them for offline sync.
|
|
9
11
|
*
|
|
10
12
|
* @typedef {{ replicate: (stream: any) => any }} Replicable
|
|
11
13
|
*/
|
|
@@ -15,7 +17,7 @@ export function channelTopic(topic: any, channel: any): any;
|
|
|
15
17
|
*/
|
|
16
18
|
export class Network extends ReadyResource {
|
|
17
19
|
/** @param {NetworkOpts} [opts] */
|
|
18
|
-
constructor({ identity, bootstrap, firewall, relayThrough, channel }?: NetworkOpts);
|
|
20
|
+
constructor({ identity, bootstrap, firewall, relayThrough, channel, store, mirrors }?: NetworkOpts);
|
|
19
21
|
identity: import("../index.js").Identity;
|
|
20
22
|
bootstrap: {
|
|
21
23
|
host: string;
|
|
@@ -24,12 +26,17 @@ export class Network extends ReadyResource {
|
|
|
24
26
|
firewall: (remotePublicKey: Uint8Array, payload: any) => boolean;
|
|
25
27
|
relayThrough: Uint8Array<ArrayBufferLike>[];
|
|
26
28
|
channel: string;
|
|
27
|
-
|
|
29
|
+
store: any;
|
|
30
|
+
mirrors: any[];
|
|
31
|
+
_dht: DHTTransport;
|
|
28
32
|
wakeup: any;
|
|
29
33
|
_replicateables: Set<any>;
|
|
30
34
|
_discoveries: Set<any>;
|
|
31
35
|
_injected: Set<any>;
|
|
32
36
|
_blind: any;
|
|
37
|
+
_blindPeering: any;
|
|
38
|
+
/** @returns {any} The underlying hyperswarm, or null before ready / after close. */
|
|
39
|
+
get swarm(): any;
|
|
33
40
|
/**
|
|
34
41
|
* Feed an externally-established connection — a Bluetooth L2CAP channel, a
|
|
35
42
|
* serial link, an in-process pair, any duplex — into the network. A raw
|
|
@@ -75,7 +82,7 @@ export class Network extends ReadyResource {
|
|
|
75
82
|
* @param {{ timeout?: number }} [opts]
|
|
76
83
|
* @returns {Promise<void>}
|
|
77
84
|
*/
|
|
78
|
-
flush(
|
|
85
|
+
flush(opts?: {
|
|
79
86
|
timeout?: number;
|
|
80
87
|
}): Promise<void>;
|
|
81
88
|
/**
|
|
@@ -149,9 +156,19 @@ export type NetworkOpts = {
|
|
|
149
156
|
* Optional network-isolation label; only same-channel peers meet.
|
|
150
157
|
*/
|
|
151
158
|
channel?: string;
|
|
159
|
+
/**
|
|
160
|
+
* Corestore; required for mirrors (blind peers replicate its cores).
|
|
161
|
+
*/
|
|
162
|
+
store?: any;
|
|
163
|
+
/**
|
|
164
|
+
* Blind-peer public keys; each attached room/blob core is mirrored through them for offline sync.
|
|
165
|
+
*/
|
|
166
|
+
mirrors?: Array<string | Uint8Array>;
|
|
152
167
|
};
|
|
153
168
|
export type Replicable = {
|
|
154
169
|
replicate: (stream: any) => any;
|
|
155
170
|
};
|
|
171
|
+
import { channelTopic } from './transports/dht.js';
|
|
156
172
|
import ReadyResource from 'ready-resource';
|
|
173
|
+
import { DHTTransport } from './transports/dht.js';
|
|
157
174
|
import { Discovery } from './discovery.js';
|
|
@@ -12,37 +12,36 @@ export function toServiceUUID(topic: Uint8Array, tag?: string): string;
|
|
|
12
12
|
* byte-stream to each discovered peer, and feeds it into `network.inject`. From
|
|
13
13
|
* there replication and pairing are transport-agnostic (see Network.inject).
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* ponytail: capability-handshake DoS link-scoring is deferred — it needs a
|
|
22
|
-
* replication-progress signal (design §4b). v1 caps links + times out dials.
|
|
15
|
+
* The server adds one data characteristic (write + notify) and advertises. The
|
|
16
|
+
* central connects, discovers the characteristic, subscribes, then framed bytes
|
|
17
|
+
* flow both ways — central→server as GATT writes, server→central as
|
|
18
|
+
* notifications — each tagged with an 8-byte session id. `backend` is
|
|
19
|
+
* bare-bluetooth in production and a mock in tests.
|
|
23
20
|
*
|
|
24
21
|
* @extends ReadyResource
|
|
25
22
|
*/
|
|
26
|
-
export class
|
|
23
|
+
export class BLETransport extends ReadyResource {
|
|
27
24
|
/**
|
|
28
25
|
* @param {object} opts
|
|
29
26
|
* @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
|
|
30
|
-
* @param {import('
|
|
27
|
+
* @param {import('../index.js').Network} opts.network
|
|
31
28
|
* @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
|
|
32
29
|
* @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
|
|
33
30
|
* @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
|
|
34
|
-
* @param {number} [opts.
|
|
31
|
+
* @param {number} [opts.maxOutbound] Max concurrent outbound dials/links; gossip covers the rest.
|
|
32
|
+
* @param {number} [opts.maxInbound] Max concurrent inbound sessions; newcomers past this are refused.
|
|
35
33
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
36
34
|
* @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
|
|
37
35
|
* @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
|
|
38
36
|
*/
|
|
39
|
-
constructor({ backend, network, uuid, nodeId, tag,
|
|
37
|
+
constructor({ backend, network, uuid, nodeId, tag, maxOutbound, maxInbound, scanOptions, keepLinks, name }: {
|
|
40
38
|
backend: any;
|
|
41
|
-
network: import("
|
|
39
|
+
network: import("../index.js").Network;
|
|
42
40
|
uuid: Uint8Array;
|
|
43
41
|
nodeId: Uint8Array;
|
|
44
42
|
tag?: string;
|
|
45
|
-
|
|
43
|
+
maxOutbound?: number;
|
|
44
|
+
maxInbound?: number;
|
|
46
45
|
scanOptions?: {
|
|
47
46
|
scanMode?: any;
|
|
48
47
|
};
|
|
@@ -50,12 +49,13 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
50
49
|
name?: string;
|
|
51
50
|
});
|
|
52
51
|
backend: any;
|
|
53
|
-
network: import("
|
|
52
|
+
network: import("../index.js").Network;
|
|
54
53
|
name: string;
|
|
55
54
|
nodeId: Uint8Array<ArrayBufferLike>;
|
|
56
55
|
nodeHex: any;
|
|
57
56
|
serviceUUID: string;
|
|
58
|
-
|
|
57
|
+
maxOutbound: number;
|
|
58
|
+
maxInbound: number;
|
|
59
59
|
scanOptions: {
|
|
60
60
|
scanMode?: any;
|
|
61
61
|
};
|
|
@@ -71,19 +71,8 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
71
71
|
_scanning: boolean;
|
|
72
72
|
_advertising: boolean;
|
|
73
73
|
_serviceAdded: boolean;
|
|
74
|
-
/** peripheral id
|
|
75
|
-
|
|
76
|
-
/** peripheral ids that carry a live channel — never re-dialed (a second
|
|
77
|
-
* dial's failure would disconnect the peripheral and kill the good link) */
|
|
78
|
-
_linked: Set<any>;
|
|
79
|
-
/** peripheral id → retry-after timestamp; failed dials back off */
|
|
80
|
-
_coolUntil: Map<any, any>;
|
|
81
|
-
/** peripheral id → consecutive failure count; drives exponential backoff */
|
|
82
|
-
_failures: Map<any, any>;
|
|
83
|
-
/** peripheral id → remote peer key, learned at handshake — dial guard */
|
|
84
|
-
_peerByPeripheral: Map<any, any>;
|
|
85
|
-
/** live central-side peripheral wrappers — for goodbye + physical hang-up on suspend */
|
|
86
|
-
_connectedPeripherals: Set<any>;
|
|
74
|
+
/** peripheral id → per-peer dial state { timer, linked, coolUntil, failures, peerKey, peripheral } */
|
|
75
|
+
_devices: Map<any, any>;
|
|
87
76
|
/** last central.connect timestamp — global inter-dial rate limit */
|
|
88
77
|
_lastDial: number;
|
|
89
78
|
_scanTimer: any;
|
|
@@ -100,21 +89,15 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
100
89
|
*/
|
|
101
90
|
shouldInitiate(peerNodeId: Uint8Array): boolean;
|
|
102
91
|
get linkCount(): number;
|
|
92
|
+
_device(id: any): any;
|
|
93
|
+
_prune(id: any): void;
|
|
103
94
|
_startServer(Service: any, Characteristic: any): void;
|
|
104
95
|
_maybeAdvertise(): void;
|
|
105
96
|
_onWriteRequests(requests: any): void;
|
|
106
97
|
_onServerFrame(data: any): void;
|
|
107
98
|
_closeServerSession(sidHex: any, sid: any): void;
|
|
108
|
-
/** Our hello payload: the local app-user name the peer labels this link with. */
|
|
109
99
|
_helloPayload(): any;
|
|
110
|
-
|
|
111
|
-
* Parse a hello payload. Malformed → null (the caller ignores it).
|
|
112
|
-
*
|
|
113
|
-
* @param {Uint8Array} payload
|
|
114
|
-
* @returns {string | null}
|
|
115
|
-
*/
|
|
116
|
-
_parseHello(payload: Uint8Array): string | null;
|
|
117
|
-
/** Stash a peer's name onto a server session + its conn, then refresh mirrors. */
|
|
100
|
+
_parseHello(payload: any): string;
|
|
118
101
|
_applyPeerName(session: any, payload: any): void;
|
|
119
102
|
_enqueueNotify(f: any): Promise<any>;
|
|
120
103
|
_drainNotify(): void;
|
|
@@ -130,26 +113,17 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
130
113
|
_writeOnce(peripheral: any, char: any, f: any): Promise<any>;
|
|
131
114
|
_abortDial(peripheral: any, _reason: any): void;
|
|
132
115
|
_clearDial(id: any): void;
|
|
116
|
+
_isDialing(): boolean;
|
|
133
117
|
_onCentralError(err: any): void;
|
|
134
|
-
_onChannel(
|
|
118
|
+
_onChannel(stream: any, isInitiator: any, peripheralId: any): any;
|
|
135
119
|
_track(conn: any, peripheralId: any, isInitiator: any): void;
|
|
136
120
|
_untrack(conn: any): void;
|
|
137
|
-
/**
|
|
138
|
-
* Best-effort TYPE_CLOSE to every live session — server sessions over the
|
|
139
|
-
* notify path, central sessions over the write path — reusing the same helpers
|
|
140
|
-
* a normal stream close uses. Waits up to DRAIN_MS for the frames to flush,
|
|
141
|
-
* then resolves regardless: suspend must never hang on a wedged radio.
|
|
142
|
-
*
|
|
143
|
-
* @returns {Promise<void>}
|
|
144
|
-
*/
|
|
145
121
|
_sayGoodbye(): Promise<void>;
|
|
146
122
|
/**
|
|
147
123
|
* Pause radio activity but KEEP the Server/Central instances and the
|
|
148
124
|
* registered GATT service alive — the toggle-friendly counterpart to _close.
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* keeps a duplicate GATT service registered; remote centrals then subscribe to
|
|
152
|
-
* the dead service and hear silence. Reuse one instance instead. Idempotent.
|
|
125
|
+
* CoreBluetooth managers can't be destroy()ed (native double-free), so one
|
|
126
|
+
* transport is reused across toggles rather than recreated. Idempotent.
|
|
153
127
|
*/
|
|
154
128
|
suspend(): Promise<void>;
|
|
155
129
|
/**
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function channelTopic(topic: any, channel: any): any;
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {object} DHTTransportOpts
|
|
4
|
+
* @property {import('../../identity/index.js').Identity} [identity] Long-lived keypair used as the swarm identity.
|
|
5
|
+
* @property {Array<{ host: string, port: number }>} [bootstrap] Custom DHT bootstrap nodes.
|
|
6
|
+
* @property {(remotePublicKey: Uint8Array, payload: any) => boolean} [firewall] Incoming-connection filter.
|
|
7
|
+
* @property {Uint8Array[]} [relayThrough] Relay public keys to tunnel through.
|
|
8
|
+
* @property {string} [channel] Optional network-isolation label; only same-channel peers meet.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The internet transport: a Hyperswarm that finds peers over the DHT. Owns the
|
|
12
|
+
* swarm's whole lifecycle — construction (identity keyPair, bootstrap, firewall,
|
|
13
|
+
* relay), the channel-topic join/leave wrapping, flush, suspend/resume, and
|
|
14
|
+
* teardown. {@link Network} subscribes to `this.swarm`'s connection/peer events
|
|
15
|
+
* and drives topic joins; the swarm-specific wiring lives here.
|
|
16
|
+
*/
|
|
17
|
+
export class DHTTransport {
|
|
18
|
+
/** @param {DHTTransportOpts} [opts] */
|
|
19
|
+
constructor({ identity, bootstrap, firewall, relayThrough, channel }?: DHTTransportOpts);
|
|
20
|
+
swarm: any;
|
|
21
|
+
/** @returns {boolean} */
|
|
22
|
+
get suspended(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Wait for pending DHT announces and lookups to settle, bounded by timeout.
|
|
25
|
+
*
|
|
26
|
+
* @param {{ timeout?: number }} [opts]
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
flush({ timeout }?: {
|
|
30
|
+
timeout?: number;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Pause the swarm — keeps state, drops sockets. Idempotent.
|
|
34
|
+
*
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
suspend(): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Resume a suspended swarm. Idempotent.
|
|
40
|
+
*
|
|
41
|
+
* @returns {Promise<void>}
|
|
42
|
+
*/
|
|
43
|
+
resume(): Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Flush pending discovery, then tear down the swarm. Idempotent.
|
|
46
|
+
*
|
|
47
|
+
* @returns {Promise<void>}
|
|
48
|
+
*/
|
|
49
|
+
destroy(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export type DHTTransportOpts = {
|
|
52
|
+
/**
|
|
53
|
+
* Long-lived keypair used as the swarm identity.
|
|
54
|
+
*/
|
|
55
|
+
identity?: import("../../identity/index.js").Identity;
|
|
56
|
+
/**
|
|
57
|
+
* Custom DHT bootstrap nodes.
|
|
58
|
+
*/
|
|
59
|
+
bootstrap?: Array<{
|
|
60
|
+
host: string;
|
|
61
|
+
port: number;
|
|
62
|
+
}>;
|
|
63
|
+
/**
|
|
64
|
+
* Incoming-connection filter.
|
|
65
|
+
*/
|
|
66
|
+
firewall?: (remotePublicKey: Uint8Array, payload: any) => boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Relay public keys to tunnel through.
|
|
69
|
+
*/
|
|
70
|
+
relayThrough?: Uint8Array[];
|
|
71
|
+
/**
|
|
72
|
+
* Optional network-isolation label; only same-channel peers meet.
|
|
73
|
+
*/
|
|
74
|
+
channel?: string;
|
|
75
|
+
};
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A dumb byte-carrying duplex for the GATT transport. Framing and session logic
|
|
3
|
-
* live in
|
|
4
|
-
* write and pushes inbound payload bytes.
|
|
5
|
-
* duplex, exactly like the old L2CAP channel.
|
|
3
|
+
* live in BLETransport; this only fragments outbound writes to fit a GATT
|
|
4
|
+
* write and pushes inbound payload bytes.
|
|
6
5
|
*
|
|
7
6
|
* @extends Duplex
|
|
8
7
|
*/
|
package/types/rpc/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* @property {(type: string, row: any) => Uint8Array} encodeRow
|
|
10
10
|
* @property {(type: string, buf: Uint8Array) => any} decodeRow
|
|
11
11
|
* @property {(type: string, rows: any[]) => Uint8Array} encodeRows
|
|
12
|
+
* @property {(type: string, changes: Array<{ prev: any, next: any }>) => Uint8Array} encodeChanges
|
|
13
|
+
* @property {(type: string, buf: Uint8Array) => Array<{ prev: any, next: any }>} decodeChanges
|
|
12
14
|
* @property {(type: string, buf: Uint8Array) => any[]} decodeRows
|
|
13
15
|
* @property {(q: any) => Uint8Array} encodeQuery
|
|
14
16
|
* @property {(buf: Uint8Array) => any} decodeQuery
|
|
@@ -57,6 +59,14 @@ export type Codec = {
|
|
|
57
59
|
encodeRow: (type: string, row: any) => Uint8Array;
|
|
58
60
|
decodeRow: (type: string, buf: Uint8Array) => any;
|
|
59
61
|
encodeRows: (type: string, rows: any[]) => Uint8Array;
|
|
62
|
+
encodeChanges: (type: string, changes: Array<{
|
|
63
|
+
prev: any;
|
|
64
|
+
next: any;
|
|
65
|
+
}>) => Uint8Array;
|
|
66
|
+
decodeChanges: (type: string, buf: Uint8Array) => Array<{
|
|
67
|
+
prev: any;
|
|
68
|
+
next: any;
|
|
69
|
+
}>;
|
|
60
70
|
decodeRows: (type: string, buf: Uint8Array) => any[];
|
|
61
71
|
encodeQuery: (q: any) => Uint8Array;
|
|
62
72
|
decodeQuery: (buf: Uint8Array) => any;
|