This commit is contained in:
Andre Staltz 2024-01-12 13:15:51 +02:00
commit f0070499d6
No known key found for this signature in database
GPG Key ID: 9EDE23EA7E8A4890
26 changed files with 1283 additions and 0 deletions

25
.github/workflows/node.js.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm test

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.vscode
node_modules
pnpm-lock.yaml
package-lock.json
coverage
*~
lib/*.d.ts

2
.prettierrc.yaml Normal file
View File

@ -0,0 +1,2 @@
semi: false
singleQuote: true

20
LICENSE Normal file
View File

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2024 Andre 'Staltz' Medeiros <contact@staltz.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

18
README.md Normal file
View File

@ -0,0 +1,18 @@
**Work in progress**
## Installation
We're not on npm yet. In your package.json, include this as
```js
"ppppp-net": "github:staltz/ppppp-net"
```
**TODO:**
- [x] connect
- [x] stage
- [x] stats.json
- [ ] interpool glue
- [ ] firewall
- [ ] scheduler

7
declarations/atomic-file-rw.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
type CB<T> = (...args: [Error] | [null, T]) => void
declare module 'atomic-file-rw' {
export function readFile(path: string, encoding: string, cb: CB<string>): void;
export function writeFile(path: string, data: string, encoding: string, cb: CB<string>): void;
export function deleteFile(path: string, cb: CB<null>): void;
}

7
declarations/multiserver.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare module 'multiserver/plugins/net' {
interface NetPlugin {
parse(addr: string): { host: string; port: number } | undefined
}
function createNetPlugin(options: any): NetPlugin
export = createNetPlugin
}

4
declarations/pull-cat.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module 'pull-cat' {
function concat(...args: Array<any>): any;
export = concat;
}

9
declarations/pull-notify.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module 'pull-notify' {
interface Notify {
(data: any): void;
listen(): unknown;
end(): void;
}
function CreateNotify(): Notify
export = CreateNotify
}

5
declarations/pull-ping.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module 'pull-ping' {
function pullPing(opts: {timeout: number, serve?: boolean}): unknown;
export = pullPing;
}

276
lib/connections.js Normal file
View File

@ -0,0 +1,276 @@
const debug = require('debug')('ppppp:net:connections')
const createNotify = require('pull-notify')
const run = require('promisify-tuple')
const IP = require('ip')
const msNetPlugin = require('multiserver/plugins/net')({})
/**
* @typedef {import('./index').RpcConnectListener} RpcConnectListener
* @typedef {import('./index').Address} Address
* @typedef {import('./index').RPC} RPC
* @typedef {import('./index').Peer} Peer
* @typedef {import('./infos').Info} Info
* @typedef {import('./infos')} Infos
*/
class Connections {
/** @type {Peer} */
#peer
/** @type {Infos} */
#infos
/** @type {boolean} */
#closed
/** @type {ReturnType<createNotify>} */
#notifyEvent
/** @type {Map<Address, RPC>} */
#rpcs
/**
* Used only to schedule a connect when a disconnect is in progress.
* @type {Set<Address>}
*/
#connectRetries
/**
* @param {Peer} peer
* @param {Infos} infos
*/
constructor(peer, infos) {
this.#peer = peer
this.#infos = infos
this.#closed = false
this.#notifyEvent = createNotify()
this.#rpcs = new Map()
this.#connectRetries = new Set()
this.#peer.addListener('rpc:connect', this.#onRpcConnect)
}
/**
* @param {Address} address
* @returns {Info['inferredType']}
*/
static inferPeerType(address) {
// TODO perhaps the `type` should be provided by each multiserver plugin?
// like when multiserver plugins provide the `stream.address` to secret-stack
if (address.startsWith('tunnel:')) return 'tunnel'
if (address.startsWith('net:')) {
const netAddr = address.split('~')[0]
const parsed = msNetPlugin.parse(netAddr)
if (parsed?.host) {
if (IP.isPrivate(parsed.host)) return 'lan'
else return 'internet'
}
}
return
}
#assertNotClosed() {
if (this.#closed) {
throw new Error('This Connections instance is closed, create a new one.')
}
}
/**
* @type {RpcConnectListener}
*/
#onRpcConnect = (rpc, weAreClient) => {
// Don't process self connections, whatever that means:
if (rpc.shse.pubkey === this.#peer.shse.pubkey) return
// This branch is already handled by this.connect()
if (weAreClient) return
this.#prepareConnectedRPC(rpc.stream.address, rpc, weAreClient)
}
/**
* @type {(address: Address, rpc: RPC, weAreClient: boolean) => void}
*/
#prepareConnectedRPC = (address, rpc, weAreClient) => {
const initiator = weAreClient ? 'we' : 'they'
debug('Connected to %s, %s initiated it', address, initiator)
this.#rpcs.set(address, rpc)
rpc.once('closed', () => {
debug('Disconnected from %s', address)
this.#rpcs.delete(address)
this.#infos.update(address, { state: 'disconnected' })
this.#notifyEvent({ type: 'disconnected', address })
this.#infos.emit()
})
const state = /**@type {Info['state']}*/ ('connected')
const inferredType = Connections.inferPeerType(address)
this.#infos.update(address, { state, inferredType })
this.#notifyEvent({
type: state,
address,
details: { rpc, weAreClient },
})
this.#infos.emit()
}
/**
* @param {string} address
* @returns {Promise<RPC>}
*/
async connect(address) {
this.#assertNotClosed()
// this._assertValidAddress(address);
const prevInfo = this.#infos.get(address)
switch (prevInfo?.state ?? 'disconnected') {
case 'connected': {
const rpc = this.#rpcs.get(address)
if (!rpc) {
// prettier-ignore
throw new Error(`Failed to connect to ${address} due to inconsistent internal state`);
}
return rpc
}
case 'disconnecting': {
// If disconnecting, schedule a connect() after disconnection completed
this.#connectRetries.add(address)
// note: control flow should fall through below!
}
case 'connecting': {
return new Promise((resolve, reject) => {
let timeout = 100
const checkAgain = () => {
const rpc = this.#rpcs.get(address)
if (rpc) resolve(rpc)
else if (timeout > 5 * 60e3) {
// prettier-ignore
reject(new Error(`Failed to connect to ${address} after waiting a long time`))
} else {
timeout *= 2
setTimeout(checkAgain, timeout)
}
}
checkAgain()
})
}
case 'disconnected': {
debug('Connecting to %s', address)
const state = /**@type {Info['state']}*/ ('connecting')
this.#infos.update(address, { state })
this.#notifyEvent({ type: state, address })
this.#infos.emit()
const [err, rpc] = await run(this.#peer.connect)(address)
if (err) {
this.#infos.update(address, { state: 'disconnected' })
debug('Failed to connect to %s because: %s', address, err.message)
this.#notifyEvent({
type: 'connecting-failed',
address,
details: err,
})
this.#infos.emit()
throw err
}
const concurrentInfo = this.#infos.get(address)
if (!concurrentInfo || concurrentInfo.state !== 'connected') {
this.#prepareConnectedRPC(address, rpc, true)
return rpc
} else {
const rpc2 = this.#rpcs.get(address)
if (!rpc2) {
// prettier-ignore
throw new Error(`Failed to connect to ${address} due to inconsistent internal state`);
}
return rpc2
}
}
default: {
// prettier-ignore
debug('Unexpected control flow, peer %s has bad state %o', address, prevInfo)
// prettier-ignore
throw new Error(`Unexpected control flow, peer ${address} has bad state "${prevInfo?.state ?? '?'}"`)
}
}
}
/**
* @param {Address} address
* @returns {Promise<boolean>}
*/
async disconnect(address) {
this.#assertNotClosed()
const prevInfo = this.#infos.get(address)
if (!prevInfo || prevInfo?.state === 'disconnected') return false
if (prevInfo.state === 'disconnecting') return false
/**@type {RPC}*/
let rpc
if (prevInfo.state === 'connecting') {
rpc = await new Promise((resolve) => {
let timeout = 100
const checkAgain = () => {
const rpc = this.#rpcs.get(address)
if (rpc) resolve(rpc)
else {
timeout *= 2
timeout = Math.min(timeout, 30e3)
setTimeout(checkAgain, 100)
}
}
checkAgain()
})
} else if (prevInfo.state === 'connected') {
const maybeRPC = this.#rpcs.get(address)
if (!maybeRPC) {
// prettier-ignore
throw new Error(`Failed to disconnect from ${address} due to inconsistent internal state`);
} else {
rpc = maybeRPC
}
}
debug('Disconnecting from %s', address)
const state = /**@type {Info['state']}*/ ('disconnecting')
this.#infos.update(address, { state })
this.#notifyEvent({ type: state, address })
this.#infos.emit()
// @ts-ignore
await run(rpc.close)(true)
// Additional cleanup will execute in the "closed" event handler
// Re-connect because while disconnect() was running,
// someone called connect()
if (this.#connectRetries.has(address)) {
this.#connectRetries.delete(address)
this.connect(address)
}
return true
}
listen() {
this.#assertNotClosed()
return this.#notifyEvent.listen()
}
reset() {
if (this.#closed) return
for (const rpc of this.#rpcs.values()) {
rpc.close(true)
}
}
close() {
this.reset()
this.#peer.removeListener('rpc:connect', this.#onRpcConnect)
this.#closed = true
this.#rpcs.clear()
this.#connectRetries.clear()
this.#notifyEvent.end()
debug('Closed')
}
}
module.exports = Connections

