mirror of https://codeberg.org/pzp/pzp-net.git
init
This commit is contained in:
commit
f0070499d6
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
.vscode
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
coverage
|
||||
*~
|
||||
lib/*.d.ts
|
|
@ -0,0 +1,2 @@
|
|||
semi: false
|
||||
singleQuote: true
|
|
@ -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.
|
|
@ -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
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
declare module 'pull-cat' {
|
||||
function concat(...args: Array<any>): any;
|
||||
export = concat;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
declare module 'pull-notify' {
|
||||
interface Notify {
|
||||
(data: any): void;
|
||||
listen(): unknown;
|
||||
end(): void;
|
||||
}
|
||||
function CreateNotify(): Notify
|
||||
export = CreateNotify
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
declare module 'pull-ping' {
|
||||
function pullPing(opts: {timeout: number, serve?: boolean}): unknown;
|
||||
export = pullPing;
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,11 @@
|
|||
class Firewall {
|
||||
constructor() {
|
||||
// FIXME: implement
|
||||
}
|
||||
|
||||
start() {
|
||||
// FIXME: implement
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Firewall
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,15 @@
|
|||
class Scheduler {
|
||||
constructor() {
|
||||
// FIXME: implement
|
||||
}
|
||||
|
||||
start() {
|
||||
// FIXME: implement
|
||||
}
|
||||
|
||||
stop() {
|
||||
// FIXME: implement
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Scheduler
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"net:staltz.com:8008~noauth": {
|
||||
"source": "stored"
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
|
@ -0,0 +1,12 @@
|
|||
absolute
|
||||
025xyyx rubbish !!!!file--------that
|
||||
cannot be
|
||||
healed because =======
|
||||
it's not-f-c-c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON####
|
||||
of any kind
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"net:staltz.com:8008~noauth": {
|
||||
"duration": {
|
||||
"mean": 0,
|
||||
"stdev": 0,
|
||||
"count": 440,
|
||||
"sum": 0,
|
||||
"sqsum": 0
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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 }
|
|
@ -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"]
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue