@qvac/registry-client 0.2.1 → 0.4.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/README.md CHANGED
@@ -139,11 +139,9 @@ The package includes a CLI for querying and downloading models from the registry
139
139
 
140
140
  ### Install
141
141
 
142
- The package is hosted on npm. Configure npm to use the registry for the `@qvac` scope, then install globally:
142
+ The package is hosted on npm. Install globally:
143
143
 
144
144
  ```bash
145
- echo "@qvac:registry=https://registry.npmjs.org" >> ~/.npmrc
146
- echo "//registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN" >> ~/.npmrc
147
145
  npm install -g @qvac/registry-client
148
146
  ```
149
147
 
@@ -226,6 +224,47 @@ Downloading ... -> /absolute/path/ggml-tiny-q8_0.bin
226
224
  Download complete: 41.52 MB
227
225
  ```
228
226
 
227
+ ### Profile download performance
228
+
229
+ Diagnose slow downloads by collecting UDX network stats, connection info, and hypercore metrics — similar to [hyperdrive-profiler](https://github.com/holepunchto/hyperdrive-profiler) but for registry Hyperblobs:
230
+
231
+ ```bash
232
+ $ qvac-registry profile \
233
+ "ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-tiny.bin" hf
234
+
235
+ --- 5.0s elapsed ---
236
+ Network (UDX)
237
+ Bytes received: 42.1MB (8.4MB/s)
238
+ Bytes transmitted: 128kB (25.6kB/s)
239
+ Packets rx/tx: 29034 / 1842
240
+ Packets dropped: 0
241
+ Connection
242
+ Firewalled: false
243
+ Blob peers: 2
244
+ Issues: rto=0 fast-recoveries=0 retransmits=0
245
+ Hypercore
246
+ Blob core: 665 / 665 (contiguous / length)
247
+ Hotswaps: 0
248
+ ...
249
+ ==================================================
250
+ FINAL SUMMARY
251
+ ==================================================
252
+ Download
253
+ Model: ggerganov/whisper.cpp/resolve/.../ggml-tiny.bin
254
+ Size: 73.5MB (1120 blocks)
255
+ Metadata: 2.15s
256
+ Transfer: 8.72s
257
+ Avg speed: 8.4MB/s
258
+ Total: 10.87s
259
+ ```
260
+
261
+ Flags:
262
+
263
+ ```
264
+ --interval|-i [seconds] Stats print interval (default: 5)
265
+ --timeout|-t [ms] Stream read timeout (default: 120000)
266
+ ```
267
+
229
268
  ### JSON output
230
269
 
231
270
  All commands support `--json` for machine-readable output:
@@ -250,6 +289,7 @@ See the `examples/` folder for complete working examples:
250
289
  - `download-model.js`: Download a single model to disk via metadata lookup
251
290
  - `download-blob.js`: Download a blob directly using known blob coordinates
252
291
  - `download-all-models.js`: Download all models in the registry
292
+ - `profile-download.js`: Profile download performance with network/connection/hypercore stats
253
293
 
254
294
  Run examples:
255
295
 
@@ -259,6 +299,7 @@ node examples/example.js
259
299
  node examples/download-model.js
260
300
  node examples/download-blob.js
261
301
  node examples/download-all-models.js
302
+ node examples/profile-download.js "model/path"
262
303
  ```
263
304
 
264
305
  ## Configuration
package/bin/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  const { command, flag, arg, summary, header, footer, description } = require('paparam')
5
5
  const { QVACRegistryClient } = require('../index')
6
+ const { profileDownload } = require('../lib/profiler')
6
7
  const IdEnc = require('hypercore-id-encoding')
7
8
  const path = require('#path')
8
9
 
@@ -191,6 +192,44 @@ const downloadCmd = command('download',
191
192
  }
192
193
  )
193
194
 