11
lib/firewall.js Normal file
View File

@ -0,0 +1,11 @@
class Firewall {
constructor() {
// FIXME: implement
}
start() {
// FIXME: implement
}
}
module.exports = Firewall

176
lib/index.js Normal file
View File

@ -0,0 +1,176 @@
const pullPing = require('pull-ping')
const Path = require('path')
const Infos = require('./infos')
const Stats = require('./stats')
const Connections = require('./connections')
const Scheduler = require('./scheduler')
/**
* @typedef {string} Address
* @typedef {(rpc: RPC, weAreClient: boolean) => void} RpcConnectListener
* @typedef {{
* shse: {pubkey: string};
* close: {
* (errOrEnd: boolean, cb?: CB<void>): void,
* hook(hookIt: (this: unknown, fn: any, args: any) => any): void
* };
* connect(address: string, cb: CB<RPC>): void;
* once(event: 'closed', cb: CB<void>): void;
* addListener(event: 'rpc:connect', listener: RpcConnectListener): void;
* removeListener(event: 'rpc:connect', listener: RpcConnectListener): void;
* }} Peer
* @typedef {Peer & {stream: {address: string}}} RPC
* @typedef {{
* global: {
* path?: string
* timers?: {
* ping?: number
* },
* },
* net?: {
* autostart?: boolean,
* persistTimeout?: number,
* },
* }} Config
* @typedef {Config & {global: {path: string}}} ExpectedConfig
* @typedef {import('./infos').Info} Info
*/
/**
* @template T
* @typedef {(...args: [Error] | [null, T]) => void } CB
*/
/**
* @param {Config} config
* @returns {asserts config is ExpectedConfig}
*/
function assertValidConfig(config) {
if (typeof config.global?.path !== 'string') {
throw new Error('net plugin requires config.global.path')
}
}
/**
* @param {Peer} peer
* @param {Config} config
*/
function initNet(peer, config) {
assertValidConfig(config)
const autostart = config.net?.autostart ?? true
const netDir = Path.join(config.global.path, 'net')
const infos = new Infos()
const stats = new Stats(netDir, infos, config.net?.persistTimeout)
const connections = new Connections(peer, infos)
const scheduler = new Scheduler()
peer.close.hook(function (fn, args) {
scheduler.stop()
connections.close()
stats.close()
return fn.apply(this, args)
})
if (autostart) {
start()
}
async function start() {
await stats.loaded()
queueMicrotask(scheduler.start.bind(scheduler))
}
function stop() {
scheduler.stop()
}
/**
* @param {Address} address
* @param {Partial<Info>} info
*/
function stage(address, info) {
if (info.state) throw new Error('Cannot stage peer info with "state" field')
if (infos.has(address)) {
return false
} else {
infos.update(address, info)
return true
}
}
/**
* @param {Address} address
* @param {CB<RPC>} cb
*/
function connect(address, cb) {
connections.connect(address).then(
(result) => cb(null, result),
(err) => cb(err)
)
}
/**
* @param {Address} address
* @param {CB<boolean>} cb
*/
function disconnect(address, cb) {
return connections.disconnect(address).then(
(result) => cb(null, result),
(err) => cb(err)
)
}
/**
* @param {Address} address
* @param {Info} info
*/
function updateInfo(address, info) {
infos.update(address, info)
}
function listen() {
return connections.listen()
}
function peers() {
return infos.liveEntries()
}
function ping() {
const MIN = 10e3 // 10sec
const DEFAULT = 5 * 60e3 // 5min
const MAX = 30 * 60e3 // 30min
let timeout = config.global.timers?.ping ?? DEFAULT
timeout = Math.max(MIN, Math.min(timeout, MAX))
return pullPing({ timeout })
}
return {
start,
stop,
stage,
connect,
disconnect,
updateInfo,
listen,
peers,
ping,
}
}
exports.name = 'net'
exports.manifest = {
start: 'sync',
stop: 'sync',
stage: 'sync',
connect: 'async',
disconnect: 'async',
listen: 'source',
peers: 'source',
ping: 'duplex',
}
exports.permissions = {
anonymous: { allow: ['ping'] },
}
exports.init = initNet

