@tetherto/mdk-app-runtime 0.0.0 → 0.12.1

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/lib/runtime.js ADDED
@@ -0,0 +1,583 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('crypto')
4
+ const EventEmitter = require('events')
5
+ const fs = require('fs')
6
+ const http = require('http')
7
+ const path = require('path')
8
+ const util = require('util')
9
+ const createDebug = require('debug')
10
+ const HyperswarmRPC = require('@hyperswarm/rpc')
11
+ const DHT = require('hyperdht')
12
+ const { loadPlugin } = require('./plugin-loader')
13
+ const { loadAppManifest, resolvePluginDir } = require('./manifest')
14
+ const protocol = require('./protocol')
15
+
16
+ const { METHODS, STREAM_MESSAGES: MSG, ERR } = protocol
17
+
18
+ const LEVELS = ['error', 'warn', 'info', 'debug', 'trace', 'fatal']
19
+
20
+ // Default logger: error/warn/info/debug/trace/fatal + child(). error, fatal and
21
+ // warn always reach stderr (a failing controller must not go unnoticed); the
22
+ // rest print only under DEBUG=mdk:app:<name> (or a wildcard). Accepts
23
+ // pino-style calls too (`logger.info({ k: 1 }, 'msg')`).
24
+ const ALWAYS = new Set(['error', 'fatal', 'warn'])
25
+
26
+ function _debugLogger (dbg) {
27
+ const logger = {}
28
+ for (const level of LEVELS) {
29
+ logger[level] = (...args) => {
30
+ if (!dbg.enabled) {
31
+ if (ALWAYS.has(level)) process.stderr.write(`${dbg.namespace} [${level}] ${util.format(...args)}\n`)
32
+ return
33
+ }
34
+ const [first, second, ...rest] = args
35
+ if (first && typeof first === 'object' && typeof second === 'string') {
36
+ dbg('[%s] %s %o', level, second, first, ...rest)
37
+ } else if (typeof first === 'string') {
38
+ dbg('[%s] ' + first, level, ...(second === undefined ? [] : [second]), ...rest)
39
+ } else {
40
+ dbg('[%s] %o', level, ...args)
41
+ }
42
+ }
43
+ }
44
+ logger.child = (bindings) => {
45
+ const suffix = bindings && (bindings.name || bindings.component)
46
+ return suffix ? _debugLogger(dbg.extend(String(suffix))) : logger
47
+ }
48
+ return logger
49
+ }
50
+
51
+ function defaultLogger (name) {
52
+ return _debugLogger(createDebug(`mdk:app:${name}`))
53
+ }
54
+
55
+ // Written whole (temp file + rename) so a reader never sees half a key. A new
56
+ // directory is owner-only, as the trusted-keys reader expects. The temp name is
57
+ // random and created exclusively ('wx'), so nothing planted at a guessable path
58
+ // (a symlink, another user's file) is written through or renamed into place.
59
+ function _writeKeyFile (file, key) {
60
+ const target = path.resolve(file)
61
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 })
62
+ const tmp = `${target}.${crypto.randomBytes(8).toString('hex')}.tmp`
63
+ try {
64
+ fs.writeFileSync(tmp, key.toString('hex'), { mode: 0o644, flag: 'wx' })
65
+ if (process.platform !== 'win32') fs.chmodSync(tmp, 0o644)
66
+ fs.renameSync(tmp, target)
67
+ } catch (err) {
68
+ try { fs.unlinkSync(tmp) } catch {}
69
+ throw err
70
+ }
71
+ }
72
+
73
+ // Removes a key file only while it still names this runtime: a newer runtime
74
+ // may have replaced it.
75
+ function _removeKeyFile (file, key) {
76
+ try {
77
+ if (fs.readFileSync(file, 'utf8').trim() === key.toString('hex')) fs.unlinkSync(file)
78
+ } catch {}
79
+ }
80
+
81
+ // A 32-byte secret seed persisted at `file` (dir 0o700, file 0o600) so an
82
+ // identity survives restarts; a looser mode on an existing file is tightened.
83
+ function getOrCreateSeed (file) {
84
+ const dir = path.dirname(file)
85
+
86
+ const read = () => {
87
+ let seed
88
+ try {
89
+ seed = fs.readFileSync(file)
90
+ } catch (err) {
91
+ if (err.code === 'ENOENT') return null
92
+ throw err
93
+ }
94
+ if (seed.length !== 32) throw new Error(`ERR_APP_RUNTIME_SEED_INVALID: ${file} must hold exactly 32 bytes`)
95
+ if (process.platform !== 'win32' && (fs.statSync(file).mode & 0o077) !== 0) fs.chmodSync(file, 0o600)
96
+ return seed
97
+ }
98
+
99
+ const existing = read()
100
+ if (existing) return existing
101
+
102
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
103
+ const seed = crypto.randomBytes(32)
104
+ try {
105
+ fs.writeFileSync(file, seed, { mode: 0o600, flag: 'wx' })
106
+ } catch (err) {
107
+ // Another process created it between the read and the write: use its seed.
108
+ if (err.code === 'EEXIST') return read()
109
+ throw err
110
+ }
111
+ return seed
112
+ }
113
+
114
+ // Seeds live in <storeDir>/identity. Without a storeDir every start gets a fresh random seed.
115
+ function _storeSeed (storeDir, name) {
116
+ return storeDir ? getOrCreateSeed(path.join(storeDir, 'identity', name)) : crypto.randomBytes(32)
117
+ }
118
+
119
+ // The keypair handed to createMdkClient. The secret key is non-enumerable so
120
+ // that logging, spreading or serializing the config cannot leak it.
121
+ function _kernelKeyPair (seed) {
122
+ const { publicKey, secretKey } = DHT.keyPair(seed)
123
+ const keyPair = { publicKey }
124
+ Object.defineProperty(keyPair, 'secretKey', { value: secretKey, enumerable: false })
125
+ return Object.freeze(keyPair)
126
+ }
127
+
128
+ // Firewall: hyperdht blocks a connection when this returns true (or throws).
129
+ // Unlike the Kernel listener, an empty allow set denies everyone: a runtime
130
+ // executes app code for whoever reaches it. Key files are re-read on every
131
+ // connection, so the Gateway may publish its key before or after this starts;
132
+ // an unreadable file contributes no keys.
133
+ //
134
+ // A key in an allowed key file is a full-trust caller (it skips the Gateway's
135
+ // auth and sets `identity`), so a file is only read when nobody else could have
136
+ // written it: a regular file, not a symlink, owned by this process's user and
137
+ // not group/world-writable (0644 is fine). Anything else is skipped with one
138
+ // warning, as mdk-core's trusted-keys reader does. Each entry must be a
139
+ // 32-byte key in hex; other tokens are ignored.
140
+
141
+ const KEY_HEX = /^[0-9a-f]{64}$/
142
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0
143
+ const O_NONBLOCK = fs.constants.O_NONBLOCK || 0
144
+
145
+ function _normalizeKey (k) {
146
+ if (Buffer.isBuffer(k)) return k.toString('hex')
147
+ return typeof k === 'string' ? k.trim().toLowerCase() : ''
148
+ }
149
+
150
+ // Why `st` may not be trusted as a key file, or null when it may.
151
+ function _untrustedReason (st) {
152
+ if (st.isSymbolicLink()) return 'is a symlink'
153
+ if (!st.isFile()) return 'is not a regular file'
154
+ if (process.platform !== 'win32') {
155
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return 'is owned by another user'
156
+ if (st.mode & 0o022) return 'is group/world-writable'
157
+ }
158
+ return null
159
+ }
160
+
161
+ // Returns { keys } for a trusted file (keys = valid hex keys in it), or
162
+ // { reason } when the file is untrusted, or { keys: [] } when it is absent or unreadable.
163
+ async function _readKeyFile (file) {
164
+ let fh = null
165
+ try {
166
+ const st = await fs.promises.lstat(file)
167
+ const reason = _untrustedReason(st)
168
+ if (reason) return { reason }
169
+ // O_NOFOLLOW/O_NONBLOCK and the inode check close the gap between lstat and
170
+ // open: a file swapped for a symlink or a FIFO meanwhile is refused.
171
+ fh = await fs.promises.open(file, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK)
172
+ const fst = await fh.stat()
173
+ if (fst.dev !== st.dev || fst.ino !== st.ino) return { reason: 'changed while being read' }
174
+ const fstReason = _untrustedReason(fst)
175
+ if (fstReason) return { reason: fstReason }
176
+ const text = await fh.readFile('utf8')
177
+ return { keys: text.split(/\s+/).map(_normalizeKey).filter((k) => KEY_HEX.test(k)) }
178
+ } catch (err) {
179
+ if (err && err.code === 'ELOOP') return { reason: 'is a symlink' }
180
+ return { keys: [] }
181
+ } finally {
182
+ if (fh) await fh.close().catch(() => {})
183
+ }
184
+ }
185
+
186
+ function buildFirewall ({ allowedKeys, allowedKeyFiles, logger }) {
187
+ const staticKeys = (allowedKeys || []).map(_normalizeKey).filter((k) => KEY_HEX.test(k))
188
+ const files = allowedKeyFiles || []
189
+ // file -> the reason last warned about, so a bad file warns once, not per connection.
190
+ const warned = new Map()
191
+
192
+ return async (remotePublicKey) => {
193
+ const hex = Buffer.from(remotePublicKey).toString('hex')
194
+ if (staticKeys.includes(hex)) return false
195
+
196
+ for (const file of files) {
197
+ const { keys, reason } = await _readKeyFile(file)
198
+ if (reason) {
199
+ if (warned.get(file) !== reason) {
200
+ warned.set(file, reason)
201
+ logger.warn('ignoring allowed key file %s: it %s', file, reason)
202
+ }
203
+ continue
204
+ }
205
+ warned.delete(file)
206
+ if (keys.includes(hex)) return false
207
+ }
208
+
209
+ logger.warn('blocked connection from %s...', hex.slice(0, 16))
210
+ return true
211
+ }
212
+ }
213
+
214
+ // The ServerResponse-like `raw` a stream handler receives: bridges
215
+ // writeHead/write/end onto the `app.stream` channel.
216
+
217
+ class StreamResponse extends EventEmitter {
218
+ constructor (channel) {
219
+ super()
220
+ this._channel = channel
221
+ this._headers = new Map()
222
+ this.statusCode = 200
223
+ this.headersSent = false
224
+ this.writableEnded = false
225
+ this.destroyed = false
226
+ this._closeEmitted = false
227
+ }
228
+
229
+ get writableFinished () { return this.writableEnded }
230
+ get finished () { return this.writableEnded }
231
+
232
+ setHeader (name, value) { this._headers.set(String(name).toLowerCase(), value); return this }
233
+ getHeader (name) { return this._headers.get(String(name).toLowerCase()) }
234
+ hasHeader (name) { return this._headers.has(String(name).toLowerCase()) }
235
+ removeHeader (name) { this._headers.delete(String(name).toLowerCase()) }
236
+ getHeaders () { return Object.fromEntries(this._headers) }
237
+
238
+ writeHead (status, headers) {
239
+ if (this.headersSent) return this
240
+ if (Number.isInteger(status)) this.statusCode = status
241
+ if (headers && typeof headers === 'object') {
242
+ for (const [k, v] of Object.entries(headers)) this.setHeader(k, v)
243
+ }
244
+ this.headersSent = true
245
+ this._send(MSG.HEAD, protocol.encode({ statusCode: this.statusCode, headers: this.getHeaders() }))
246
+ return this
247
+ }
248
+
249
+ flushHeaders () {
250
+ if (!this.headersSent) this.writeHead(this.statusCode)
251
+ }
252
+
253
+ write (chunk, encoding, cb) {
254
+ if (typeof encoding === 'function') { cb = encoding; encoding = undefined }
255
+ if (this.writableEnded || this.destroyed) return false
256
+ this.flushHeaders()
257
+ const buf = _toBuffer(chunk, encoding)
258
+ // false: the connection's outgoing buffer is full; 'drain' follows.
259
+ const ok = buf.length === 0 ? true : this._send(MSG.CHUNK, buf)
260
+ if (typeof cb === 'function') cb()
261
+ return ok
262
+ }
263
+
264
+ end (chunk, encoding, cb) {
265
+ if (typeof chunk === 'function') { cb = chunk; chunk = undefined; encoding = undefined }
266
+ if (typeof encoding === 'function') { cb = encoding; encoding = undefined }
267
+ if (this.writableEnded) return this
268
+ if (chunk !== undefined && chunk !== null) this.write(chunk, encoding)
269
+ if (this.destroyed) { this.writableEnded = true; return this }
270
+ this.flushHeaders()
271
+ this.writableEnded = true
272
+ this._send(MSG.END, Buffer.alloc(0))
273
+ // protomux keeps order, so the peer sees END before the close.
274
+ this._channel.close()
275
+ if (typeof cb === 'function') cb()
276
+ this.emit('finish')
277
+ return this
278
+ }
279
+
280
+ destroy () {
281
+ if (!this.destroyed) this._channel.close()
282
+ this._markClosed()
283
+ return this
284
+ }
285
+
286
+ // The channel (or the connection under it) is gone: remote disconnect, our
287
+ // own end(), or runtime shutdown. 'close' fires once, like ServerResponse's.
288
+ _markClosed () {
289
+ this.destroyed = true
290
+ if (this._closeEmitted) return
291
+ this._closeEmitted = true
292
+ this.emit('close')
293
+ }
294
+
295
+ // protomux's send() returns whether the connection's stream is drained.
296
+ _send (type, buf) {
297
+ if (this.destroyed || this._channel.closed) return false
298
+ try {
299
+ return this._channel.messages[type].send(buf) !== false
300
+ } catch {
301
+ return false
302
+ }
303
+ }
304
+ }
305
+
306
+ function _toBuffer (chunk, encoding) {
307
+ if (Buffer.isBuffer(chunk)) return chunk
308
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk)
309
+ if (typeof chunk === 'string') return Buffer.from(chunk, encoding)
310
+ return Buffer.from(String(chunk))
311
+ }
312
+
313
+ function _pluginReq (req, identity) {
314
+ const r = req && typeof req === 'object' ? req : {}
315
+ return {
316
+ params: r.params || {},
317
+ query: r.query || {},
318
+ body: r.body,
319
+ headers: r.headers || {},
320
+ _info: identity || {}
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Runs one app's controllers and serves them over HRPC.
326
+ *
327
+ * Manifest, route table and controllers are loaded synchronously when this is
328
+ * called (so a broken app fails here, not on first request), through the
329
+ * Gateway's plugin loader; the network side only comes up in start(). onReady callbacks run once, after the first
330
+ * successful start(); a stop()/start() cycle does not re-run them.
331
+ *
332
+ * The ambient `config` carries `kernelKey`, `kernelBootstrap` and
333
+ * `kernelKeyPair` (the app's own Kernel identity, derived from a persisted
334
+ * seed), which is what `createMdkClient(config)` reads.
335
+ */
336
+ function createAppRuntime (opts) {
337
+ opts = opts || {}
338
+ const { name, appDir } = opts
339
+ if (typeof name !== 'string' || !name) throw new Error('ERR_APP_RUNTIME_NAME_REQUIRED')
340
+ if (typeof appDir !== 'string' || !appDir) throw new Error('ERR_APP_RUNTIME_APP_DIR_REQUIRED')
341
+
342
+ const logger = opts.logger || defaultLogger(name)
343
+ const { manifest, dir } = loadAppManifest(appDir)
344
+
345
+ // Controllers load with the ambient config, so identity is derived first.
346
+ const storeDir = opts.storeDir || null
347
+ const seedRpc = _storeSeed(storeDir, 'seedRpc')
348
+ const kernelKeyPair = _kernelKeyPair(_storeSeed(storeDir, 'seedKernelClient'))
349
+
350
+ const kernelKey = Buffer.isBuffer(opts.kernelKey) ? opts.kernelKey.toString('hex') : opts.kernelKey
351
+
352
+ // The app's own writable directory, so relative paths never land in the
353
+ // project root or collide with another app's.
354
+ const dataDir = storeDir ? path.resolve(storeDir, 'data') : null
355
+ if (dataDir) fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 })
356
+
357
+ // Operator config wins over the manifest's defaults. Which Kernel to dial, the
358
+ // identity to dial it with and the data dir are the runtime's own: neither
359
+ // layer of config can point the app elsewhere.
360
+ const config = Object.freeze({
361
+ ...manifest.config,
362
+ ...opts.config,
363
+ kernelKey: kernelKey || null,
364
+ kernelBootstrap: opts.kernelBootstrap || null,
365
+ dataDir,
366
+ kernelKeyPair
367
+ })
368
+
369
+ const readyFns = []
370
+ let ready = false
371
+ const runReady = (fn) => {
372
+ const failed = (err) => logger.error('onReady callback failed: %s', err && err.message)
373
+ try {
374
+ const out = fn()
375
+ if (out && typeof out.catch === 'function') out.catch(failed)
376
+ } catch (err) {
377
+ failed(err)
378
+ }
379
+ }
380
+
381
+ const context = Object.freeze({
382
+ config,
383
+ logger,
384
+ // Runs fn once this runtime is serving; straight away if it already is.
385
+ onReady (fn) {
386
+ if (typeof fn !== 'function') throw new Error('ERR_APP_RUNTIME_ONREADY_FN_REQUIRED')
387
+ if (ready) runReady(fn)
388
+ else readyFns.push(fn)
389
+ }
390
+ })
391
+
392
+ const { routes, load } = loadPlugin(resolvePluginDir(dir), context)
393
+ const routeById = new Map(routes.map(r => [r.id, r]))
394
+
395
+ // The app's background logic: `main` (`<file>[#export]`, beside the route
396
+ // table) is awaited with the config before the first start() serves, so no
397
+ // request reaches a controller whose state main has not set up yet.
398
+ const main = manifest.main ? load(manifest.main) : null
399
+ if (main !== null && typeof main !== 'function') throw new Error(`ERR_APP_MAIN_NOT_FUNCTION: ${manifest.main}`)
400
+ let mainDone = !main
401
+
402
+ const firewall = buildFirewall({
403
+ allowedKeys: opts.allowedKeys,
404
+ allowedKeyFiles: opts.allowedKeyFiles,
405
+ logger
406
+ })
407
+
408
+ let rpc = null
409
+ let server = null
410
+ let starting = null
411
+ let stopping = null
412
+
413
+ // Decodes a request envelope and finds its route; throws a protocol error otherwise.
414
+ function parseRequest (buf, wantStream) {
415
+ let msg
416
+ try {
417
+ msg = protocol.decode(buf)
418
+ } catch {
419
+ throw protocol.protocolError(400, ERR.BAD_REQUEST, 'malformed request')
420
+ }
421
+ const bad = protocol.checkRequest(msg)
422
+ if (bad) throw bad
423
+ const route = routeById.get(msg.routeId)
424
+ if (!route) throw protocol.protocolError(404, ERR.ROUTE_NOT_FOUND, msg.routeId)
425
+ if (wantStream && route.stream !== true) throw protocol.protocolError(400, ERR.ROUTE_NOT_STREAM, msg.routeId)
426
+ if (!wantStream && route.stream === true) throw protocol.protocolError(400, ERR.ROUTE_IS_STREAM, msg.routeId)
427
+ return { msg, route }
428
+ }
429
+
430
+ // An ERR_* failure is the controller's answer; anything else is a bug the
431
+ // caller only sees masked, so it is logged here with its stack.
432
+ function logFailure (routeId, err) {
433
+ if (protocol.isSafeError(err)) logger.debug('route %s failed: %s', routeId, err.message)
434
+ else logger.error('route %s failed: %s', routeId, (err && err.stack) || err)
435
+ }
436
+
437
+ async function onInvoke (buf) {
438
+ let routeId = null
439
+ try {
440
+ const { msg, route } = parseRequest(buf, false)
441
+ routeId = route.id
442
+ const result = await route._handler(_pluginReq(msg.req, msg.identity))
443
+ return protocol.encode(protocol.okResponse(result))
444
+ } catch (err) {
445
+ logFailure(routeId, err)
446
+ return protocol.encode(protocol.toErrorResponse(err))
447
+ }
448
+ }
449
+
450
+ // Server side of one `app.stream` channel.
451
+ function onStreamChannel (mux) {
452
+ let channel = null
453
+ let raw = null
454
+
455
+ const sendError = (err) => {
456
+ try { channel.messages[MSG.ERROR].send(protocol.encode(protocol.toErrorResponse(err))) } catch {}
457
+ channel.close()
458
+ }
459
+
460
+ const onRequest = async (buf) => {
461
+ if (raw) return // one request per channel
462
+ let msg, route
463
+ try {
464
+ ({ msg, route } = parseRequest(buf, true))
465
+ } catch (err) {
466
+ return sendError(err)
467
+ }
468
+
469
+ raw = new StreamResponse(channel)
470
+ try {
471
+ await route._handler(_pluginReq(msg.req, msg.identity), raw)
472
+ } catch (err) {
473
+ // A stream handler owns the response, so a failure is reported in-band
474
+ // while headers are still unsent. Afterwards the channel closes without
475
+ // END, so the caller sees a truncated body as an error, not a success.
476
+ logFailure(route.id, err)
477
+ if (!raw.headersSent) {
478
+ const { status, message } = protocol.toErrorResponse(err)
479
+ raw.writeHead(status, { 'content-type': 'application/json' })
480
+ raw.end(JSON.stringify({ statusCode: status, error: http.STATUS_CODES[status] || 'Bad Request', message }))
481
+ } else if (!raw.writableEnded) {
482
+ raw.destroy()
483
+ }
484
+ }
485
+ }
486
+
487
+ // Index = STREAM_MESSAGES; only REQUEST is inbound.
488
+ const messages = [
489
+ { onmessage: onRequest },
490
+ {}, {}, {}, {}
491
+ ]
492
+ channel = mux.createChannel({
493
+ protocol: METHODS.STREAM,
494
+ messages,
495
+ ondrain: () => { if (raw && !raw.destroyed) raw.emit('drain') },
496
+ onclose: () => { if (raw) raw._markClosed() }
497
+ })
498
+ if (channel) channel.open()
499
+ }
500
+
501
+ async function _start () {
502
+ if (!mainDone) {
503
+ await main(config, context)
504
+ mainDone = true
505
+ }
506
+ rpc = new HyperswarmRPC({ seed: seedRpc, bootstrap: opts.bootstrap || undefined })
507
+ server = rpc.createServer({ firewall })
508
+ server.respond(METHODS.INVOKE, onInvoke)
509
+ server.on('connection', (conn) => {
510
+ conn.mux.pair({ protocol: METHODS.STREAM }, () => onStreamChannel(conn.mux))
511
+ })
512
+ await server.listen()
513
+
514
+ const publicKey = server.publicKey
515
+ if (opts.keyFile) _writeKeyFile(opts.keyFile, publicKey)
516
+
517
+ // Public by nature, so world-readable but not writable: an operator copies
518
+ // it into the Kernel's auth.whitelist, or it lands in a trusted-keys dir,
519
+ // whose reader skips group/world-writable files.
520
+ try {
521
+ if (opts.kernelKeyFile) _writeKeyFile(opts.kernelKeyFile, kernelKeyPair.publicKey)
522
+ } catch (err) {
523
+ // A failed start must not leave the key the Gateway dials (it would
524
+ // wait out appTimeoutMs for a 502 instead of answering 503 at once).
525
+ if (opts.keyFile) _removeKeyFile(opts.keyFile, publicKey)
526
+ throw err
527
+ }
528
+
529
+ logger.info('app runtime "%s" serving (key %s...)', name, publicKey.toString('hex').slice(0, 16))
530
+
531
+ if (!ready) {
532
+ ready = true
533
+ for (const fn of readyFns.splice(0)) runReady(fn)
534
+ }
535
+ return { publicKey }
536
+ }
537
+
538
+ async function start () {
539
+ if (stopping) await stopping
540
+ // `starting` first: _start() sets `server` before it is listening, so a
541
+ // second start() meanwhile must wait for the first, not report ready.
542
+ if (starting) return starting
543
+ if (server) return { publicKey: server.publicKey }
544
+ starting = _start().catch(async (err) => {
545
+ await _teardown()
546
+ throw err
547
+ }).finally(() => { starting = null })
548
+ return starting
549
+ }
550
+
551
+ async function _teardown () {
552
+ const s = server
553
+ const r = rpc
554
+ server = null
555
+ rpc = null
556
+ if (s) await s.close().catch(() => {})
557
+ if (r) await r.destroy().catch(() => {})
558
+ }
559
+
560
+ async function stop () {
561
+ if (starting) await starting.catch(() => {})
562
+ if (!stopping) {
563
+ const publicKey = server && server.publicKey
564
+ stopping = _teardown().finally(() => { stopping = null })
565
+ // The Gateway reads a missing key file as "not started" (503) instead of
566
+ // dialling a runtime that is gone.
567
+ if (publicKey && opts.keyFile) _removeKeyFile(opts.keyFile, publicKey)
568
+ }
569
+ return stopping
570
+ }
571
+
572
+ return {
573
+ name,
574
+ routes,
575
+ manifest,
576
+ start,
577
+ stop,
578
+ getPublicKey () { return server ? server.publicKey : null },
579
+ getKernelPublicKey () { return Buffer.from(kernelKeyPair.publicKey) }
580
+ }
581
+ }
582
+
583
+ module.exports = { createAppRuntime, getOrCreateSeed, buildFirewall, _writeKeyFile }
package/package.json CHANGED
@@ -1 +1,57 @@
1
- {"name":"@tetherto/mdk-app-runtime","version":"0.0.0"}
1
+ {
2
+ "name": "@tetherto/mdk-app-runtime",
3
+ "version": "0.12.1",
4
+ "description": "MDK app runtime: runs one app's controllers in its own process and serves them over HRPC",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "mdk-app-runtime": "bin/run-app.js"
8
+ },
9
+ "keywords": [
10
+ "tether",
11
+ "mdk",
12
+ "app"
13
+ ],
14
+ "author": "Tether",
15
+ "license": "Apache-2.0",
16
+ "engines": {
17
+ "node": ">=24"
18
+ },
19
+ "scripts": {
20
+ "lint": "standard",
21
+ "lint:fix": "standard --fix",
22
+ "test": "npm run lint && npm run test:unit",
23
+ "test:unit": "NODE_ENV=test brittle tests/unit/*.test.js",
24
+ "test:coverage": "NODE_ENV=test brittle --coverage 'tests/**/*.test.js'"
25
+ },
26
+ "dependencies": {
27
+ "@hyperswarm/rpc": "^3.5.0",
28
+ "@tetherto/mdk-worker": "^0.12.1",
29
+ "debug": "4.4.1",
30
+ "hyperdht": "6.34.1",
31
+ "yaml": "^2.9.0"
32
+ },
33
+ "devDependencies": {
34
+ "brittle": "3.18.0",
35
+ "standard": "17.1.0"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/tetherto/mdk.git",
40
+ "directory": "backend/core/app-runtime"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": [
46
+ "index.js",
47
+ "lib/",
48
+ "bin/",
49
+ "README.md"
50
+ ],
51
+ "exports": {
52
+ ".": "./index.js",
53
+ "./lib/plugin-loader": "./lib/plugin-loader.js",
54
+ "./bin/run-app.js": "./bin/run-app.js",
55
+ "./package.json": "./package.json"
56
+ }
57
+ }