@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/LICENSE +202 -0
- package/README.md +106 -0
- package/bin/run-app.js +118 -0
- package/index.js +14 -0
- package/lib/client.js +297 -0
- package/lib/manifest.js +75 -0
- package/lib/plugin-loader.js +216 -0
- package/lib/protocol.js +184 -0
- package/lib/runtime.js +583 -0
- package/package.json +57 -1
package/lib/client.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const { Readable, pipeline } = require('stream')
|
|
5
|
+
const debug = require('debug')('mdk:app:client')
|
|
6
|
+
const HyperswarmRPC = require('@hyperswarm/rpc')
|
|
7
|
+
const DHT = require('hyperdht')
|
|
8
|
+
const protocol = require('./protocol')
|
|
9
|
+
|
|
10
|
+
const { METHODS, STREAM_MESSAGES: MSG, ERR } = protocol
|
|
11
|
+
|
|
12
|
+
function _unavailable (routeId, cause) {
|
|
13
|
+
const err = new Error(`${ERR.RUNTIME_UNAVAILABLE}: ${routeId}`)
|
|
14
|
+
err.statusCode = 502
|
|
15
|
+
err.code = ERR.RUNTIME_UNAVAILABLE
|
|
16
|
+
if (cause) err.cause = cause
|
|
17
|
+
return err
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function _timeout (routeId, timeoutMs) {
|
|
21
|
+
const err = new Error(`${ERR.RUNTIME_TIMEOUT}: ${routeId} (${timeoutMs}ms)`)
|
|
22
|
+
err.statusCode = 504
|
|
23
|
+
err.code = ERR.RUNTIME_TIMEOUT
|
|
24
|
+
return err
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function _aborted (routeId) {
|
|
28
|
+
const err = new Error(`${ERR.REQUEST_ABORTED}: ${routeId}`)
|
|
29
|
+
err.statusCode = 499
|
|
30
|
+
err.code = ERR.REQUEST_ABORTED
|
|
31
|
+
return err
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _overflow (routeId, limit) {
|
|
35
|
+
const err = new Error(`${ERR.STREAM_OVERFLOW}: ${routeId} buffered more than ${limit} bytes`)
|
|
36
|
+
err.statusCode = 502
|
|
37
|
+
err.code = ERR.STREAM_OVERFLOW
|
|
38
|
+
return err
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _versionError (got) {
|
|
42
|
+
return protocol.protocolError(502, ERR.PROTOCOL_VERSION, `expected ${protocol.PROTOCOL_VERSION}, got ${got}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const _buf = (v) => Buffer.isBuffer(v) ? v : Buffer.from(String(v).trim(), 'hex')
|
|
46
|
+
|
|
47
|
+
// How long an idle connection to a runtime is kept for the next call.
|
|
48
|
+
const POOL_LINGER_MS = 10000
|
|
49
|
+
|
|
50
|
+
// Most body bytes one stream may hold unread before it is torn down (see
|
|
51
|
+
// protocol.js, "Backpressure").
|
|
52
|
+
const MAX_STREAM_BUFFER_BYTES = 16 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Caller side of app runtimes (see runtime.js / protocol.js). One transport and
|
|
56
|
+
* one key pair for every runtime it calls, so they all allow-list the same
|
|
57
|
+
* `publicKey`: derived from `seed` (hex or Buffer; random without one), or the
|
|
58
|
+
* injected `rpc`'s own. An injected `rpc` / `dht` is left alone by close().
|
|
59
|
+
* `runtimeKey` is a runtime's RPC public key (hex or Buffer).
|
|
60
|
+
*
|
|
61
|
+
* Unary calls share one connection per runtime, held open while any call on it
|
|
62
|
+
* is in flight and for `poolLingerMs` after the last one. (@hyperswarm/rpc's own
|
|
63
|
+
* pooled `request()` counts a call as finished as soon as it is sent, so its
|
|
64
|
+
* linger timer tears the connection down under a call slower than that.)
|
|
65
|
+
*
|
|
66
|
+
* A stream whose reader falls more than `maxStreamBufferBytes` (default 16 MiB)
|
|
67
|
+
* behind the runtime is destroyed with ERR_APP_STREAM_OVERFLOW and its channel
|
|
68
|
+
* closed, so a slow consumer cannot grow this process's memory without bound.
|
|
69
|
+
*/
|
|
70
|
+
function createRuntimeClient (opts = {}) {
|
|
71
|
+
const keyPair = opts.rpc ? opts.rpc.defaultKeyPair : DHT.keyPair(opts.seed ? _buf(opts.seed) : crypto.randomBytes(32))
|
|
72
|
+
const poolLingerMs = opts.poolLingerMs ?? POOL_LINGER_MS
|
|
73
|
+
const maxStreamBufferBytes = opts.maxStreamBufferBytes ?? MAX_STREAM_BUFFER_BYTES
|
|
74
|
+
if (!(maxStreamBufferBytes > 0)) throw new Error('ERR_APP_RUNTIME_CLIENT_OPTIONS_INVALID: maxStreamBufferBytes must be a positive number')
|
|
75
|
+
const pool = new Map()
|
|
76
|
+
let rpc = null
|
|
77
|
+
|
|
78
|
+
function assertConnected () {
|
|
79
|
+
if (!rpc) throw new Error('ERR_APP_RUNTIME_CLIENT_NOT_CONNECTED')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function connect () {
|
|
83
|
+
if (!rpc) rpc = opts.rpc || new HyperswarmRPC({ keyPair, dht: opts.dht, bootstrap: opts.bootstrap, poolLinger: poolLingerMs })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function acquire (key) {
|
|
87
|
+
const id = key.toString('hex')
|
|
88
|
+
let ref = pool.get(id)
|
|
89
|
+
if (!ref) {
|
|
90
|
+
const conn = rpc.connect(key)
|
|
91
|
+
ref = { id, conn, active: 0, timer: null }
|
|
92
|
+
const entry = ref
|
|
93
|
+
conn.on('close', () => {
|
|
94
|
+
clearTimeout(entry.timer)
|
|
95
|
+
if (pool.get(id) === entry) pool.delete(id)
|
|
96
|
+
})
|
|
97
|
+
pool.set(id, ref)
|
|
98
|
+
}
|
|
99
|
+
clearTimeout(ref.timer)
|
|
100
|
+
ref.active++
|
|
101
|
+
return ref
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function release (ref) {
|
|
105
|
+
if (--ref.active > 0) return
|
|
106
|
+
ref.timer = setTimeout(() => {
|
|
107
|
+
if (pool.get(ref.id) === ref) pool.delete(ref.id)
|
|
108
|
+
ref.conn.destroy()
|
|
109
|
+
}, poolLingerMs)
|
|
110
|
+
ref.timer.unref()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function close () {
|
|
114
|
+
for (const ref of pool.values()) {
|
|
115
|
+
clearTimeout(ref.timer)
|
|
116
|
+
ref.conn.destroy()
|
|
117
|
+
}
|
|
118
|
+
pool.clear()
|
|
119
|
+
const r = rpc
|
|
120
|
+
rpc = null
|
|
121
|
+
if (r && !opts.rpc) await r.destroy()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `signal` aborts a call whose caller has gone: while the connection is still
|
|
125
|
+
// opening and carries only this call, it is dropped, so the request never
|
|
126
|
+
// reaches the runtime.
|
|
127
|
+
async function invoke (runtimeKey, routeId, req, { identity, timeoutMs, signal } = {}) {
|
|
128
|
+
assertConnected()
|
|
129
|
+
if (signal && signal.aborted) throw _aborted(routeId)
|
|
130
|
+
let resBuf
|
|
131
|
+
const ref = acquire(_buf(runtimeKey))
|
|
132
|
+
const onAbort = () => {
|
|
133
|
+
if (ref.active === 1 && !(ref.conn.rpc && ref.conn.rpc.opened)) ref.conn.destroy()
|
|
134
|
+
}
|
|
135
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true })
|
|
136
|
+
try {
|
|
137
|
+
const options = timeoutMs > 0 ? { timeout: timeoutMs } : undefined
|
|
138
|
+
resBuf = await ref.conn.request(METHODS.INVOKE, protocol.encode(protocol.buildRequest(routeId, req, identity)), options)
|
|
139
|
+
} catch (cause) {
|
|
140
|
+
debug('transport failure for %s: %s', routeId, cause && cause.message)
|
|
141
|
+
if (signal && signal.aborted) throw _aborted(routeId)
|
|
142
|
+
if (cause && cause.code === 'TIMEOUT_EXCEEDED') throw _timeout(routeId, timeoutMs)
|
|
143
|
+
throw _unavailable(routeId, cause)
|
|
144
|
+
} finally {
|
|
145
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
146
|
+
release(ref)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let res
|
|
150
|
+
try {
|
|
151
|
+
res = protocol.decode(resBuf)
|
|
152
|
+
} catch (cause) {
|
|
153
|
+
throw _unavailable(routeId, cause)
|
|
154
|
+
}
|
|
155
|
+
if (!res || res.v !== protocol.PROTOCOL_VERSION) throw _versionError(res && res.v)
|
|
156
|
+
if (!res.ok) throw protocol.fromErrorResponse(res)
|
|
157
|
+
return res.result
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Streams ride a protomux channel on a dedicated connection (see protocol.js).
|
|
161
|
+
// `timeoutMs` bounds the wait for the response head; once the head arrived
|
|
162
|
+
// the body is bounded only by the stream's own lifetime. `signal` closes the
|
|
163
|
+
// channel while the head is still pending; after it, the caller destroys the
|
|
164
|
+
// returned stream. Unread body bytes are capped at maxStreamBufferBytes.
|
|
165
|
+
async function stream (runtimeKey, routeId, req, { identity, timeoutMs, signal } = {}) {
|
|
166
|
+
assertConnected()
|
|
167
|
+
if (signal && signal.aborted) throw _aborted(routeId)
|
|
168
|
+
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const conn = rpc.connect(_buf(runtimeKey))
|
|
171
|
+
let channel = null
|
|
172
|
+
let settled = false
|
|
173
|
+
let ended = false
|
|
174
|
+
let readable = null
|
|
175
|
+
let timer = null
|
|
176
|
+
|
|
177
|
+
const onAbort = () => fail(_aborted(routeId))
|
|
178
|
+
const cleanup = () => {
|
|
179
|
+
clearTimeout(timer)
|
|
180
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
181
|
+
if (channel && !channel.closed) channel.close()
|
|
182
|
+
conn.destroy()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const fail = (err) => {
|
|
186
|
+
if (!settled) {
|
|
187
|
+
settled = true
|
|
188
|
+
cleanup()
|
|
189
|
+
reject(err)
|
|
190
|
+
} else if (readable && !readable.destroyed) {
|
|
191
|
+
readable.destroy(err)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const onclose = () => {
|
|
196
|
+
if (ended) return
|
|
197
|
+
fail(_unavailable(routeId))
|
|
198
|
+
}
|
|
199
|
+
conn.on('close', onclose)
|
|
200
|
+
|
|
201
|
+
if (timeoutMs > 0) {
|
|
202
|
+
timer = setTimeout(() => {
|
|
203
|
+
debug('no stream head for %s within %dms', routeId, timeoutMs)
|
|
204
|
+
fail(_timeout(routeId, timeoutMs))
|
|
205
|
+
}, timeoutMs)
|
|
206
|
+
}
|
|
207
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true })
|
|
208
|
+
|
|
209
|
+
const mux = conn.mux
|
|
210
|
+
if (!mux) return fail(_unavailable(routeId))
|
|
211
|
+
|
|
212
|
+
channel = mux.createChannel({
|
|
213
|
+
protocol: METHODS.STREAM,
|
|
214
|
+
messages: [
|
|
215
|
+
{},
|
|
216
|
+
{
|
|
217
|
+
onmessage: (buf) => {
|
|
218
|
+
if (settled) return
|
|
219
|
+
let head
|
|
220
|
+
try {
|
|
221
|
+
head = protocol.decode(buf)
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
return fail(_unavailable(routeId, cause))
|
|
224
|
+
}
|
|
225
|
+
clearTimeout(timer)
|
|
226
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
227
|
+
readable = new Readable({
|
|
228
|
+
read () {},
|
|
229
|
+
destroy (err, cb) { cleanup(); cb(err) }
|
|
230
|
+
})
|
|
231
|
+
settled = true
|
|
232
|
+
resolve({ statusCode: head.statusCode, headers: head.headers || {}, stream: readable })
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
onmessage: (buf) => {
|
|
237
|
+
if (!readable || readable.destroyed) return
|
|
238
|
+
readable.push(Buffer.from(buf))
|
|
239
|
+
// The protocol has no flow control (see protocol.js), so a reader
|
|
240
|
+
// this far behind ends the stream; destroy() closes the channel
|
|
241
|
+
// and the connection, which stops the runtime's handler.
|
|
242
|
+
if (readable.readableLength > maxStreamBufferBytes) {
|
|
243
|
+
debug('stream %s overflowed %d buffered bytes', routeId, maxStreamBufferBytes)
|
|
244
|
+
readable.destroy(_overflow(routeId, maxStreamBufferBytes))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
onmessage: () => {
|
|
250
|
+
// END with no HEAD before it: nothing to resolve with.
|
|
251
|
+
if (!settled) return fail(_unavailable(routeId))
|
|
252
|
+
ended = true
|
|
253
|
+
if (readable && !readable.destroyed) readable.push(null)
|
|
254
|
+
cleanup()
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
onmessage: (buf) => {
|
|
259
|
+
let err
|
|
260
|
+
try {
|
|
261
|
+
const res = protocol.decode(buf)
|
|
262
|
+
err = res && res.v === protocol.PROTOCOL_VERSION ? protocol.fromErrorResponse(res) : _versionError(res && res.v)
|
|
263
|
+
} catch (cause) {
|
|
264
|
+
err = _unavailable(routeId, cause)
|
|
265
|
+
}
|
|
266
|
+
ended = true
|
|
267
|
+
fail(err)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
],
|
|
271
|
+
onclose
|
|
272
|
+
})
|
|
273
|
+
if (!channel) return fail(_unavailable(routeId))
|
|
274
|
+
|
|
275
|
+
channel.open()
|
|
276
|
+
channel.messages[MSG.REQUEST].send(protocol.encode(protocol.buildRequest(routeId, req, identity)))
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { publicKey: Buffer.from(keyPair.publicKey), connect, invoke, stream, close }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Writes the head of a stream() result, pipes its body and cleans up if either
|
|
284
|
+
// side goes away. Resolves (never rejects) once the response finished or was
|
|
285
|
+
// torn down.
|
|
286
|
+
function pipeToResponse (streamResult, raw) {
|
|
287
|
+
raw.writeHead(streamResult.statusCode, streamResult.headers)
|
|
288
|
+
if (typeof raw.flushHeaders === 'function') raw.flushHeaders()
|
|
289
|
+
return new Promise((resolve) => {
|
|
290
|
+
pipeline(streamResult.stream, raw, (err) => {
|
|
291
|
+
if (err) debug('stream aborted: %s', err.message)
|
|
292
|
+
resolve()
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { createRuntimeClient, pipeToResponse, MAX_STREAM_BUFFER_BYTES }
|
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const YAML = require('yaml')
|
|
6
|
+
|
|
7
|
+
const MANIFEST_FILE = 'mdk-app.yaml'
|
|
8
|
+
const PLUGIN_FILE = 'mdk-plugin.json'
|
|
9
|
+
|
|
10
|
+
const ID_RE = /^[a-z][a-z0-9]*(\.[a-z0-9][a-z0-9-]*)+$/
|
|
11
|
+
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/
|
|
12
|
+
|
|
13
|
+
const isMapping = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
14
|
+
const isStrings = (v) => Array.isArray(v) && v.every(s => typeof s === 'string')
|
|
15
|
+
|
|
16
|
+
// Shape only: the permission vocabulary belongs to the Kernel, which enforces
|
|
17
|
+
// it. Other keys pass through untouched.
|
|
18
|
+
function validateAppManifest (obj, label = MANIFEST_FILE) {
|
|
19
|
+
const fail = (reason) => { throw new Error(`ERR_APP_MANIFEST_INVALID: ${label}: ${reason}`) }
|
|
20
|
+
|
|
21
|
+
if (!isMapping(obj)) fail('must be a mapping')
|
|
22
|
+
if (typeof obj.id !== 'string' || !ID_RE.test(obj.id)) fail(`"id" must be reverse-DNS style (e.g. io.tether.example), got ${JSON.stringify(obj.id)}`)
|
|
23
|
+
if (typeof obj.version !== 'string' || !SEMVER_RE.test(obj.version)) fail(`"version" must be a semver string, got ${JSON.stringify(obj.version)}`)
|
|
24
|
+
if (typeof obj.displayName !== 'string' || !obj.displayName.trim()) fail('"displayName" must be a non-empty string')
|
|
25
|
+
if (obj.permissions != null) {
|
|
26
|
+
if (!Array.isArray(obj.permissions)) fail('"permissions" must be an array')
|
|
27
|
+
obj.permissions.forEach((rule, i) => {
|
|
28
|
+
if (!isMapping(rule) || !isStrings(rule.resources) || !isStrings(rule.verbs) || (rule.resourceNames != null && !isStrings(rule.resourceNames))) {
|
|
29
|
+
fail(`permissions[${i}] must be { resources: string[], verbs: string[], resourceNames?: string[] }`)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
if (obj.config != null && !isMapping(obj.config)) fail('"config" must be a mapping')
|
|
34
|
+
if (obj.main != null && (typeof obj.main !== 'string' || !obj.main.trim())) fail('"main" must be a non-empty "<file>[#export]" string')
|
|
35
|
+
|
|
36
|
+
return { ...obj, permissions: obj.permissions || [], config: obj.config || {} }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadAppManifest (appDir) {
|
|
40
|
+
let dir
|
|
41
|
+
try {
|
|
42
|
+
dir = fs.realpathSync(appDir)
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error(`ERR_APP_MANIFEST_MISSING: ${appDir}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const file = path.join(dir, MANIFEST_FILE)
|
|
48
|
+
let text
|
|
49
|
+
try {
|
|
50
|
+
text = fs.readFileSync(file, 'utf8')
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') throw new Error(`ERR_APP_MANIFEST_MISSING: ${dir}`)
|
|
53
|
+
throw new Error(`ERR_APP_MANIFEST_INVALID: ${file}: cannot read: ${err.message}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let parsed
|
|
57
|
+
try {
|
|
58
|
+
parsed = YAML.parse(text)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new Error(`ERR_APP_MANIFEST_INVALID: ${file}: not valid YAML: ${err.message}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { manifest: validateAppManifest(parsed, file), dir }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The directory of an app's route table: <appDir>/plugin if it holds a
|
|
67
|
+
// mdk-plugin.json, else <appDir> itself.
|
|
68
|
+
function resolvePluginDir (appDir) {
|
|
69
|
+
for (const dir of [path.join(appDir, 'plugin'), appDir]) {
|
|
70
|
+
if (fs.existsSync(path.join(dir, PLUGIN_FILE))) return path.resolve(dir)
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_MISSING: ${appDir}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { MANIFEST_FILE, validateAppManifest, loadAppManifest, resolvePluginDir }
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('path')
|
|
4
|
+
// Deep import: index.js would pull the whole worker runtime (hyperswarm) into
|
|
5
|
+
// the gateway process, which also loads route tables through this file, for a
|
|
6
|
+
// loader that only needs fs/path/module.
|
|
7
|
+
const { createModuleContext } = require('@tetherto/mdk-worker/lib/module-context')
|
|
8
|
+
|
|
9
|
+
const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
|
10
|
+
|
|
11
|
+
function _resolveHttpFields (route) {
|
|
12
|
+
const http = route.http
|
|
13
|
+
if (!http) return route
|
|
14
|
+
|
|
15
|
+
const out = { ...route }
|
|
16
|
+
if (http.method) out.method = http.method
|
|
17
|
+
if (http.path) out.path = http.path
|
|
18
|
+
|
|
19
|
+
if (!out.schema) {
|
|
20
|
+
const schema = http.parameters ? _parametersToFastifySchema(http.parameters) : {}
|
|
21
|
+
const body = _requestBodyToFastifySchema(http.requestBody)
|
|
22
|
+
if (body) schema.body = body
|
|
23
|
+
if (Object.keys(schema).length) out.schema = schema
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The same application/json schema MCP validates tool args against, so a route
|
|
30
|
+
// accepts the same bodies over HTTP and over MCP. An optional body may be
|
|
31
|
+
// omitted entirely; when one is sent it must match.
|
|
32
|
+
function _requestBodyToFastifySchema (requestBody) {
|
|
33
|
+
const schema = requestBody?.content?.['application/json']?.schema
|
|
34
|
+
if (!schema || typeof schema !== 'object') return null
|
|
35
|
+
if (requestBody.required === true) return schema
|
|
36
|
+
return { anyOf: [schema, { type: 'null' }] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _parametersToFastifySchema (parameters) {
|
|
40
|
+
const schema = {}
|
|
41
|
+
const groups = { query: 'querystring', path: 'params', header: 'headers' }
|
|
42
|
+
|
|
43
|
+
for (const param of parameters) {
|
|
44
|
+
const target = groups[param.in]
|
|
45
|
+
if (!target) continue
|
|
46
|
+
|
|
47
|
+
if (!schema[target]) {
|
|
48
|
+
schema[target] = { type: 'object', properties: {} }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
schema[target].properties[param.name] = param.schema || {}
|
|
52
|
+
|
|
53
|
+
if (param.required) {
|
|
54
|
+
if (!schema[target].required) schema[target].required = []
|
|
55
|
+
schema[target].required.push(param.name)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return schema
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _normalizePath (routePath) {
|
|
63
|
+
return routePath.replace(/\{([^{}]+)\}/g, ':$1')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _validateManifest (manifest, pluginDir) {
|
|
67
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
68
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: must be a JSON object`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const field of ['name', 'version']) {
|
|
72
|
+
if (typeof manifest[field] !== 'string' || !manifest[field]) {
|
|
73
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: missing required field "${field}"`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!Array.isArray(manifest.routes) || manifest.routes.length === 0) {
|
|
78
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: "routes" must be a non-empty array`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const seenIds = new Set()
|
|
82
|
+
const seenRoutes = new Set()
|
|
83
|
+
|
|
84
|
+
for (const route of manifest.routes) {
|
|
85
|
+
if (typeof route.id !== 'string' || !route.id) {
|
|
86
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: route missing required field "id"`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof route.handler !== 'string' || !route.handler) {
|
|
90
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: route missing required field "handler"`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const method = (route.http?.method || route.method || '').toUpperCase()
|
|
94
|
+
if (!method) {
|
|
95
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: route missing required field "method"`)
|
|
96
|
+
}
|
|
97
|
+
if (!VALID_METHODS.has(method)) {
|
|
98
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: route "${route.id}" has invalid method "${method}"`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const routePath = route.http?.path || route.path
|
|
102
|
+
if (typeof routePath !== 'string' || !routePath) {
|
|
103
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: route missing required field "path"`)
|
|
104
|
+
}
|
|
105
|
+
// Mounted under a prefix, "health" would serve /apps/<name>health.
|
|
106
|
+
if (!routePath.startsWith('/')) {
|
|
107
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: route "${route.id}" path "${routePath}" must start with "/"`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (route.auth !== undefined && typeof route.auth !== 'boolean') {
|
|
111
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: route "${route.id}" field "auth" must be a boolean`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (seenIds.has(route.id)) {
|
|
115
|
+
throw new Error(`ERR_PLUGIN_ROUTE_DUPLICATE_ID: ${pluginDir}: duplicate route id "${route.id}"`)
|
|
116
|
+
}
|
|
117
|
+
seenIds.add(route.id)
|
|
118
|
+
|
|
119
|
+
const key = `${method} ${_normalizePath(routePath)}`
|
|
120
|
+
if (seenRoutes.has(key)) {
|
|
121
|
+
throw new Error(`ERR_PLUGIN_ROUTE_DUPLICATE_PATH: ${pluginDir}: route "${route.id}" repeats ${key}`)
|
|
122
|
+
}
|
|
123
|
+
seenRoutes.add(key)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A manifest names handlers relative to its own dir; one that resolves outside
|
|
128
|
+
// it (../, an absolute path) would load code the operator never pointed at.
|
|
129
|
+
function _confinedHandlerPath (pluginDir, handlerFile, what) {
|
|
130
|
+
const root = path.resolve(pluginDir)
|
|
131
|
+
const handlerPath = path.resolve(root, handlerFile)
|
|
132
|
+
const rel = path.relative(root, handlerPath)
|
|
133
|
+
if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel) || path.isAbsolute(handlerFile)) {
|
|
134
|
+
throw new Error(`ERR_PLUGIN_HANDLER_OUTSIDE_PLUGIN: ${pluginDir}: ${what} must be inside the plugin dir`)
|
|
135
|
+
}
|
|
136
|
+
return handlerPath
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _resolvable (file) {
|
|
140
|
+
try {
|
|
141
|
+
require.resolve(file)
|
|
142
|
+
return true
|
|
143
|
+
} catch {
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// `<file>[#export]` relative to the plugin dir, loaded through the plugin's
|
|
149
|
+
// module registry. A file that exists but cannot load (a missing dependency, a
|
|
150
|
+
// throw at load time) is LOAD_FAILED with the real cause, not NOT_FOUND.
|
|
151
|
+
function _loadExport (moduleContext, pluginDir, spec, what) {
|
|
152
|
+
const [file, namedExport] = spec.split('#')
|
|
153
|
+
const filePath = _confinedHandlerPath(pluginDir, file, `${what} "${file}"`)
|
|
154
|
+
let mod
|
|
155
|
+
try {
|
|
156
|
+
mod = moduleContext.load(filePath)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err.code === 'MODULE_NOT_FOUND' && !_resolvable(filePath)) {
|
|
159
|
+
throw new Error(`ERR_PLUGIN_HANDLER_NOT_FOUND: ${pluginDir}: ${what} "${spec}"`)
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`ERR_PLUGIN_HANDLER_LOAD_FAILED: ${pluginDir}: ${what} "${spec}": ${err.message}`, { cause: err })
|
|
162
|
+
}
|
|
163
|
+
return namedExport ? mod[namedExport] : mod
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// With `handlers: false` only the manifest is read: no handler file is required.
|
|
167
|
+
function loadPlugin (pluginDir, context, { handlers = true } = {}) {
|
|
168
|
+
const manifestPath = path.join(pluginDir, 'mdk-plugin.json')
|
|
169
|
+
|
|
170
|
+
let manifest
|
|
171
|
+
try {
|
|
172
|
+
manifest = require(manifestPath)
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err.code === 'MODULE_NOT_FOUND') {
|
|
175
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_MISSING: ${pluginDir}`)
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`ERR_PLUGIN_MANIFEST_INVALID: ${pluginDir}: ${err.message}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_validateManifest(manifest, pluginDir)
|
|
181
|
+
|
|
182
|
+
// One private module registry per plugin: handler files and everything they
|
|
183
|
+
// require in-package (their lib/client.js, say) load fresh for this plugin,
|
|
184
|
+
// and '@tetherto/mdk-gateway/plugin' resolves to its own frozen context.
|
|
185
|
+
const moduleContext = createModuleContext({
|
|
186
|
+
dir: pluginDir,
|
|
187
|
+
ambient: context ? { '@tetherto/mdk-gateway/plugin': context } : {},
|
|
188
|
+
label: `[mdk-gateway-plugin:${manifest.name}]`
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const routes = manifest.routes.map(route => {
|
|
192
|
+
const resolved = _resolveHttpFields(route)
|
|
193
|
+
if (!handlers) return { ...resolved, method: (resolved.method || '').toUpperCase(), path: _normalizePath(resolved.path) }
|
|
194
|
+
|
|
195
|
+
const handler = _loadExport(moduleContext, pluginDir, resolved.handler, `route "${resolved.id}" handler`)
|
|
196
|
+
|
|
197
|
+
if (typeof handler !== 'function') {
|
|
198
|
+
throw new Error(`ERR_PLUGIN_HANDLER_NOT_FUNCTION: ${pluginDir}: route "${resolved.id}" must export a function`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
...resolved,
|
|
203
|
+
method: (resolved.method || '').toUpperCase(),
|
|
204
|
+
path: _normalizePath(resolved.path),
|
|
205
|
+
_handler: handler
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Loads another `<file>[#export]` of the plugin (an app's `main`) in the same
|
|
210
|
+
// registry, so it shares module state and the ambient context with the handlers.
|
|
211
|
+
const load = (spec) => _loadExport(moduleContext, pluginDir, spec, 'module')
|
|
212
|
+
|
|
213
|
+
return { manifest, routes, load }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { loadPlugin, validateManifest: _validateManifest }
|