91
lib/infos.js Normal file
View File

@ -0,0 +1,91 @@
const createNotify = require('pull-notify')
const pullConcat = require('pull-cat')
const pull = require('pull-stream')
/**
* @typedef {import('./index').Address} Address
* @typedef {import('./stats').StatsInfo} StatsInfo
* @typedef {{
* state: 'connected' | 'disconnected' | 'connecting' | 'disconnecting',
* connBirth?: number,
* connUpdated?: number,
* inferredType?: 'internet' | 'lan' | 'tunnel' | undefined;
* stats?: StatsInfo
* }} Info
*/
class Infos {
/** @type {Map<Address, Info>} */
#map
/** @type {ReturnType<createNotify>} */
#notify
constructor() {
this.#map = new Map()
this.#notify = createNotify()
}
/**
* @param {Address} address
* @returns {Info | undefined}
*/
get(address) {
return this.#map.get(address)
}
/**
* @param {Address} address
* @returns {boolean}
*/
has(address) {
return this.#map.has(address)
}
/**
* @param {Address} address
* @param {Partial<Info>} info
* @returns {void}
*/
update(address, info) {
const now = Date.now()
const connUpdated = now // FIXME: not just conn
const prevInfo = this.#map.get(address)
if (prevInfo) {
for (const key of Object.keys(info)) {
const k = /**@type {keyof Info}*/ (key)
if (typeof info[k] === 'undefined') delete info[k]
}
this.#map.set(address, { ...prevInfo, connUpdated, ...info })
} else if (!info.state) {
this.#map.set(address, { ...info, state: 'disconnected' })
} else {
const connBirth = now
this.#map.set(address, {
.../**@type {Info}*/ (info),
connBirth,
connUpdated,
})
}
}
size() {
return this.#map.size
}
emit() {
this.#notify(Array.from(this.#map.entries()))
}
entries() {
return this.#map.entries()
}
liveEntries() {
return pullConcat([
pull.values([Array.from(this.#map.entries())]),
this.#notify.listen(),
])
}
}
module.exports = Infos

15
lib/scheduler.js Normal file
View File

@ -0,0 +1,15 @@
class Scheduler {
constructor() {
// FIXME: implement
}
start() {
// FIXME: implement
}
stop() {
// FIXME: implement
}
}
module.exports = Scheduler

194
lib/stats.js Normal file
View File

@ -0,0 +1,194 @@
const Path = require('path')
const FS = require('fs')
const debug = require('debug')('ppppp:net:stats')
const atomic = require('atomic-file-rw')
/**
* @typedef {import('./index').Address} Address
* @typedef {import('./infos')} Infos
* @typedef {{
* mean: number;
* stdev: number;
* count: number;
* sum: number;
* sqsum: number;
* }} Statistics
* @typedef {{
* birth?: number;
* key?: string;
* source?: string;
* failure?: number;
* stateChange?: number;
* duration?: Statistics;
* ping?: {
* rtt: Statistics;
* skew: Statistics;
* };
* [name: string]: any;
* }} StatsInfo
*/
/**
* @template T
* @typedef {import('./index').CB<T>} CB
*/
/**
* Automatically heal from corruption .json files.
*
* - Remove (some) extraneous characters from the end of the file
* - If nothing works, return empty object instead of crashing
*/
const SelfHealingJSONCodec = {
/**
* @param {any} obj
*/
encode(obj) {
return JSON.stringify(obj, null, 2)
},
/**
* @param {any} input
* @returns {Record<string, any>}
*/
decode(input) {
if (!input) return {}
const str = /**@type {string}*/ (input.toString())
const MAX_TRIM = 10
let foundCorruption = false
for (let i = 0; i < MAX_TRIM; i++) {
try {
return JSON.parse(str.substring(0, str.length - i))
} catch (err) {
if (!foundCorruption) {
foundCorruption = true
// prettier-ignore
console.warn(`WARNING: ppppp-net found a corrupted ${Stats.FILENAME} file and is attempting to heal it`)
}
continue
}
}
console.error(
`ERROR! ppppp-net failed to heal corrupted ${Stats.FILENAME} file`
)
return {}
},
}
class Stats {
/** @type {string} */
#path
/** @type {number} */
#persistTimeout
/** @type {boolean} */
#closed
/** @type {Infos} */
#infos
/** @type {Promise<true>} */
#loadedPromise
/** @type {(value: true) => void} */
// @ts-ignore
#loadedResolve
/** @type {(reason: any) => void} */
// @ts-ignore
#loadedReject
static FILENAME = 'stats.json'
static DEFAULT_PERSIST_TIMEOUT = 2000
/**
* @param {string} dir
* @param {Infos} infos
* @param {number | undefined} persistTimeout
*/
constructor(dir, infos, persistTimeout) {
this.#path = Path.join(dir, Stats.FILENAME)
this.#persistTimeout = persistTimeout ?? Stats.DEFAULT_PERSIST_TIMEOUT
this.#closed = false
this.#infos = infos
this.#loadedPromise = new Promise((resolve, reject) => {
this.#loadedResolve = resolve
this.#loadedReject = reject
})
this.#readFromDisk(this.#path, (err, fileContents) => {
if (err) {
this.#loadedReject(err)
debug(`Failed to load ${Stats.FILENAME}`)
return
} else if (fileContents) {
const vals = SelfHealingJSONCodec.decode(fileContents)
for (const [address, statsInfo] of Object.entries(vals)) {
this.#infos.update(address, { stats: statsInfo })
}
this.#loadedResolve(true)
debug('Loaded conn.json into ConnDB in memory')
} else {
atomic.writeFile(this.#path, '{}', 'utf8', () => {})
this.#loadedResolve(true)
// prettier-ignore
debug(`Created new ${Stats.FILENAME} because there was no existing one.`);
return
}
})
}
/**
* @param {string} path
* @param {CB<string | null>} cb
*/
#readFromDisk(path, cb) {
if (typeof localStorage !== 'undefined' && localStorage !== null) {
// In a browser
atomic.readFile(path, 'utf8', cb)
} else {
// In Node.js
if (FS.existsSync(path)) {
atomic.readFile(path, 'utf8', cb)
} else {
cb(null, null)
}
}
}
/**
* @param {CB<unknown>=} cb
* @returns {void}
*/
#writeToDisk(cb) {
if (this.#infos.size() === 0) return
debug(`Begun serializing and writing ${Stats.FILENAME}`)
const record = /**@type {Record<Address, StatsInfo>}*/ ({})
for (let [address, info] of this.#infos.entries()) {
if (info.stats) {
record[address] = info.stats
}
}
const json = SelfHealingJSONCodec.encode(record)
atomic.writeFile(this.#path, json, 'utf8', (err, x) => {
if (!err) debug(`Done serializing and writing ${Stats.FILENAME}`)
if (err) return cb?.(err)
cb?.(null, null)
})
}
close() {
this.#closed = true;
// FIXME: implement
// this._cancelScheduleWrite();
// this._write();
// this._map?.clear();
// (this as any)._map = void 0;
// (this as any)._notify = void 0;
// (this as any)._stateFile = void 0;
debug('Closed the Stats instance');
}
/**
* @returns {Promise<true>}
*/
loaded() {
return this.#loadedPromise
}
}
module.exports = Stats