195
+ const profileCmd = command('profile',
196
+ summary('Profile download performance for a model'),
197
+ description('Downloads a model to a temp directory while collecting UDX network stats, connection info, and hypercore metrics. Similar to hyperdrive-profiler but for registry blobs.'),
198
+ arg('<path>', 'Model path'),
199
+ arg('[source]', 'Model source filter (e.g. hf, s3)'),
200
+ flag('--interval|-i [seconds]', 'Stats print interval in seconds (default: 5)'),
201
+ flag('--timeout|-t [ms]', 'Stream read timeout in ms (default: 120000)'),
202
+ async function (cmd) {
203
+ const rootFlags = getRootFlags(cmd)
204
+ const registryCoreKey = rootFlags.key || process.env.QVAC_REGISTRY_CORE_KEY
205
+
206
+ if (!registryCoreKey) {
207
+ console.error('Registry core key is required. Set QVAC_REGISTRY_CORE_KEY or use --key.')
208
+ process.exit(1)
209
+ }
210
+
211
+ const interval = cmd.flags.interval ? parseInt(cmd.flags.interval, 10) : 5
212
+ const timeout = cmd.flags.timeout ? parseInt(cmd.flags.timeout, 10) : 120000
213
+
214
+ if (Number.isNaN(interval) || interval <= 0) {
215
+ console.error('--interval must be a positive number')
216
+ process.exit(1)
217
+ }
218
+ if (Number.isNaN(timeout) || timeout <= 0) {
219
+ console.error('--timeout must be a positive number')
220
+ process.exit(1)
221
+ }
222
+
223
+ await profileDownload({
224
+ registryCoreKey,
225
+ modelPath: cmd.args.path,
226
+ source: cmd.args.source || undefined,
227
+ intervalSec: interval,
228
+ timeout
229
+ })
230
+ }
231
+ )
232
+
194
233
  // --- Root command ---
195
234
 
