@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.
@@ -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