79
package.json Normal file
View File

@ -0,0 +1,79 @@
{
"name": "ppppp-net",
"version": "1.0.0",
"description": "PPPPP plugin to manage connections with hubs and peers",
"author": "Andre Staltz <contact@staltz.com>",
"license": "MIT",
"homepage": "https://github.com/staltz/ppppp-net",
"repository": {
"type": "git",
"url": "git@github.com:staltz/ppppp-net.git"
},
"main": "index.js",
"files": [
"*.js",
"lib/*.js",
"lib/*.d.ts"
],
"types": "types/index.d.ts",
"exports": {
".": {
"require": "./lib/index.js"
}
},
"type": "commonjs",
"engines": {
"node": ">=18"
},
"dependencies": {
"@types/pull-stream": "^3.6.7",
"atomic-file-rw": "^0.3.0",
"debug": "^4.3.2",
"has-network2": ">=0.0.3",
"ip": "^1.1.5",
"multiserver": "3",
"on-change-network-strict": "1.0.0",
"on-wakeup": "^1.0.1",
"promisify-tuple": "^1.0.1",
"pull-cat": "~1.1.11",
"pull-notify": "^0.1.2",
"pull-pause": "~0.0.2",
"pull-ping": "^2.0.3",
"pull-stream": "^3.6.14",
"statistics": "^3.3.0",
"ziii": "~1.0.2"
},
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/ip": "^1.1.3",
"@types/node": "18",
"bs58": "^5.0.0",
"c8": "7",
"ppppp-caps": "file:../caps",
"ppppp-db": "file:../db",
"ppppp-dict": "file:../dict",
"ppppp-keypair": "file:../keypair",
"ppppp-set": "file:../set",
"prettier": "^2.6.2",
"pretty-quick": "^3.1.3",
"rimraf": "^4.4.0",
"secret-handshake-ext": "0.0.11",
"secret-stack": "~8.1.0",
"ssb-box": "^1.0.1",
"typescript": "^5.1.3"
},
"scripts": {
"clean-check": "tsc --build --clean",
"prepublishOnly": "npm run clean-check && tsc --build",
"postpublish": "npm run clean-check",
"test": "npm run clean-check && node --test",
"format-code": "prettier --write \"(lib|test)/**/*.js\"",
"format-code-staged": "pretty-quick --staged --pattern \"(lib|test)/**/*.js\"",
"coverage": "c8 --reporter=lcov npm run test"
},
"husky": {
"hooks": {
"pre-commit": "npm run format-code-staged"
}
}
}

