@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/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 }
@@ -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 }