@tetherto/mdk-worker 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/README.md +207 -0
- package/device.js +15 -0
- package/index.js +12 -0
- package/lib/contract-loader.js +74 -0
- package/lib/contract-validator.js +114 -0
- package/lib/instance-loader.js +107 -0
- package/lib/module-context.js +104 -0
- package/lib/param-validator.js +54 -0
- package/lib/plugin-loader.js +94 -0
- package/lib/service-builtins.js +268 -0
- package/lib/worker-runtime-v2.js +113 -0
- package/lib/worker-runtime.js +589 -0
- package/mdk-contract.schema.json +138 -0
- package/package.json +59 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate command params against the contract's declared `params` list
|
|
5
|
+
* ({ name, type, required?, enum?, min?, max? } per mdk-contract.schema.json)
|
|
6
|
+
* before they reach a handler. The Kernel runs a similar check, but the worker
|
|
7
|
+
* is reachable on its own RPC key, so it must not trust the caller to have.
|
|
8
|
+
*
|
|
9
|
+
* Undeclared keys pass through (the Kernel admits them too); a declared key
|
|
10
|
+
* that is absent, or null, is only an error when it is `required`.
|
|
11
|
+
*
|
|
12
|
+
* Returns null when params are valid, or an ERR_PARAM_* string naming the
|
|
13
|
+
* first offending param.
|
|
14
|
+
*/
|
|
15
|
+
function validateParams (declared, params) {
|
|
16
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) return 'ERR_PARAMS_INVALID'
|
|
17
|
+
if (!Array.isArray(declared)) return null
|
|
18
|
+
|
|
19
|
+
for (const def of declared) {
|
|
20
|
+
if (!def || typeof def.name !== 'string') continue
|
|
21
|
+
const value = Object.hasOwn(params, def.name) ? params[def.name] : undefined
|
|
22
|
+
|
|
23
|
+
if (value === undefined || value === null) {
|
|
24
|
+
if (def.required === true) return `ERR_PARAM_REQUIRED: ${def.name}`
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (def.type && !_isType(value, def.type)) return `ERR_PARAM_TYPE: ${def.name} must be ${def.type}`
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(def.enum) && !def.enum.includes(value)) return `ERR_PARAM_ENUM: ${def.name}`
|
|
31
|
+
|
|
32
|
+
if (typeof value === 'number') {
|
|
33
|
+
if (typeof def.min === 'number' && value < def.min) return `ERR_PARAM_RANGE: ${def.name} below min ${def.min}`
|
|
34
|
+
if (typeof def.max === 'number' && value > def.max) return `ERR_PARAM_RANGE: ${def.name} above max ${def.max}`
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _isType (value, type) {
|
|
41
|
+
switch (type) {
|
|
42
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value)
|
|
43
|
+
case 'string': return typeof value === 'string'
|
|
44
|
+
case 'boolean': return typeof value === 'boolean'
|
|
45
|
+
// The schema has no `array` type, so list params (switchSocket's `args`,
|
|
46
|
+
// setupPools' `pools`) are declared `object`: arrays must pass.
|
|
47
|
+
case 'object': return typeof value === 'object'
|
|
48
|
+
// An unknown declared type is a contract problem, not a caller one; the
|
|
49
|
+
// schema restricts `type`, so there is nothing safe to check here.
|
|
50
|
+
default: return true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { validateParams }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const debug = require('debug')('mdk:worker:plugin-loader')
|
|
5
|
+
|
|
6
|
+
const SECTIONS = ['telemetry', 'commands']
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validate a Worker Plugin ({ contract, dir, connect, disconnect? }) and
|
|
10
|
+
* eagerly require every handler declared in the contract. Any missing module,
|
|
11
|
+
* non-function export, missing `handler` field or duplicate name aborts with
|
|
12
|
+
* an ERR_PLUGIN_* error — nothing is loaded lazily at request time.
|
|
13
|
+
*
|
|
14
|
+
* Returns { contract, publishedContract, handlers, connect, disconnect }.
|
|
15
|
+
* `publishedContract` is a copy with `handler` fields stripped — handler
|
|
16
|
+
* paths are plugin-internal and never leave the process on capability pulls.
|
|
17
|
+
*
|
|
18
|
+
* `plugin.loadHandler` (optional) replaces the `require` used to bind each
|
|
19
|
+
* handler: it is called with (absoluteFile, { section, name }) and must return
|
|
20
|
+
* the function to publish. It is the seam for hosts that bind handlers
|
|
21
|
+
* per device rather than once per process — contract-loader.js resolves paths
|
|
22
|
+
* through it without executing them, and worker-runtime-v2.js binds shims.
|
|
23
|
+
*/
|
|
24
|
+
function loadPlugin (plugin) {
|
|
25
|
+
if (!plugin || typeof plugin !== 'object') throw new Error('ERR_PLUGIN_REQUIRED')
|
|
26
|
+
if (typeof plugin.connect !== 'function') throw new Error('ERR_PLUGIN_CONNECT_NOT_FUNCTION')
|
|
27
|
+
if (plugin.disconnect !== undefined && typeof plugin.disconnect !== 'function') {
|
|
28
|
+
throw new Error('ERR_PLUGIN_DISCONNECT_NOT_FUNCTION')
|
|
29
|
+
}
|
|
30
|
+
if (plugin.loadHandler !== undefined && typeof plugin.loadHandler !== 'function') {
|
|
31
|
+
throw new Error('ERR_PLUGIN_LOAD_HANDLER_NOT_FUNCTION')
|
|
32
|
+
}
|
|
33
|
+
if (typeof plugin.dir !== 'string' || !plugin.dir) throw new Error('ERR_PLUGIN_DIR_MISSING')
|
|
34
|
+
|
|
35
|
+
const contract = plugin.contract
|
|
36
|
+
if (!contract || typeof contract !== 'object') throw new Error('ERR_PLUGIN_CONTRACT_MISSING')
|
|
37
|
+
// The Kernel terminates workers whose capability payload lacks either object.
|
|
38
|
+
if (!contract.metadata || typeof contract.metadata !== 'object') throw new Error('ERR_PLUGIN_CONTRACT_METADATA_MISSING')
|
|
39
|
+
if (!contract.capabilities || typeof contract.capabilities !== 'object') throw new Error('ERR_PLUGIN_CONTRACT_CAPABILITIES_MISSING')
|
|
40
|
+
|
|
41
|
+
const handlers = {}
|
|
42
|
+
for (const section of SECTIONS) {
|
|
43
|
+
handlers[section] = _loadSection(contract, plugin.dir, section, plugin.loadHandler || require)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
contract,
|
|
48
|
+
publishedContract: _stripHandlers(contract),
|
|
49
|
+
handlers,
|
|
50
|
+
connect: plugin.connect,
|
|
51
|
+
disconnect: plugin.disconnect || null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _loadSection (contract, dir, section, load) {
|
|
56
|
+
const entries = contract.capabilities[section] || []
|
|
57
|
+
if (!Array.isArray(entries)) throw new Error(`ERR_PLUGIN_SECTION_NOT_ARRAY: ${section}`)
|
|
58
|
+
|
|
59
|
+
const loaded = new Map()
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!entry || typeof entry.name !== 'string' || !entry.name) {
|
|
62
|
+
throw new Error(`ERR_PLUGIN_ENTRY_NAME_MISSING: ${section}`)
|
|
63
|
+
}
|
|
64
|
+
if (loaded.has(entry.name)) throw new Error(`ERR_PLUGIN_DUPLICATE_NAME: ${section}.${entry.name}`)
|
|
65
|
+
if (typeof entry.handler !== 'string' || !entry.handler) {
|
|
66
|
+
throw new Error(`ERR_PLUGIN_HANDLER_MISSING: ${section}.${entry.name}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const file = path.resolve(dir, entry.handler)
|
|
70
|
+
let fn
|
|
71
|
+
try {
|
|
72
|
+
fn = load(file, { section, name: entry.name })
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new Error(`ERR_PLUGIN_HANDLER_NOT_FOUND: ${section}.${entry.name}: ${file}: ${err.message}`)
|
|
75
|
+
}
|
|
76
|
+
if (typeof fn !== 'function') {
|
|
77
|
+
throw new Error(`ERR_PLUGIN_HANDLER_NOT_FUNCTION: ${section}.${entry.name}: ${file}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
loaded.set(entry.name, fn)
|
|
81
|
+
debug('loaded %s handler %s from %s', section, entry.name, entry.handler)
|
|
82
|
+
}
|
|
83
|
+
return loaded
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function _stripHandlers (contract) {
|
|
87
|
+
const copy = JSON.parse(JSON.stringify(contract))
|
|
88
|
+
for (const section of SECTIONS) {
|
|
89
|
+
for (const entry of copy.capabilities[section] || []) delete entry.handler
|
|
90
|
+
}
|
|
91
|
+
return copy
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { loadPlugin }
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Legacy worker-infra surface served by the runtime from injected services —
|
|
5
|
+
* the query types and commands the adapter-era workers answered from the
|
|
6
|
+
* manager's store rather than the device. Each entry is active only when its backing
|
|
7
|
+
* service is present in the runtime's `services` object:
|
|
8
|
+
*
|
|
9
|
+
* logHistory → telemetry `logs`, `historical_logs`, `logs_multi`
|
|
10
|
+
* settings → telemetry `settings`, command `saveSettings`
|
|
11
|
+
* provisioning → telemetry `thing_config`, `list`, `count`, `config`,
|
|
12
|
+
* commands `registerThing`, `updateThing`, `forgetThings`
|
|
13
|
+
* stats → telemetry `stats`
|
|
14
|
+
* comments → commands `saveComment`, `editComment`, `deleteComment`
|
|
15
|
+
* actions → `write.calls.request`
|
|
16
|
+
* pool → telemetry `ext_data` (scheduler-driven pool workers)
|
|
17
|
+
*
|
|
18
|
+
* These never reach plugin handlers: they are worker infrastructure, not
|
|
19
|
+
* device translation.
|
|
20
|
+
*
|
|
21
|
+
* Telemetry handlers receive (services, deviceId, params, rt) where rt is
|
|
22
|
+
* runtime context: { workerId, metadata, deviceIds }. When provisioning is
|
|
23
|
+
* present, `list` shadows the runtime's bare devices list with the legacy
|
|
24
|
+
* thing list ({ things: [{ id, code, type, tags, info, comments, last? }] })
|
|
25
|
+
* that adapter-era gateways read.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Upper bound on caller-supplied device id lists (logs_multi, stats): each
|
|
29
|
+
// id costs a store walk, so an unbounded list is a cheap way to pin the worker.
|
|
30
|
+
const MAX_DEVICE_IDS = 1000
|
|
31
|
+
|
|
32
|
+
const TELEMETRY_BUILTINS = {
|
|
33
|
+
logs: {
|
|
34
|
+
service: 'logHistory',
|
|
35
|
+
handle: async (services, deviceId, params) => ({
|
|
36
|
+
logs: await services.logHistory.tailLog(_addressed(params, 'thingId', deviceId))
|
|
37
|
+
})
|
|
38
|
+
},
|
|
39
|
+
historical_logs: {
|
|
40
|
+
service: 'logHistory',
|
|
41
|
+
handle: async (services, deviceId, params) => ({
|
|
42
|
+
logs: await services.logHistory.getHistoricalLogs(_addressed(params, 'thingId', deviceId))
|
|
43
|
+
})
|
|
44
|
+
},
|
|
45
|
+
logs_multi: {
|
|
46
|
+
service: 'logHistory',
|
|
47
|
+
handle: async (services, deviceId, params, rt) => {
|
|
48
|
+
const deviceIds = params.deviceIds !== undefined
|
|
49
|
+
? _deviceIdList(params.deviceIds)
|
|
50
|
+
: (deviceId ? [deviceId] : rt.deviceIds)
|
|
51
|
+
const allLogs = []
|
|
52
|
+
for (const id of deviceIds) {
|
|
53
|
+
try {
|
|
54
|
+
const logs = await services.logHistory.tailLog({ ...params, thingId: id })
|
|
55
|
+
allLogs.push(...(Array.isArray(logs) ? logs : []))
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// one unreadable device log must not fail the multi-pull
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { logs: allLogs }
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
settings: {
|
|
64
|
+
service: 'settings',
|
|
65
|
+
handle: async (services) => ({
|
|
66
|
+
settings: await services.settings.getSettings()
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
thing_config: {
|
|
70
|
+
service: 'provisioning',
|
|
71
|
+
handle: async (services, deviceId, params) => ({
|
|
72
|
+
config: await services.provisioning.getThingConf(_addressed(params, 'thingId', deviceId))
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
list: {
|
|
76
|
+
service: 'provisioning',
|
|
77
|
+
handle: async (services, deviceId, params, rt) => {
|
|
78
|
+
const devices = services.provisioning.listDevices({
|
|
79
|
+
...params,
|
|
80
|
+
offset: params.offset ?? 0,
|
|
81
|
+
limit: params.limit ?? 100
|
|
82
|
+
})
|
|
83
|
+
const things = devices.map((dev) => ({
|
|
84
|
+
id: dev.id,
|
|
85
|
+
code: dev.code,
|
|
86
|
+
type: dev.type,
|
|
87
|
+
tags: dev.tags,
|
|
88
|
+
info: dev.info,
|
|
89
|
+
rack: rt.workerId,
|
|
90
|
+
comments: dev.comments,
|
|
91
|
+
address: dev.opts && dev.opts.address,
|
|
92
|
+
port: dev.opts && dev.opts.port,
|
|
93
|
+
...(params.status && services.snaps ? { last: services.snaps.getLast(dev.id) || {} } : {})
|
|
94
|
+
}))
|
|
95
|
+
return { things }
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
count: {
|
|
99
|
+
service: 'provisioning',
|
|
100
|
+
handle: async (services) => ({
|
|
101
|
+
count: services.provisioning.listDeviceIds().length
|
|
102
|
+
})
|
|
103
|
+
},
|
|
104
|
+
config: {
|
|
105
|
+
service: 'provisioning',
|
|
106
|
+
handle: async (services, deviceId, params, rt) => ({
|
|
107
|
+
config: { workerId: rt.workerId, contract: rt.metadata }
|
|
108
|
+
})
|
|
109
|
+
},
|
|
110
|
+
stats: {
|
|
111
|
+
service: 'stats',
|
|
112
|
+
handle: async (services, deviceId, params) => ({
|
|
113
|
+
stats: await services.stats.aggrStats(
|
|
114
|
+
params.deviceIds === undefined ? undefined : _deviceIdList(params.deviceIds),
|
|
115
|
+
params.opts || {}
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
},
|
|
119
|
+
ext_data: {
|
|
120
|
+
service: 'pool',
|
|
121
|
+
handle: async (services, deviceId, params) => ({
|
|
122
|
+
extData: await services.pool.getWrkExtData({ query: params })
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const COMMAND_BUILTINS = {
|
|
128
|
+
registerThing: {
|
|
129
|
+
service: 'provisioning',
|
|
130
|
+
handle: (services, deviceId, params) => services.provisioning.registerThing(params)
|
|
131
|
+
},
|
|
132
|
+
updateThing: {
|
|
133
|
+
service: 'provisioning',
|
|
134
|
+
handle: (services, deviceId, params) => services.provisioning.updateThing(_addressed(params, 'id', deviceId))
|
|
135
|
+
},
|
|
136
|
+
forgetThings: {
|
|
137
|
+
service: 'provisioning',
|
|
138
|
+
handle: async (services, deviceId, params) => ({ removed: await services.provisioning.forgetThings(params) })
|
|
139
|
+
},
|
|
140
|
+
saveSettings: {
|
|
141
|
+
service: 'settings',
|
|
142
|
+
handle: (services, deviceId, params) => services.settings.saveSettingsEntries(params)
|
|
143
|
+
},
|
|
144
|
+
saveComment: {
|
|
145
|
+
service: 'comments',
|
|
146
|
+
handle: (services, deviceId, params) => services.comments.saveThingComment(_addressed(params, 'thingId', deviceId))
|
|
147
|
+
},
|
|
148
|
+
editComment: {
|
|
149
|
+
service: 'comments',
|
|
150
|
+
handle: (services, deviceId, params) => services.comments.editThingComment(_addressed(params, 'thingId', deviceId))
|
|
151
|
+
},
|
|
152
|
+
deleteComment: {
|
|
153
|
+
service: 'comments',
|
|
154
|
+
handle: (services, deviceId, params) => services.comments.deleteThingComment(_addressed(params, 'thingId', deviceId))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Contract entries for the built-in commands, merged into the published
|
|
159
|
+
// capability payload — the Kernel dispatcher rejects commands that are not
|
|
160
|
+
// declared (ERR_COMMAND_NOT_IN_CAPABILITIES).
|
|
161
|
+
const COMMAND_CONTRACT_ENTRIES = {
|
|
162
|
+
registerThing: {
|
|
163
|
+
name: 'registerThing',
|
|
164
|
+
description: 'Register a new device config. Takes effect on runtime restart.',
|
|
165
|
+
params: [
|
|
166
|
+
{ name: 'info', type: 'object' },
|
|
167
|
+
{ name: 'opts', type: 'object' }
|
|
168
|
+
]
|
|
169
|
+
},
|
|
170
|
+
updateThing: {
|
|
171
|
+
name: 'updateThing',
|
|
172
|
+
description: 'Update a persisted device config. Takes effect on runtime restart.',
|
|
173
|
+
params: [
|
|
174
|
+
{ name: 'info', type: 'object' },
|
|
175
|
+
{ name: 'opts', type: 'object' }
|
|
176
|
+
]
|
|
177
|
+
},
|
|
178
|
+
forgetThings: {
|
|
179
|
+
name: 'forgetThings',
|
|
180
|
+
description: 'Remove persisted device configs. Takes effect on runtime restart.',
|
|
181
|
+
params: [
|
|
182
|
+
{ name: 'query', type: 'object' }
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
saveSettings: {
|
|
186
|
+
name: 'saveSettings',
|
|
187
|
+
description: 'Update worker settings.',
|
|
188
|
+
params: []
|
|
189
|
+
},
|
|
190
|
+
saveComment: {
|
|
191
|
+
name: 'saveComment',
|
|
192
|
+
description: 'Add a comment to a device.',
|
|
193
|
+
params: [
|
|
194
|
+
{ name: 'comment', type: 'string' },
|
|
195
|
+
{ name: 'user', type: 'string' }
|
|
196
|
+
]
|
|
197
|
+
},
|
|
198
|
+
editComment: {
|
|
199
|
+
name: 'editComment',
|
|
200
|
+
description: 'Edit an existing comment.',
|
|
201
|
+
params: [
|
|
202
|
+
{ name: 'id', type: 'string' },
|
|
203
|
+
{ name: 'comment', type: 'string' },
|
|
204
|
+
{ name: 'user', type: 'string' }
|
|
205
|
+
]
|
|
206
|
+
},
|
|
207
|
+
deleteComment: {
|
|
208
|
+
name: 'deleteComment',
|
|
209
|
+
description: 'Delete a comment.',
|
|
210
|
+
params: [
|
|
211
|
+
{ name: 'id', type: 'string' },
|
|
212
|
+
{ name: 'user', type: 'string' }
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The envelope's deviceId addresses the request, so it wins over a same-named
|
|
218
|
+
// key smuggled into params. Worker-scoped requests (deviceId null) keep the
|
|
219
|
+
// caller's key, which is how provisioning names the device it updates.
|
|
220
|
+
function _addressed (params, key, deviceId) {
|
|
221
|
+
return deviceId ? { ...params, [key]: deviceId } : { ...params }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _deviceIdList (ids) {
|
|
225
|
+
if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string')) {
|
|
226
|
+
throw new Error('ERR_PARAM_TYPE: deviceIds must be an array of strings')
|
|
227
|
+
}
|
|
228
|
+
if (ids.length > MAX_DEVICE_IDS) throw new Error(`ERR_PARAM_RANGE: deviceIds above max ${MAX_DEVICE_IDS}`)
|
|
229
|
+
return ids
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function telemetryBuiltin (services, type) {
|
|
233
|
+
if (!services) return null
|
|
234
|
+
const entry = Object.hasOwn(TELEMETRY_BUILTINS, type) ? TELEMETRY_BUILTINS[type] : null
|
|
235
|
+
return entry && services[entry.service] ? entry : null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function commandBuiltin (services, command) {
|
|
239
|
+
if (!services) return null
|
|
240
|
+
const entry = Object.hasOwn(COMMAND_BUILTINS, command) ? COMMAND_BUILTINS[command] : null
|
|
241
|
+
return entry && services[entry.service] ? entry : null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Returns the contract to publish: the plugin contract plus one command
|
|
246
|
+
* entry per active built-in. The input contract is not mutated; without
|
|
247
|
+
* services (or when every built-in is inactive) it is returned as-is.
|
|
248
|
+
*/
|
|
249
|
+
function mergeBuiltinCommands (contract, services) {
|
|
250
|
+
if (!services) return contract
|
|
251
|
+
|
|
252
|
+
const extra = Object.keys(COMMAND_BUILTINS)
|
|
253
|
+
.filter((name) => commandBuiltin(services, name))
|
|
254
|
+
.map((name) => COMMAND_CONTRACT_ENTRIES[name])
|
|
255
|
+
if (!extra.length) return contract
|
|
256
|
+
|
|
257
|
+
const copy = JSON.parse(JSON.stringify(contract))
|
|
258
|
+
copy.capabilities.commands = [...(copy.capabilities.commands || []), ...extra]
|
|
259
|
+
return copy
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Declared params of an active built-in command, for the runtime's param
|
|
263
|
+
// validation (the same list the capability payload publishes).
|
|
264
|
+
function builtinCommandParams (command) {
|
|
265
|
+
return Object.hasOwn(COMMAND_CONTRACT_ENTRIES, command) ? COMMAND_CONTRACT_ENTRIES[command].params : null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = { telemetryBuiltin, commandBuiltin, mergeBuiltinCommands, builtinCommandParams, MAX_DEVICE_IDS }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const createLogger = require('debug')
|
|
4
|
+
const debug = createLogger('mdk:worker:runtime-v2')
|
|
5
|
+
const WorkerRuntime = require('./worker-runtime')
|
|
6
|
+
const { loadContract } = require('./contract-loader')
|
|
7
|
+
const { createInstance } = require('./instance-loader')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Host for a directory-loaded Worker Plugin — an mdk-contract.json plus handler
|
|
11
|
+
* files, with no module to require and no connect() to call:
|
|
12
|
+
*
|
|
13
|
+
* const runtime = new WorkerRuntimeV2(pkgDir, { workerId, kernelTopic, devices, env, config, storeDir })
|
|
14
|
+
*
|
|
15
|
+
* One HRPC server and one DHT identity for the process regardless of device
|
|
16
|
+
* count, as in v1; what fans out per device is the plugin's module registry.
|
|
17
|
+
* Pass `storeDir` (or `store`) so the DHT/RPC seeds — and therefore the
|
|
18
|
+
* public key published to the Kernel — survive restarts.
|
|
19
|
+
* Each device gets its own instance (see instance-loader.js), so handlers are
|
|
20
|
+
* plain (params) functions that read `{ id, opts, env, config }` from the
|
|
21
|
+
* ambient '@tetherto/mdk-worker/device' instead of taking a ctx argument. The
|
|
22
|
+
* device list is fixed at construction.
|
|
23
|
+
*
|
|
24
|
+
* v1 is inherited whole — deviceId routing, the pull set, envelope wrapping,
|
|
25
|
+
* param normalization, the service built-ins — because the contract's handler
|
|
26
|
+
* table is filled with shims that resolve the addressed device's instance.
|
|
27
|
+
*
|
|
28
|
+
* Two behavior differences from v1: there is no boot-time probe, so every
|
|
29
|
+
* declared device reports `online` and an unreachable one surfaces as an error
|
|
30
|
+
* inside the telemetry payload rather than ERR_DEVICE_UNAVAILABLE; and there is
|
|
31
|
+
* no disconnect, so whatever a plugin opens at load time lives until the
|
|
32
|
+
* process exits.
|
|
33
|
+
*
|
|
34
|
+
* A builtin `health` telemetry channel is also registered into v1's own
|
|
35
|
+
* handler table (see the constructor) so every contract-first plugin gets a
|
|
36
|
+
* `TELEMETRY_PULL { query: { type: 'health' } }` channel with zero contract
|
|
37
|
+
* changes, answered by `entry.instance.telemetry.get('health')` per device
|
|
38
|
+
* (auto-injected by instance-loader.js). It routes through the exact same
|
|
39
|
+
* dispatch v1 already uses for any real telemetry channel — no special
|
|
40
|
+
* casing here.
|
|
41
|
+
*/
|
|
42
|
+
class WorkerRuntimeV2 extends WorkerRuntime {
|
|
43
|
+
constructor (dir, opts) {
|
|
44
|
+
opts = opts || {}
|
|
45
|
+
if (typeof dir !== 'string' || !dir) throw new Error('ERR_WORKER_DIR_REQUIRED')
|
|
46
|
+
|
|
47
|
+
const contract = loadContract(dir)
|
|
48
|
+
// The shims are v1's handler table, so they must be built during super() —
|
|
49
|
+
// before `this` exists. They reach the runtime through this holder.
|
|
50
|
+
const host = {}
|
|
51
|
+
super({
|
|
52
|
+
contract: contract.contract,
|
|
53
|
+
dir,
|
|
54
|
+
connect: _noConnect,
|
|
55
|
+
loadHandler: (file, { section, name }) => (ctx, params) => host.runtime._invoke(section, name, ctx, params)
|
|
56
|
+
}, opts)
|
|
57
|
+
host.runtime = this
|
|
58
|
+
|
|
59
|
+
this._dir = dir
|
|
60
|
+
this._entries = contract.entries
|
|
61
|
+
// Resolved by the CLI from mdk.yaml and shared by every device; `config` is
|
|
62
|
+
// the plugin-defined opaque block, device connection details live in opts.
|
|
63
|
+
this._env = Object.freeze({ ...(opts.env || {}) })
|
|
64
|
+
this._config = Object.freeze({ ...(opts.config || {}) })
|
|
65
|
+
|
|
66
|
+
// Builtin `health` channel, registered the same way a real contract
|
|
67
|
+
// entry would be: a shim in v1's own handler table that resolves through
|
|
68
|
+
// _invoke() to the addressed device's instance. `this` fully exists here
|
|
69
|
+
// (after super()), so no `host` indirection is needed. Guarded so a
|
|
70
|
+
// plugin that legitimately declares its own `health` channel keeps it.
|
|
71
|
+
if (!this._plugin.handlers.telemetry.has('health')) {
|
|
72
|
+
this._plugin.handlers.telemetry.set('health', (ctx, params) => this._invoke('telemetry', 'health', ctx, params))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async _openContexts () {
|
|
77
|
+
for (const entry of this._devices.values()) {
|
|
78
|
+
if (entry.status === 'online') continue
|
|
79
|
+
|
|
80
|
+
const device = Object.freeze({
|
|
81
|
+
id: entry.deviceId,
|
|
82
|
+
opts: Object.freeze({ ...entry.config }),
|
|
83
|
+
env: this._env,
|
|
84
|
+
config: this._config,
|
|
85
|
+
workerId: this.workerId,
|
|
86
|
+
logger: createLogger(`mdk:worker:${this.workerId}:${entry.deviceId}`)
|
|
87
|
+
})
|
|
88
|
+
// createInstance auto-registers the builtin `health` telemetry entry
|
|
89
|
+
// (see instance-loader.js's BUILTIN_TELEMETRY) into entry.instance
|
|
90
|
+
// itself — there is no separate runtime-level snapshot to keep here.
|
|
91
|
+
entry.instance = createInstance({ dir: this._dir, entries: this._entries, device })
|
|
92
|
+
// v1 dispatches fn(ctx, params) and the shim routes on ctx.deviceId; the
|
|
93
|
+
// plugin sees none of this. No `device` key: there is nothing to connect.
|
|
94
|
+
entry.ctx = Object.freeze({ deviceId: entry.deviceId, config: entry.config, services: this.services })
|
|
95
|
+
entry.status = 'online'
|
|
96
|
+
entry.error = null
|
|
97
|
+
debug('instance opened: %s', entry.deviceId)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_invoke (section, name, ctx, params) {
|
|
102
|
+
const deviceId = ctx ? ctx.deviceId : null
|
|
103
|
+
const entry = this._devices.get(deviceId)
|
|
104
|
+
if (!entry || !entry.instance) throw new Error(`ERR_DEVICE_INSTANCE_MISSING: ${deviceId}`)
|
|
105
|
+
return entry.instance[section].get(name)(params)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _noConnect () {
|
|
110
|
+
throw new Error('ERR_CONNECT_NOT_SUPPORTED')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = WorkerRuntimeV2
|