1
test/fixtures/absent/README.md vendored Normal file
View File

@ -0,0 +1 @@
The purpose of this file is to show, in git, that this directory should be empty, to test the use case where `stats.json` is absent.

7
test/fixtures/corrupted/stats.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"net:staltz.com:8008~noauth": {
"source": "stored"
}
},
}
},

12
test/fixtures/irrecoverable/stats.json vendored Normal file
View File

@ -0,0 +1,12 @@
absolute
025xyyx rubbish !!!!file--------that
cannot be
healed because =======
it's not-f-c-c
}
}
}
}
}
JSON####
of any kind

11
test/fixtures/present/stats.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"net:staltz.com:8008~noauth": {
"duration": {
"mean": 0,
"stdev": 0,
"count": 440,
"sum": 0,
"sqsum": 0
}
}
}

46
test/glue.test.js Normal file
View File

@ -0,0 +1,46 @@
const test = require('node:test')
const assert = require('node:assert')
const p = require('node:util').promisify
const { createPeerMock } = require('./util')
const TEST_ADDR =
'net:localhost:9752~shse:EqTMFv7zm8hpPyAkj789qdJgqtz81AEbcinpAs24RRUC'
test('Glueing together stats with connections', async (t) => {
await t.test('stage() is ignored when peer already connected', async () => {
const peer = createPeerMock()
const address = TEST_ADDR
const result = await p(peer.net.connect)(address)
assert.ok(result, 'connect was succesful')
const entriesBefore = await p(peer.net.peers())(null)
assert.equal(entriesBefore.length, 1, 'there is one entry in peers()')
assert.equal(entriesBefore[0][0], address, 'entry addr ok')
assert.equal(entriesBefore[0][1].state, 'connected', 'entry state ok')
const stagingResult = peer.net.stage(address, { mode: 'internet' })
assert.equal(stagingResult, false, 'stage() should refuse')
const entriesAfter = await p(peer.net.peers())(null)
assert.equal(entriesAfter.length, 1, 'there is one entry in peers()')
assert.equal(entriesAfter[0][0], address, 'entry addr ok')
assert.equal(entriesAfter[0][1].state, 'connected', 'entry state ok')
})
await t.test('stage() successful', async (t) => {
const peer = createPeerMock()
const address = TEST_ADDR
const entriesBefore = await p(peer.net.peers())(null)
assert.equal(entriesBefore.length, 0, 'there is no entry in peers()')
const stagingResult = peer.net.stage(address, { mode: 'internet' })
assert.equal(stagingResult, true, 'stage() should refuse')
const entriesAfter = await p(peer.net.peers())(null)
assert.equal(entriesAfter.length, 1, 'there is one entry in peers()')
assert.equal(entriesAfter[0][0], address, 'entry addr ok')
assert.equal(entriesAfter[0][1].state, 'disconnected', 'entry state ok')
})
})