196
235
  const cmd = command('qvac-registry',
@@ -202,7 +241,8 @@ const cmd = command('qvac-registry',
202
241
  flag('--verbose|-v', 'Enable verbose/debug logging'),
203
242
  listCmd,
204
243
  getCmd,
205
- downloadCmd
244
+ downloadCmd,
245
+ profileCmd
206
246
  )
207
247
 
208
248
  cmd.parse()
package/index.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import ReadyResource = require('ready-resource')
2
+
3
+ export interface LifecycleLogOptions {
4
+ log?: (msg: string) => Promise<void> | void
5
+ }
6
+
1
7
  export interface QVACBlobBinding {
2
8
  coreKey: Buffer | string
3
9
  blockOffset: number
@@ -41,6 +47,8 @@ export interface QVACDownloadOptions {
41
47
  timeout?: number
42
48
  peerTimeout?: number
43
49
  outputFile?: string
50
+ onProgress?: (progress: { downloaded: number, total: number, cachedBlocks: number, totalBlocks: number }) => void
51
+ signal?: AbortSignal
44
52
  }
45
53
 
46
54
  export interface QVACBlobDownloadOptions {
@@ -78,11 +86,29 @@ export interface FindByParams {
78
86
  includeDeprecated?: boolean
79
87
  }
80
88
 
81
- export class QVACRegistryClient {
89
+ export interface LifecycleSwarmHandle {
90
+ readonly suspended: boolean
91
+ suspend (opts?: LifecycleLogOptions): Promise<void>
92
+ resume (opts?: LifecycleLogOptions): Promise<void>
93
+ }
94
+
95
+ export interface LifecycleStoreHandle {
96
+ suspend (opts?: LifecycleLogOptions): Promise<void>
97
+ resume (): Promise<void>
98
+ }
99
+
100
+ export class QVACRegistryClient extends ReadyResource {
82
101
  constructor (opts?: QVACRegistryClientOptions)
83
102
 
103
+ /** Valid only while the client remains open. Cached handles become stale after close(). */
104
+ readonly corestore: LifecycleStoreHandle | null
105
+ /** Valid only while the client remains open. Cached handles become stale after close(). */
106
+ readonly hyperswarm: LifecycleSwarmHandle | null
107
+
84
108
  ready (): Promise<void>
85
109
  close (): Promise<void>
110
+ suspend (opts?: LifecycleLogOptions): Promise<void>
111
+ resume (opts?: LifecycleLogOptions): Promise<void>
86
112
 
87
113
  getModel (path: string, source: string): Promise<QVACModelEntry | null>
88
114
  downloadModel (path: string, source: string, options?: QVACDownloadOptions): Promise<QVACDownloadResult>
package/lib/client.js CHANGED
@@ -526,6 +526,42 @@ class QVACRegistryClient extends ReadyResource {
526
526
  }
527
527
  }
528
528
 
529
+ async suspend (opts = {}) {
530
+ this.logger.debug('suspend called')
531
+
532
+ if (!this.opened || this.closing) {
533
+ this.logger.debug('Skipping suspend while client is not open or is closing')
534
+ return
535
+ }
536
+
537
+ if (this.hyperswarm && !this.hyperswarm.suspended) {
538
+ await this.hyperswarm.suspend(opts)
539
+ }
540
+ if (this.corestore) {
541
+ await this.corestore.suspend(opts)
542
+ }
543
+
544
+ this.logger.debug('QVACRegistryClient suspended')
545
+ }
546
+
547
+ async resume (opts = {}) {
548
+ this.logger.debug('resume called')
549
+
550
+ if (!this.opened || this.closing) {
551
+ this.logger.debug('Skipping resume while client is not open or is closing')
552
+ return
553
+ }
554
+
555
+ if (this.corestore) {
556
+ await this.corestore.resume()
557
+ }
558
+ if (this.hyperswarm && this.hyperswarm.suspended) {
559
+ await this.hyperswarm.resume(opts)
560
+ }
561
+
562
+ this.logger.debug('QVACRegistryClient resumed')
563
+ }
564
+
529
565
  async _close () {
530
566
  this.logger.debug('_close called')
531
567
 
@@ -0,0 +1,297 @@
1
+ 'use strict'
2
+
3
+ const Corestore = require('corestore')
4
+ const Hyperswarm = require('hyperswarm')
5
+ const Hyperblobs = require('hyperblobs')
6
+ const HypercoreStats = require('hypercore-stats')
7
+ const HyperswarmStats = require('hyperswarm-stats')
8
+ const IdEnc = require('hypercore-id-encoding')
9
+ const byteSize = require('tiny-byte-size')
10
+ const { RegistryDatabase } = require('@qvac/registry-schema')
11
+
12
+ /**
13
+ * Normalize a blob core key into a Buffer regardless of input format.
14
+ * Handles raw Buffer, { data: [...] } objects from HyperDB, and z32/hex strings.
15
+ */
16
+ function decodeCoreKey (key) {
17
+ if (Buffer.isBuffer(key)) return key
18
+ if (typeof key === 'object' && key !== null && key.data) {
19
+ return Buffer.from(key.data)
20
+ }
21
+ return IdEnc.decode(key)
22
+ }
23
+
24
+ /**
25
+ * Collect a snapshot of network, connection, and hypercore stats.
26
+ * All fields are plain values suitable for logging or formatting.
27
+ */
28
+ function collectStats (swarmStats, hypercoreStats, blobCore, elapsedSec) {
29
+ const bytesRx = swarmStats.dhtStats.udxBytesReceived
30
+ const bytesTx = swarmStats.dhtStats.udxBytesTransmitted
31
+
32
+ const peers = []
33
+ for (const peer of blobCore.peers) {
34
+ peers.push({
35
+ key: IdEnc.normalize(peer.remotePublicKey),
36
+ remoteLength: peer.remoteLength,
37
+ remoteContiguous: peer.remoteContiguousLength
38
+ })
39
+ }
40
+
41
+ return {
42
+ elapsedSec,
43
+ network: {
44
+ bytesRx,
45
+ bytesTx,
46
+ rxPerSec: elapsedSec > 0 ? bytesRx / elapsedSec : 0,
47
+ txPerSec: elapsedSec > 0 ? bytesTx / elapsedSec : 0,
48
+ packetsRx: swarmStats.dhtStats.udxPacketsReceived,
49
+ packetsTx: swarmStats.dhtStats.udxPacketsTransmitted,
50
+ packetsDropped: swarmStats.dhtStats.udxPacketsDropped
51
+ },
52
+ connection: {
53
+ firewalled: swarmStats.dhtStats.isFirewalled,
54
+ blobPeers: blobCore.peers.length,
55
+ attempted: swarmStats.connects.client.attempted,
56
+ opened: swarmStats.connects.client.opened,
57
+ closed: swarmStats.connects.client.closed,
58
+ rtos: swarmStats.getRTOCountAcrossAllStreams(),
59
+ fastRecoveries: swarmStats.getFastRecoveriesAcrossAllStreams(),
60
+ retransmits: swarmStats.getRetransmitsAcrossAllStreams()
61
+ },
62
+ hypercore: {
63
+ contiguousLength: blobCore.contiguousLength,
64
+ length: blobCore.length,
65
+ hotswaps: hypercoreStats.totalHotswaps
66
+ },
67
+ peers
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Format a stats snapshot into a human-readable string.
73
+ */
74
+ function formatStats (stats) {
75
+ const n = stats.network
76
+ const c = stats.connection
77
+ const h = stats.hypercore
78
+
79
+ let lines = '--- ' + stats.elapsedSec.toFixed(1) + 's elapsed ---\n'
80
+ lines += 'Network (UDX)\n'
81
+ lines += ' Bytes received: ' + byteSize(n.bytesRx) + ' (' + byteSize(n.rxPerSec) + '/s)\n'
82
+ lines += ' Bytes transmitted: ' + byteSize(n.bytesTx) + ' (' + byteSize(n.txPerSec) + '/s)\n'
83
+ lines += ' Packets rx/tx: ' + n.packetsRx + ' / ' + n.packetsTx + '\n'
84
+ lines += ' Packets dropped: ' + n.packetsDropped + '\n'
85
+ lines += 'Connection\n'
86
+ lines += ' Firewalled: ' + c.firewalled + '\n'
87
+ lines += ' Blob peers: ' + c.blobPeers + '\n'
88
+ lines += ' Attempted: ' + c.attempted + '\n'
89
+ lines += ' Opened: ' + c.opened + '\n'
90
+ lines += ' Closed: ' + c.closed + '\n'
91
+ lines += ' Issues: rto=' + c.rtos + ' fast-recoveries=' + c.fastRecoveries + ' retransmits=' + c.retransmits + '\n'
92
+ lines += 'Hypercore\n'
93
+ lines += ' Blob core: ' + h.contiguousLength + ' / ' + h.length + ' (contiguous / length)\n'
94
+ lines += ' Hotswaps: ' + h.hotswaps + '\n'
95
+
96
+ if (stats.peers.length > 0) {
97
+ lines += ' Peers:\n'
98
+ for (const p of stats.peers) {
99
+ lines += ' ' + p.key + ' remote=' + p.remoteContiguous + '/' + p.remoteLength + '\n'
100
+ }
101
+ }
102
+
103
+ return lines
104
+ }
105
+
106
+ /**
107
+ * Format a final download summary into a human-readable string.
108
+ */
109
+ function formatSummary (opts) {
110
+ let lines = '='.repeat(50) + '\n'
111
+ lines += 'FINAL SUMMARY\n'
112
+ lines += '='.repeat(50) + '\n'
113
+ lines += 'Download\n'
114
+ lines += ' Model: ' + opts.modelPath + '\n'
115
+ lines += ' Size: ' + byteSize(opts.totalBytes) + ' (' + opts.totalBlocks + ' blocks)\n'
116
+ lines += ' Metadata: ' + opts.metadataSec.toFixed(2) + 's\n'
117
+ lines += ' Transfer: ' + opts.transferSec.toFixed(2) + 's\n'
118
+ lines += ' Avg speed: ' + byteSize(opts.avgSpeed) + '/s\n'
119
+ lines += ' Total: ' + opts.totalSec.toFixed(2) + 's\n'
120
+ return lines
121
+ }
122
+
123
+ /**
124
+ * Profile a blob download from the registry, printing periodic stats.
125
+ *
126
+ * @param {object} opts
127
+ * @param {string} opts.registryCoreKey - Registry view core key (z32 or hex)
128
+ * @param {string} opts.modelPath - Model path in the registry
129
+ * @param {string} [opts.source] - Source filter (e.g. "hf", "s3")
130
+ * @param {number} [opts.intervalSec=5] - Stats print interval in seconds
131
+ * @param {number} [opts.timeout=120000] - Stream read timeout in ms
132
+ * @param {function} [opts.onStats] - Called with (formattedString, rawStats) on each interval
133
+ * @param {function} [opts.onLog] - Called with (message) for log output; defaults to console.log
134
+ * @returns {Promise<object>} Summary with timing and stats
135
+ */
136
+ async function profileDownload (opts) {
137
+ // Lazy-loaded: these Node builtins don't exist in Bare runtime,
138
+ // so they must not be required at the top level or unit tests break.
139
+ const os = require('#os')
140
+ const fs = require('#fs')
141
+ const path = require('#path')
142
+ const { performance } = require('perf_hooks')
143
+ const { pipeline } = require('stream/promises')
144
+
145
+ const {
146
+ registryCoreKey,
147
+ modelPath,
148
+ source,
149
+ intervalSec = 5,
150
+ timeout = 120000,
151
+ onStats,
152
+ onLog = console.log
153
+ } = opts
154
+
155
+ if (!registryCoreKey) throw new Error('registryCoreKey is required')
156
+ if (!modelPath) throw new Error('modelPath is required')
157
+
158
+ const tStart = performance.now()
159
+ const tmpdir = path.join(os.tmpdir(), 'qvac-profile-' + Date.now())
160
+ fs.mkdirSync(tmpdir, { recursive: true })
161
+
162
+ const store = new Corestore(tmpdir)
163
+ await store.ready()
164
+
165
+ const swarm = new Hyperswarm()
166
+ const hcStats = await HypercoreStats.fromCorestore(store, { cacheExpiryMs: 1000 })
167
+ const swStats = new HyperswarmStats(swarm)
168
+
169
+ swarm.on('connection', (conn, peerInfo) => {
170
+ const key = peerInfo?.publicKey ? IdEnc.normalize(peerInfo.publicKey) : 'unknown'
171
+ onLog(' [conn] peer ' + key + ' connected')
172
+ store.replicate(conn)
173
+ conn.on('error', (e) => onLog(' [conn] error: ' + e.message))
174
+ conn.on('close', () => onLog(' [conn] peer ' + key + ' disconnected'))
175
+ })
176
+
177
+ const cleanupResources = async () => {
178
+ await swarm.destroy()
179
+ await store.close()
180
+ try { fs.rmSync(tmpdir, { recursive: true, force: true }) } catch {}
181
+ }
182
+
183
+ try {
184
+ const viewKey = IdEnc.decode(registryCoreKey)
185
+ const viewCore = store.get({ key: viewKey })
186
+ await viewCore.ready()
187
+
188
+ onLog('View core key: ' + IdEnc.normalize(viewCore.key))
189
+
190
+ const foundPeers = viewCore.findingPeers()
191
+ swarm.join(viewCore.discoveryKey, { client: true, server: false })
192
+ swarm.flush().then(() => foundPeers())
193
+
194
+ await viewCore.update()
195
+ const metadataSec = (performance.now() - tStart) / 1000
196
+
197
+ const db = new RegistryDatabase(viewCore, { extension: false })
198
+ await db.ready()
199
+
200
+ onLog('Metadata synced in ' + metadataSec.toFixed(2) + 's (' + viewCore.length + ' blocks)')
201
+
202
+ const model = await db.getModel(modelPath, source || undefined)
203
+ if (!model) throw new Error('Model not found: ' + modelPath)
204
+
205
+ const blob = model.blobBinding
206
+ if (!blob || !blob.coreKey) throw new Error('Model has no blob binding')
207
+
208
+ onLog('\nProfiling download: ' + model.path)
209
+ onLog(' engine: ' + model.engine)
210
+ onLog(' source: ' + model.source)
211
+ onLog(' size: ' + byteSize(blob.byteLength) + ' (' + blob.blockLength + ' blocks)')
212
+ onLog('')
213
+
214
+ const coreKeyBuf = decodeCoreKey(blob.coreKey)
215
+ const blobCore = store.get({ key: coreKeyBuf })
216
+ await blobCore.ready()
217
+ const blobs = new Hyperblobs(blobCore)
218
+ await blobs.ready()
219
+
220
+ onLog('Blob core key: ' + IdEnc.normalize(blobCore.key))
221
+ onLog('Blob core discovery: ' + IdEnc.normalize(blobCore.discoveryKey))
222
+
223
+ const foundBlobPeers = blobCore.findingPeers()
224
+ swarm.join(blobCore.discoveryKey, { client: true, server: false })
225
+ swarm.flush().then(() => foundBlobPeers())
226
+
227
+ await blobCore.update()
228
+ onLog('Blob core synced: length=' + blobCore.length)
229
+ onLog('')
230
+
231
+ const outputFile = path.join(tmpdir, 'download.bin')
232
+ const tDownloadStart = performance.now()
233
+
234
+ const emitStats = () => {
235
+ const elapsed = (performance.now() - tDownloadStart) / 1000
236
+ const raw = collectStats(swStats, hcStats, blobCore, elapsed)
237
+ const formatted = formatStats(raw)
238
+ if (onStats) {
239
+ onStats(formatted, raw)
240
+ } else {
241
+ onLog(formatted)
242
+ }
243
+ return raw
244
+ }
245
+
246
+ const statsInterval = setInterval(emitStats, intervalSec * 1000)
247
+
248
+ const rangeDownload = blobCore.download({
249
+ start: blob.blockOffset,
250
+ length: blob.blockLength
251
+ })
252
+
253
+ const readStream = blobs.createReadStream(blob, { wait: true, timeout })
254
+ const writeStream = fs.createWriteStream(outputFile)
255
+
256
+ try {
257
+ await pipeline(readStream, writeStream)
258
+ } finally {
259
+ rangeDownload.destroy()
260
+ clearInterval(statsInterval)
261
+ }
262
+
263
+ const totalSec = (performance.now() - tStart) / 1000
264
+ const transferSec = (performance.now() - tDownloadStart) / 1000
265
+ const avgSpeed = transferSec > 0 ? blob.byteLength / transferSec : 0
266
+
267
+ const finalStats = emitStats()
268
+
269
+ const summary = {
270
+ modelPath: model.path,
271
+ totalBytes: blob.byteLength,
272
+ totalBlocks: blob.blockLength,
273
+ metadataSec,
274
+ transferSec,
275
+ avgSpeed,
276
+ totalSec,
277
+ finalStats
278
+ }
279
+
280
+ onLog(formatSummary(summary))
281
+
282
+ await blobs.close()
283
+ await blobCore.close()
284
+
285
+ return summary
286
+ } finally {
287
+ await cleanupResources()
288
+ }
289
+ }
290
+
291
+ module.exports = {
292
+ decodeCoreKey,
293
+ collectStats,
294
+ formatStats,
295
+ formatSummary,
296
+ profileDownload
297
+ }
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@qvac/registry-client",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "QVAC Registry client library for read-only queries via Hyperswarm",
5
5
  "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/tetherto/qvac.git",
9
+ "directory": "packages/qvac-lib-registry-server/client"
10
+ },
11
+ "bugs": "https://github.com/tetherto/qvac/issues",
12
+ "homepage": "https://github.com/tetherto/qvac/tree/main/packages/qvac-lib-registry-server/client#readme",
6
13
  "type": "commonjs",
7
14
  "main": "index.js",
8
15
  "bin": {
@@ -46,7 +53,7 @@
46
53
  },
47
54
  "dependencies": {
48
55
  "@qvac/error": "^0.1.0",
49
- "@qvac/registry-schema": "^0.1.1",
56
+ "@qvac/registry-schema": "^0.1.2",
50
57
  "b4a": "^1.6.7",
51
58
  "bare-fs": "^4.5.2",
52
59
  "bare-os": "^3.6.2",
@@ -55,10 +62,13 @@
55
62
  "corestore": "^7.4.5",
56
63
  "hyperblobs": "^2.8.0",
57
64
  "hypercore-id-encoding": "^1.3.0",
65
+ "hypercore-stats": "^2.4.0",
58
66
  "hyperdb": "^4.16.1",
59
67
  "hyperswarm": "^4.14.0",
68
+ "hyperswarm-stats": "^1.3.0",
60
69
  "paparam": "^1.10.0",
61
- "ready-resource": "^1.0.1"
70
+ "ready-resource": "^1.0.1",
71
+ "tiny-byte-size": "^1.1.0"
62
72
  },
63
73
  "devDependencies": {
64
74
  "brittle": "^3.4.0",