85
test/index.test.js Normal file
View File

@ -0,0 +1,85 @@
const test = require('node:test')
const assert = require('node:assert')
const p = require('node:util').promisify
const pull = require('pull-stream')
const { createPeer } = require('./util')
const TEST_ADDR =
'net:localhost:9752~shse:EqTMFv7zm8hpPyAkj789qdJgqtz81AEbcinpAs24RRUC'
test('net', async (t) => {
await t.test('connect() rejects given unreachable address', async () => {
const peer = createPeer({ name: 'alice' })
await assert.rejects(p(peer.net.connect)(TEST_ADDR), /ECONNREFUSED/)
await p(peer.close)(true)
})
await t.test('peers() emits all entries as they update', async () => {
const peer = createPeer({ name: 'alice' })
await new Promise((resolve, reject) => {
let i = 0
pull(
peer.net.peers(),
pull.drain((entries) => {
++i
if (i === 1) {
assert('FIRST EMISSION')
assert.equal(entries.length, 0, 'entries === []')
} else if (i === 2) {
assert('SECOND EMISSION')
assert.equal(entries.length, 1, 'there is one entry')
const entry = entries[0]
assert.equal(entry[0], TEST_ADDR, 'left is the address')
assert.equal(typeof entry[1], 'object', 'right is the data')
assert.equal(entry[1].state, 'connecting', 'state is connecting')
} else if (i === 3) {
assert('THIRD EMISSION')
assert.equal(entries.length, 1, 'entries === []')
const entry = entries[0]
assert.equal(entry[0], TEST_ADDR, 'left is the address')
assert.equal(typeof entry[1], 'object', 'right is the data')
assert.equal(entry[1].state, 'disconnected', 'state disconnected')
resolve()
} else {
reject(new Error('too many emissions'))
}
})
)
peer.net.connect(TEST_ADDR, () => {})
})
await p(peer.close)(true)
})
await t.test('listen() emits events', async () => {
const peer = createPeer({ name: 'alice' })
await new Promise((resolve, reject) => {
let i = 0
pull(
peer.net.listen(),
pull.drain((ev) => {
++i
if (i === 1) {
assert.equal(ev.type, 'connecting', 'event.type ok')
assert.equal(ev.address, TEST_ADDR, 'event.address ok')
} else if (i === 2) {
assert.equal(ev.type, 'connecting-failed', 'event.type ok')
assert.equal(ev.address, TEST_ADDR, 'event.address ok')
assert.ok(ev.details, 'event.details ok')
assert.equal(ev.details.code, 'ECONNREFUSED', 'event.details err')
resolve()
} else {
reject(new Error('too many emissions'))
}
})
)
peer.net.connect(TEST_ADDR, () => {})
})
await p(peer.close)(true)
})
})

78
test/stats.test.js Normal file
View File

@ -0,0 +1,78 @@
const test = require('node:test')
const assert = require('node:assert')
const Path = require('node:path')
const FS = require('node:fs')
const p = require('node:util').promisify
const Stats = require('../lib/stats')
const Infos = require('../lib/infos')
test('Stats', async (t) => {
await t.test('Recovers from corrupted JSON file', async () => {
const dirPath = Path.join(__dirname, './fixtures/corrupted')
const infos = new Infos()
const stats = new Stats(dirPath, infos)
assert.ok(stats, 'Stats instance was created')
const entriesBefore = Array.from(infos.entries())
assert.equal(entriesBefore.length, 0, 'before loaded(), there is no data')
await stats.loaded()
const entriesAfter = Array.from(infos.entries())
assert.equal(entriesAfter.length, 1, 'after loaded(), there is data')
const [address, info] = entriesAfter[0]
assert.equal(address, 'net:staltz.com:8008~noauth', 'the address looks ok')
assert.equal(info.stats.source, 'stored', 'the info looks ok')
})
await t.test('Creates JSON file when it is absent', async () => {
const dirPath = Path.join(__dirname, './fixtures/absent')
const statsJSONPath = Path.join(dirPath, './stats.json')
assert.equal(FS.existsSync(statsJSONPath), false, 'stats.json doesnt exist')
const infos = new Infos()
const stats = new Stats(dirPath, infos)
assert.ok(stats, 'Stats instance was created')
while (FS.existsSync(statsJSONPath) === false) {
await p(setTimeout)(1)
}
const fileContents = FS.readFileSync(statsJSONPath, 'utf8')
assert.equal(fileContents, '{}', 'stats.json data should be empty JSON')
FS.unlinkSync(statsJSONPath)
})
await t.test('Loads when JSON file is present', async () => {
const dirPath = Path.join(__dirname, './fixtures/present')
const statsJSONPath = Path.join(dirPath, './stats.json')
assert.equal(FS.existsSync(statsJSONPath), true, 'stats.json exists')
const infos = new Infos()
const stats = new Stats(dirPath, infos)
assert.ok(stats, 'Stats instance was created')
await stats.loaded()
const entries = Array.from(infos.entries())
assert.equal(entries.length === 1, true, 'stats has one entry')
assert.equal(entries[0][0], 'net:staltz.com:8008~noauth', 'entry addr ok')
assert.ok(entries[0][1].stats.duration, 'entry stats.duration ok')
})
await t.test('Cannot recover from totally broken JSON file', async () => {
const dirPath = Path.join(__dirname, './fixtures/irrecoverable')
const infos = new Infos()
const stats = new Stats(dirPath, infos)
assert.ok(stats, 'Stats instance was created')
const entriesBefore = Array.from(infos.entries())
assert.equal(entriesBefore.length, 0, 'before loaded(), there is no data')
await stats.loaded()
const entriesAfter = Array.from(infos.entries())
assert.equal(entriesAfter.length, 0, 'after loaded(), there is data')
})
})

80
test/util.js Normal file
View File

@ -0,0 +1,80 @@
const OS = require('node:os')
const FS = require('node:fs')
const Path = require('node:path')
const rimraf = require('rimraf')
const caps = require('ppppp-caps')
const Keypair = require('ppppp-keypair')
const net = require('../lib/index')
function createPeer(config) {
if (config.name) {
const name = config.name
const tmp = OS.tmpdir()
config.global ??= {}
config.global.path ??= Path.join(tmp, `ppppp-net-${name}-${Date.now()}`)
config.global.keypair ??= Keypair.generate('ed25519', name)
delete config.name
}
if (!config.global) {
throw new Error('need config.global in createPeer()')
}
if (!config.global.path) {
throw new Error('need config.global.path in createPeer()')
}
if (!config.global.keypair) {
throw new Error('need config.global.keypair in createPeer()')
}
rimraf.sync(config.global.path)
return require('secret-stack/bare')()
.use(require('secret-stack/plugins/net'))
.use(require('secret-handshake-ext/secret-stack'))
.use(net)
.call(null, {
shse: { caps },
...config,
global: {
connections: {
incoming: {
net: [{ scope: 'device', transform: 'shse', port: null }],
},
outgoing: {
net: [{ transform: 'shse' }],
},
},
...config.global,
},
})
}
function createPeerMock() {
const testPath = FS.mkdtempSync(Path.join(OS.tmpdir(), 'ppppp-net-'))
const mockPeer = {
addListener() {},
close: {
hook: () => {},
},
post: () => {},
connect: (_address, cb) => {
setTimeout(() => {
cb(null, {
once: () => {},
})
}, 200)
},
once: () => {},
}
const mockConfig = {
global: {
path: testPath,
},
// shse: { caps }
}
mockPeer.net = net.init(mockPeer, mockConfig)
return mockPeer
}
module.exports = { createPeer, createPeerMock }

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"include": ["declarations", "lib/**/*.js"],
"exclude": ["coverage/", "node_modules/", "test/"],
"compilerOptions": {
"checkJs": true,
"declaration": true,
"emitDeclarationOnly": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"lib": ["es2022", "dom"],
"module": "node16",
"skipLibCheck": true,
"strict": true,
"target": "es2022",
"typeRoots": ["node_modules/@types", "declarations"]
}
}