@tetherto/mdk-worker 0.0.0-stage → 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 +206 -2
- 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 +57 -4
package/README.md
CHANGED
|
@@ -1,3 +1,207 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tetherto/mdk-worker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`WorkerRuntime` hosts a Worker Plugin's devices behind one HRPC channel to Kernel. Every
|
|
4
|
+
Worker package in this monorepo (miners, containers, power meters, temperature, minerpools, …) constructs a
|
|
5
|
+
`WorkerRuntime` from its own boot function; see [Workers](../../workers/README.md) for the plugin side of that
|
|
6
|
+
contract.
|
|
7
|
+
|
|
8
|
+
## What this package exports
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
const { WorkerRuntime, loadPlugin, loadContract, createInstance, createModuleContext } = require('@tetherto/mdk-worker')
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- `WorkerRuntime`: starts the Hyperswarm RPC server, dispatches MDK Protocol actions to per-device handlers, and
|
|
15
|
+
persists the DHT/RPC keypair. It creates `opts.storeDir` owner-only (mode `0700`) and writes any file-backed seed
|
|
16
|
+
`0600`, so the Worker's identity stays readable only by its owner
|
|
17
|
+
- `loadPlugin`: loads a plugin object (`mdk-contract.json`, handlers, ...) from a directory
|
|
18
|
+
- `loadContract`, `createInstance`, `createModuleContext` — the loader internals `loadPlugin` composes
|
|
19
|
+
|
|
20
|
+
## Store services
|
|
21
|
+
|
|
22
|
+
> [!CAUTION]
|
|
23
|
+
> **Deprecated.** Store services are the legacy thing/adapter Worker surface: `opts.services`, the built-ins in this
|
|
24
|
+
> section, `ctx.services` in handlers, and the `@tetherto/mdk-core` layer behind them
|
|
25
|
+
> ([`createWorkerInfra`](../mdk/lib/worker-infra.js), the `*Service` classes, and the `Thing` base classes the in-repo
|
|
26
|
+
> device drivers extend). Nothing is removed, because the in-repo Workers still run on it. Build a new Worker as a
|
|
27
|
+
> directory-loaded package on `WorkerRuntimeV2` with no `services`, the shape
|
|
28
|
+
> [Build a third-party Worker](../../../docs/guides/workers/build-a-worker.md) walks through.
|
|
29
|
+
>
|
|
30
|
+
> MDK has no `WorkerRuntimeV2` equivalent yet for:
|
|
31
|
+
>
|
|
32
|
+
> - Adding or removing devices at runtime (`registerThing`, `updateThing`, `forgetThings`): a `WorkerRuntimeV2` device
|
|
33
|
+
> list is fixed at construction, so a device change is a config edit and a restart
|
|
34
|
+
> - Store-backed history and aggregates (`logs`, `historical_logs`, `logs_multi`, `stats`) and pool `ext_data`
|
|
35
|
+
> - Device comments and Worker settings
|
|
36
|
+
> - Kernel write actions (`pushAction`): the Kernel resolves candidate writes with `write.calls.request`, which only a
|
|
37
|
+
> Worker with the `actions` service answers
|
|
38
|
+
|
|
39
|
+
A [`WorkerRuntime`](lib/worker-runtime.js) host can inject an `opts.services` object naming which of the store-backed
|
|
40
|
+
capabilities below to serve. When present, [`service-builtins.js`](lib/service-builtins.js) answers the matching
|
|
41
|
+
queries and commands directly from a host-managed persisted store — never from the plugin's device handlers. A
|
|
42
|
+
Worker Plugin that doesn't need any of this passes `services: null`; see
|
|
43
|
+
[Build a third-party Worker](../../../docs/guides/workers/build-a-worker.md)'s minimal reference implementation.
|
|
44
|
+
|
|
45
|
+
Each entry activates only when its named service exists on `opts.services`:
|
|
46
|
+
|
|
47
|
+
| Service | Telemetry it activates | Commands it activates | What it needs implemented |
|
|
48
|
+
|--------------|-------------------------|-----------------------|-----------------------------------|
|
|
49
|
+
| `logHistory` | `logs`, `historical_logs`, `logs_multi` | — | `tailLog({thingId, ...})`, `getHistoricalLogs({thingId, ...})` |
|
|
50
|
+
| `settings` | `settings` | `saveSettings` | `getSettings()`, `saveSettingsEntries(params)` |
|
|
51
|
+
| `provisioning` | `thing_config`, `list`, `count`, `config` | `registerThing`, `updateThing`, `forgetThings` | `getThingConf`, `listDevices`, `listDeviceIds`, `registerThing`, `updateThing`, `forgetThings` |
|
|
52
|
+
| `stats` | `stats` | — | `aggrStats(deviceIds, opts)` |
|
|
53
|
+
| `comments` | — | `saveComment`, `editComment`, `deleteComment` | `saveThingComment`, `editThingComment`, `deleteThingComment` |
|
|
54
|
+
| `actions` | `write.calls.request` (wired directly in `WorkerRuntime.handleRequest`, not through the builtin tables) | — | `getWriteCalls(payload)` |
|
|
55
|
+
| `pool` | `ext_data` (scheduler-driven pool Workers) | — | `getWrkExtData({query})` |
|
|
56
|
+
| `snaps` | used only inside the `list` builtin, for `params.status` snapshot enrichment | — | `getLast(deviceId)` |
|
|
57
|
+
|
|
58
|
+
These never reach plugin handlers — they are Worker infrastructure, not device translation. When `provisioning` is
|
|
59
|
+
active, its `list` builtin shadows the runtime's own bare `{ deviceId, status }` device list with the fuller thing
|
|
60
|
+
list (`{ id, code, type, tags, info, comments, address, port, ... }`) some gateways read.
|
|
61
|
+
|
|
62
|
+
> [!IMPORTANT]
|
|
63
|
+
> Built-in commands must be published into the contract or the Kernel dispatcher rejects them
|
|
64
|
+
> (`ERR_COMMAND_NOT_IN_CAPABILITIES`) before they ever reach the runtime. [`mergeBuiltinCommands`](lib/service-builtins.js)
|
|
65
|
+
> handles this: it deep-clones the plugin's contract and appends one entry per *active* builtin command
|
|
66
|
+
> (`registerThing`, `updateThing`, `forgetThings`, `saveSettings`, `saveComment`, `editComment`, `deleteComment`) —
|
|
67
|
+
> the source contract is never mutated, and without `services` (or with every builtin inactive) the original contract
|
|
68
|
+
> reference is returned unchanged.
|
|
69
|
+
|
|
70
|
+
`allowEmptyDevices` (a `WorkerRuntime` constructor option, not a service) is the usual pairing with `provisioning`:
|
|
71
|
+
a fresh host boots with zero devices, takes `registerThing` writes to populate the store, and picks up the
|
|
72
|
+
provisioned set on its next restart. See [Build a third-party Worker](../../../docs/guides/workers/build-a-worker.md)'s
|
|
73
|
+
troubleshooting section for the constructor-level error (`ERR_DEVICES_REQUIRED`).
|
|
74
|
+
|
|
75
|
+
Every in-repo device Worker (Avalon, Antspace, Bitdeer, ABB, SATEC, Schneider, Seneca) wires this surface through
|
|
76
|
+
[`createWorkerInfra`](../mdk/lib/worker-infra.js), which builds the `store`, `services`, and provisioning-first
|
|
77
|
+
`devices` list a `WorkerRuntime` needs, then hands the constructed `services` object straight to
|
|
78
|
+
`new WorkerRuntime(plugin, { ..., services, allowEmptyDevices: true })`. The Ocean and F2Pool pool Workers inject only
|
|
79
|
+
a `pool` service. Reading [`miners/avalon/plugin/boot.js`](../../workers/miners/avalon/plugin/boot.js) alongside this
|
|
80
|
+
section is the fastest way to see the whole surface exercised end to end. Whatsminer and Antminer are
|
|
81
|
+
manufacturer-maintained external Workers, listed in
|
|
82
|
+
[`backend/workers/external-workers.json`](../../workers/external-workers.json), hosted on `WorkerRuntimeV2` with no
|
|
83
|
+
`services`.
|
|
84
|
+
|
|
85
|
+
## Caller allowlist
|
|
86
|
+
|
|
87
|
+
The Worker RPC server is **fail-closed**. Only public keys on its allowlist may connect; unknown keys are rejected at
|
|
88
|
+
connect time and every request is re-checked (`ERR_WORKER_CALLER_NOT_ALLOWED`), so `revokeKey()` applies to open
|
|
89
|
+
connections. Every request is envelope-validated (`validateFull`) before dispatch.
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
const runtime = new WorkerRuntime(plugin, {
|
|
93
|
+
workerId: 'rack-1',
|
|
94
|
+
devices,
|
|
95
|
+
allowedKeys: [kernel.getClientPublicKey()] // the key the Kernel dials Workers with
|
|
96
|
+
})
|
|
97
|
+
runtime.allowKey(gatewayClientKeyHex) // more callers, before or after start()
|
|
98
|
+
runtime.revokeKey(hex)
|
|
99
|
+
runtime.getAllowedKeys()
|
|
100
|
+
await runtime.start()
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Option | Default | Description |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `allowedKeys` | `[]` | Hex/Buffer public keys allowed to call. `start()` throws `ERR_WORKER_ALLOWED_KEYS_REQUIRED` when empty |
|
|
106
|
+
| `allowAll` | `false` | Admit every DHT peer that learns the Worker key. Development only; logs a warning at start |
|
|
107
|
+
| `bootstrap` | HyperDHT public bootstrap | Bootstrap nodes for the Worker's HyperDHT node; pass the same list to every component on a private DHT network |
|
|
108
|
+
|
|
109
|
+
Admit the Kernel's **client** key (`kernel.getClientPublicKey()`), not its listener key (`getPublicKey()`). Add a Gateway or other client
|
|
110
|
+
key only when deliberately granting direct Worker access; every admitted key can issue supported commands, regardless of its Kernel role.
|
|
111
|
+
`@tetherto/mdk-core` adds callers automatically for local stacks:
|
|
112
|
+
`trustKernel(runtime, kernel)` adds write-capable Kernel callers in-process, and `trustKeysFromDir(runtime, dir)` adds every published
|
|
113
|
+
key across processes. Omit those helpers when enforcing a Kernel-only Worker allowlist. The
|
|
114
|
+
[site security blueprint](../../../docs/guides/security/index.md#step-3-isolate-workers-and-devices) explains who gets access and what that access allows.
|
|
115
|
+
Every in-repo Worker boot function forwards `allowedKeys` /
|
|
116
|
+
`allowAll` to the runtime.
|
|
117
|
+
|
|
118
|
+
## Request hardening
|
|
119
|
+
|
|
120
|
+
The Worker RPC key is reachable without going through the Kernel, so the runtime does not trust the caller to have
|
|
121
|
+
checked anything:
|
|
122
|
+
|
|
123
|
+
- **Command params** are validated against the contract's declared `params` (`type`, `required`, `enum`, `min`/`max`)
|
|
124
|
+
before the handler runs, after positional (`{ value }` / `{ args }`) normalization. Undeclared keys pass through;
|
|
125
|
+
a `null` or missing optional param is skipped. The built-in commands are checked against their published entries
|
|
126
|
+
too. Arrays pass an `object` param (the schema has no `array` type).
|
|
127
|
+
- **Device id lists** in `logs_multi` and `stats` must be arrays of strings, at most `MAX_DEVICE_IDS` (1000) long.
|
|
128
|
+
- **Addressing:** the envelope's `deviceId` always wins over a `thingId`/`id` key in params; worker-scoped requests
|
|
129
|
+
(`deviceId: null`) keep the caller's key.
|
|
130
|
+
- **Errors over RPC:** handler and service errors whose message starts with `ERR_` are returned as-is. Anything else
|
|
131
|
+
is logged locally (`DEBUG=mdk:worker:runtime`) and replaced by `ERR_TELEMETRY_FAILED`, `ERR_COMMAND_FAILED` or
|
|
132
|
+
`ERR_REQUEST_FAILED`, so driver messages carrying addresses or credentials never leave the process. Throw
|
|
133
|
+
`ERR_*` errors from handlers for anything the caller should see.
|
|
134
|
+
- **Built-in `health` telemetry** (`WorkerRuntimeV2`) returns only `{ status, id, workerId }`; `env`, device `opts`
|
|
135
|
+
and `config` are never returned.
|
|
136
|
+
- **Seed files** (`storeDir`): an existing seed that is not exactly 32 bytes fails start with
|
|
137
|
+
`ERR_SEED_FILE_MALFORMED` instead of being replaced (which would rotate the Worker's identity). Symlinks
|
|
138
|
+
(`ERR_SEED_FILE_SYMLINK`), non-regular files (`ERR_SEED_FILE_INVALID`) and files owned by another user
|
|
139
|
+
(`ERR_SEED_FILE_OWNER`) are refused; group/other permission bits are stripped to `0600`. New seeds are created
|
|
140
|
+
exclusively (`O_EXCL`) with mode `0600`.
|
|
141
|
+
|
|
142
|
+
## Errors
|
|
143
|
+
|
|
144
|
+
The plugin and contract loaders validate their inputs and throw on the first problem; the request handlers instead
|
|
145
|
+
return these codes on the response envelope's `error` field rather than throwing.
|
|
146
|
+
|
|
147
|
+
| Code | Fires when | Fix |
|
|
148
|
+
| --- | --- | --- |
|
|
149
|
+
| `ERR_COMMAND_FAILED` | A command handler or built-in service throws an error whose message does not start with `ERR_` (details are logged locally) | Check the Worker's `mdk:worker:runtime` debug log; throw `ERR_*` errors from handlers |
|
|
150
|
+
| `ERR_CONTRACT_DIR_REQUIRED` | `loadContract` is called without a non-empty package-directory string | Pass the plugin package directory as `pkgDir` |
|
|
151
|
+
| `ERR_CONTRACT_INVALID_JSON` | The package's `mdk-contract.json` cannot be parsed as JSON | Correct the JSON syntax in `mdk-contract.json` |
|
|
152
|
+
| `ERR_CONTRACT_NOT_FOUND` | `mdk-contract.json` cannot be read in the package directory | Ensure `mdk-contract.json` exists in the package directory |
|
|
153
|
+
| `ERR_DEVICE_CONFIG_INVALID` | A device spec supplies `config` that is not an object | Pass `config` as an object or omit it |
|
|
154
|
+
| `ERR_DEVICE_ID_DUPLICATE` | Two device specs share the same `deviceId` | Give each device a unique `deviceId` |
|
|
155
|
+
| `ERR_DEVICE_ID_MISSING` | A device spec has no non-empty string `deviceId` | Give every device spec a `deviceId` string |
|
|
156
|
+
| `ERR_DEVICE_ID_REQUIRED` | A non-metrics telemetry query or a device command arrives with no `deviceId` | Include the target `deviceId` in the request |
|
|
157
|
+
| `ERR_DEVICE_INSTANCE_MISSING` | A handler is invoked for a device that has no open instance | Register the device and open its instance before invoking handlers |
|
|
158
|
+
| `ERR_DEVICE_NOT_FOUND` | A request targets a `deviceId` the Worker does not hold | Target a `deviceId` the Worker manages |
|
|
159
|
+
| `ERR_DEVICE_UNAVAILABLE` | A telemetry or command request targets a device that is not online | Wait for the device to reconnect and come online before retrying |
|
|
160
|
+
| `ERR_DEVICES_REQUIRED` | The runtime is started with an empty or missing device list where empty is not allowed | Supply at least one device spec |
|
|
161
|
+
| `ERR_INSTANCE_DEVICE_ID_REQUIRED` | `createInstance` is called with a device that has no non-empty `id` | Give the device object an `id` string |
|
|
162
|
+
| `ERR_INSTANCE_DEVICE_REQUIRED` | `createInstance` is called without a device object | Pass a `device` object to `createInstance` |
|
|
163
|
+
| `ERR_INSTANCE_DIR_REQUIRED` | `createInstance` is called without a non-empty `dir` string | Pass the plugin directory as `dir` |
|
|
164
|
+
| `ERR_INSTANCE_ENTRIES_REQUIRED` | `createInstance` is called without an `entries` object | Pass the resolved `entries` map |
|
|
165
|
+
| `ERR_INSTANCE_HANDLER_LOAD_FAILED` | A handler module throws while being loaded | Fix the error inside the handler module |
|
|
166
|
+
| `ERR_INSTANCE_HANDLER_NOT_FOUND` | A handler file path in the contract cannot be resolved | Correct the handler path in the contract |
|
|
167
|
+
| `ERR_INSTANCE_HANDLER_NOT_FUNCTION` | A resolved handler module does not export a function | Export a function from the handler module |
|
|
168
|
+
| `ERR_MODULE_CONTEXT_DIR_REQUIRED` | The module context is created without a non-empty `dir` string | Pass a directory when creating the module context |
|
|
169
|
+
| `ERR_NO_DEVICE_CONTEXT` | `@tetherto/mdk-worker/device` is required outside a plugin instance | Load the plugin via `createInstance({ dir, entries, device })` so the device context exists |
|
|
170
|
+
| `ERR_PARAM_ENUM` | A command param is not one of its declared `enum` values | Send one of the declared values |
|
|
171
|
+
| `ERR_PARAM_RANGE` | A numeric command param is outside its declared `min`/`max`, or a `deviceIds` list is longer than 1000 | Send a value in range; split long device lists |
|
|
172
|
+
| `ERR_PARAM_REQUIRED` | A param declared `required: true` is missing or `null` | Include the param |
|
|
173
|
+
| `ERR_PARAM_TYPE` | A command param does not match its declared `type`, or `deviceIds` is not an array of strings | Send the declared type |
|
|
174
|
+
| `ERR_PARAMS_INVALID` | Command `params` is not an object after positional normalization | Send `params` as an object |
|
|
175
|
+
| `ERR_PLUGIN_CONNECT_NOT_FUNCTION` | The plugin's `connect` is not a function | Provide a `connect` function on the plugin |
|
|
176
|
+
| `ERR_PLUGIN_CONTRACT_CAPABILITIES_MISSING` | The contract has no `capabilities` object | Add a `capabilities` object to the contract |
|
|
177
|
+
| `ERR_PLUGIN_CONTRACT_METADATA_MISSING` | The contract has no `metadata` object | Add a `metadata` object to the contract |
|
|
178
|
+
| `ERR_PLUGIN_CONTRACT_MISSING` | The plugin carries no contract object | Provide the plugin's contract |
|
|
179
|
+
| `ERR_PLUGIN_DIR_MISSING` | The plugin has no non-empty `dir` string | Set the plugin's `dir` |
|
|
180
|
+
| `ERR_PLUGIN_DISCONNECT_NOT_FUNCTION` | The plugin defines `disconnect` as something other than a function | Make `disconnect` a function or omit it |
|
|
181
|
+
| `ERR_PLUGIN_DUPLICATE_NAME` | Two entries in a contract section share a name | Give each entry in the section a unique name |
|
|
182
|
+
| `ERR_PLUGIN_ENTRY_NAME_MISSING` | A contract section entry has no non-empty `name` | Give every entry a `name` |
|
|
183
|
+
| `ERR_PLUGIN_HANDLER_MISSING` | A contract entry has no non-empty `handler` path | Set the entry's `handler` path |
|
|
184
|
+
| `ERR_PLUGIN_HANDLER_NOT_FOUND` | An entry's handler file cannot be required | Correct the entry's handler path |
|
|
185
|
+
| `ERR_PLUGIN_HANDLER_NOT_FUNCTION` | An entry's handler module does not export a function | Export a function from the handler |
|
|
186
|
+
| `ERR_PLUGIN_LOAD_HANDLER_NOT_FUNCTION` | `loadHandler` is provided but is not a function | Pass a function for `loadHandler` or omit it |
|
|
187
|
+
| `ERR_PLUGIN_REQUIRED` | `loadPlugin` is called without a plugin object | Pass a plugin object to `loadPlugin` |
|
|
188
|
+
| `ERR_PLUGIN_SECTION_NOT_ARRAY` | A contract capabilities section is present but is not an array | Make each capabilities section an array |
|
|
189
|
+
| `ERR_REQUEST_FAILED` | The RPC responder fails before or outside action dispatch (for example an undecodable request) | Send a well-formed MDK envelope |
|
|
190
|
+
| `ERR_SEED_FILE_INVALID` | A seed path in `storeDir` is not a regular file | Remove the entry so a new seed can be created |
|
|
191
|
+
| `ERR_SEED_FILE_MALFORMED` | A seed file in `storeDir` is not exactly 32 bytes | Restore the seed from backup, or delete it to accept a new Worker identity |
|
|
192
|
+
| `ERR_SEED_FILE_OWNER` | A seed file in `storeDir` is owned by another user | Run the Worker as the file's owner or fix the ownership |
|
|
193
|
+
| `ERR_SEED_FILE_SYMLINK` | A seed path in `storeDir` is a symbolic link | Replace the link with the real file |
|
|
194
|
+
| `ERR_TELEMETRY_FAILED` | A telemetry handler or built-in query throws an error whose message does not start with `ERR_` (details are logged locally) | Check the Worker's `mdk:worker:runtime` debug log; throw `ERR_*` errors from handlers |
|
|
195
|
+
| `ERR_UNKNOWN_ACTION` | An envelope carries an action the runtime does not handle | Send a supported action |
|
|
196
|
+
| `ERR_UNKNOWN_COMMAND` | A command request names a command the plugin does not define | Send a command declared in the contract |
|
|
197
|
+
| `ERR_UNKNOWN_QUERY_TYPE` | A telemetry query names a type the plugin does not provide | Query a telemetry type declared in the contract |
|
|
198
|
+
| `ERR_WORKER_DIR_REQUIRED` | WorkerRuntimeV2 is started without a non-empty `dir` string | Pass the Worker package directory as `dir` |
|
|
199
|
+
| `ERR_WORKER_ID_REQUIRED` | The runtime is constructed without a `workerId` string | Pass a `workerId` string |
|
|
200
|
+
|
|
201
|
+
## Next steps
|
|
202
|
+
|
|
203
|
+
See:
|
|
204
|
+
|
|
205
|
+
- [`backend/core/docs/README.md`](../docs/README.md#worker-runtime-tethertomdk-worker): where this package sits
|
|
206
|
+
in Core's dependency graph
|
|
207
|
+
- [Workers](../../workers/README.md): the plugin packages this runtime hosts
|
package/device.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The ambient per-instance device, required by handler files as
|
|
5
|
+
* `require('@tetherto/mdk-worker/device')` and resolving to that instance's
|
|
6
|
+
* frozen { id, opts, env, config, workerId, logger }.
|
|
7
|
+
*
|
|
8
|
+
* Inside an instance the request never reaches this file: lib/instance-loader.js
|
|
9
|
+
* intercepts it and hands back the device the instance was created with. This
|
|
10
|
+
* file exists to make the specifier resolvable and to fail with a pointer at
|
|
11
|
+
* createInstance() when a plugin module is required outside an instance — a test
|
|
12
|
+
* requiring a handler directly, say — instead of leaving the plugin with
|
|
13
|
+
* `undefined` where its device should be.
|
|
14
|
+
*/
|
|
15
|
+
throw new Error('ERR_NO_DEVICE_CONTEXT: @tetherto/mdk-worker/device is only available inside a plugin instance; load the plugin with createInstance({ dir, entries, device }) from @tetherto/mdk-worker')
|
package/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
WorkerRuntime: require('./lib/worker-runtime'),
|
|
5
|
+
WorkerRuntimeV2: require('./lib/worker-runtime-v2'),
|
|
6
|
+
loadPlugin: require('./lib/plugin-loader').loadPlugin,
|
|
7
|
+
loadContract: require('./lib/contract-loader').loadContract,
|
|
8
|
+
validateContract: require('./lib/contract-validator').validateContract,
|
|
9
|
+
validateParams: require('./lib/param-validator').validateParams,
|
|
10
|
+
createInstance: require('./lib/instance-loader').createInstance,
|
|
11
|
+
createModuleContext: require('./lib/module-context').createModuleContext
|
|
12
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { loadPlugin } = require('./plugin-loader')
|
|
6
|
+
|
|
7
|
+
const CONTRACT_FILE = 'mdk-contract.json'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load a Worker Plugin from a directory: <pkgDir>/mdk-contract.json is the
|
|
11
|
+
* package's only index. Structural validation is plugin-loader's (same
|
|
12
|
+
* ERR_PLUGIN_* errors for a missing metadata/capabilities object, a missing
|
|
13
|
+
* handler field or a duplicate name), and every handler path is resolved on
|
|
14
|
+
* disk — but never executed. A handler module reads its device from the
|
|
15
|
+
* ambient '@tetherto/mdk-worker/device', which only exists inside an instance,
|
|
16
|
+
* so requiring one here would either throw or bind the wrong device.
|
|
17
|
+
*
|
|
18
|
+
* Returns { dir, contract, publishedContract, entries }, where `entries` is
|
|
19
|
+
* { telemetry: Map, commands: Map } of name -> resolved handler file: the input
|
|
20
|
+
* createInstance() binds once per device.
|
|
21
|
+
*/
|
|
22
|
+
function loadContract (pkgDir) {
|
|
23
|
+
if (typeof pkgDir !== 'string' || !pkgDir) throw new Error('ERR_CONTRACT_DIR_REQUIRED')
|
|
24
|
+
|
|
25
|
+
const file = path.join(pkgDir, CONTRACT_FILE)
|
|
26
|
+
let raw
|
|
27
|
+
try {
|
|
28
|
+
raw = fs.readFileSync(file, 'utf8')
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(`ERR_CONTRACT_NOT_FOUND: ${file}: ${err.message}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let contract
|
|
34
|
+
try {
|
|
35
|
+
contract = JSON.parse(raw)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error(`ERR_CONTRACT_INVALID_JSON: ${file}: ${err.message}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const entries = { telemetry: new Map(), commands: new Map() }
|
|
41
|
+
const loaded = loadPlugin({
|
|
42
|
+
contract,
|
|
43
|
+
dir: pkgDir,
|
|
44
|
+
// A directory-loaded plugin has no connect(): WorkerRuntimeV2 builds an
|
|
45
|
+
// instance per device instead. Only present because v1's shape demands it.
|
|
46
|
+
connect: _noConnect,
|
|
47
|
+
loadHandler: (handlerFile, { section, name }) => {
|
|
48
|
+
entries[section].set(name, require.resolve(handlerFile))
|
|
49
|
+
return _unbound(section, name)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
dir: pkgDir,
|
|
55
|
+
contract: loaded.contract,
|
|
56
|
+
publishedContract: loaded.publishedContract,
|
|
57
|
+
entries
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _noConnect () {
|
|
62
|
+
throw new Error('ERR_CONTRACT_NO_CONNECT')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// plugin-loader publishes a function per entry; a contract on its own has no
|
|
66
|
+
// process-wide handler to give it, so the placeholder fails loudly rather than
|
|
67
|
+
// silently standing in for a device-bound one.
|
|
68
|
+
function _unbound (section, name) {
|
|
69
|
+
return () => {
|
|
70
|
+
throw new Error(`ERR_HANDLER_NOT_BOUND: ${section}.${name}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { loadContract }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const SCHEMA_PATH = path.join(__dirname, '..', 'mdk-contract.schema.json')
|
|
7
|
+
|
|
8
|
+
let _schema = null
|
|
9
|
+
|
|
10
|
+
function _loadSchema () {
|
|
11
|
+
if (!_schema) _schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'))
|
|
12
|
+
return _schema
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function _jsonType (v) {
|
|
16
|
+
if (Array.isArray(v)) return 'array'
|
|
17
|
+
if (v === null) return 'null'
|
|
18
|
+
return typeof v
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function _matchesType (expected, v) {
|
|
22
|
+
if (expected === 'integer') return typeof v === 'number' && Number.isInteger(v)
|
|
23
|
+
return _jsonType(v) === expected
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Implements the JSON Schema subset mdk-contract.schema.json uses (type, enum,
|
|
27
|
+
// required, properties, additionalProperties, items). A schema feature outside
|
|
28
|
+
// this subset would validate as always-passing here; the ajv parity test in
|
|
29
|
+
// backend/workers guards against that drift.
|
|
30
|
+
function _validateNode (schema, value, at, errors) {
|
|
31
|
+
if (schema.type && !_matchesType(schema.type, value)) {
|
|
32
|
+
errors.push(`ERR_CONTRACT_TYPE: ${at || '/'} expected ${schema.type}, got ${_jsonType(value)}`)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
36
|
+
errors.push(`ERR_CONTRACT_ENUM: ${at} is ${JSON.stringify(value)}, allowed: ${schema.enum.join(' | ')}`)
|
|
37
|
+
}
|
|
38
|
+
if (_jsonType(value) === 'object') {
|
|
39
|
+
for (const key of schema.required || []) {
|
|
40
|
+
if (!(key in value)) errors.push(`ERR_CONTRACT_REQUIRED: ${at || '/'} missing required property '${key}'`)
|
|
41
|
+
}
|
|
42
|
+
const props = schema.properties || {}
|
|
43
|
+
for (const [key, v] of Object.entries(value)) {
|
|
44
|
+
if (props[key]) {
|
|
45
|
+
_validateNode(props[key], v, `${at}/${key}`, errors)
|
|
46
|
+
} else if (schema.additionalProperties === false) {
|
|
47
|
+
errors.push(`ERR_CONTRACT_UNEXPECTED: ${at || '/'} has unexpected property '${key}'`)
|
|
48
|
+
} else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
49
|
+
_validateNode(schema.additionalProperties, v, `${at}/${key}`, errors)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(value) && schema.items) {
|
|
54
|
+
value.forEach((v, i) => _validateNode(schema.items, v, `${at}/${i}`, errors))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Rules the schema cannot express. Handler existence needs the plugin dir, so
|
|
59
|
+
// it only runs when the caller has one (a fetched external contract does not).
|
|
60
|
+
function _semanticChecks (contract, dir, errors) {
|
|
61
|
+
if (_jsonType(contract) !== 'object') return
|
|
62
|
+
const caps = contract.capabilities
|
|
63
|
+
if (_jsonType(caps) !== 'object') return
|
|
64
|
+
|
|
65
|
+
for (const section of ['telemetry', 'commands']) {
|
|
66
|
+
const entries = Array.isArray(caps[section]) ? caps[section] : []
|
|
67
|
+
const seen = new Set()
|
|
68
|
+
entries.forEach((entry, i) => {
|
|
69
|
+
const at = `/capabilities/${section}/${i}`
|
|
70
|
+
if (_jsonType(entry) !== 'object') return
|
|
71
|
+
if (entry.name) {
|
|
72
|
+
if (seen.has(entry.name)) errors.push(`ERR_CONTRACT_DUPLICATE_NAME: ${at} '${entry.name}' already declared in ${section}`)
|
|
73
|
+
seen.add(entry.name)
|
|
74
|
+
}
|
|
75
|
+
if (dir && typeof entry.handler === 'string' && entry.handler) {
|
|
76
|
+
const file = path.resolve(dir, entry.handler)
|
|
77
|
+
if (!fs.existsSync(file)) errors.push(`ERR_CONTRACT_HANDLER_NOT_FOUND: ${at} handler '${entry.handler}' does not exist (resolved: ${file})`)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const commands = Array.isArray(caps.commands) ? caps.commands : []
|
|
83
|
+
commands.forEach((cmd, i) => {
|
|
84
|
+
if (_jsonType(cmd) !== 'object' || !Array.isArray(cmd.params)) return
|
|
85
|
+
cmd.params.forEach((p, j) => {
|
|
86
|
+
if (_jsonType(p) !== 'object' || p.type !== 'number') return
|
|
87
|
+
const at = `/capabilities/commands/${i}/params/${j}`
|
|
88
|
+
if (typeof p.min !== 'number' || typeof p.max !== 'number') {
|
|
89
|
+
errors.push(`ERR_CONTRACT_PARAM_BOUNDS_MISSING: ${at} ('${cmd.name}'.'${p.name}') — number params must declare min and max so the Kernel can enforce ERR_PARAM_RANGE`)
|
|
90
|
+
} else if (p.min > p.max) {
|
|
91
|
+
errors.push(`ERR_CONTRACT_PARAM_BOUNDS_INVALID: ${at} min ${p.min} > max ${p.max}`)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate a parsed mdk-contract.json against the vendored schema, plus the
|
|
99
|
+
* semantic rules the schema cannot express: unique telemetry/command names,
|
|
100
|
+
* number params with coherent min/max, and (when opts.dir names the plugin
|
|
101
|
+
* directory) every handler file present on disk.
|
|
102
|
+
*
|
|
103
|
+
* validateContract(contract, { dir?, semantic = true }) -> { ok, errors }
|
|
104
|
+
* where errors is a list of ERR_CONTRACT_* strings. Pass semantic: false for
|
|
105
|
+
* schema-only conformance (the catalogue generator's mode).
|
|
106
|
+
*/
|
|
107
|
+
function validateContract (contract, opts = {}) {
|
|
108
|
+
const errors = []
|
|
109
|
+
_validateNode(_loadSchema(), contract, '', errors)
|
|
110
|
+
if (opts.semantic !== false) _semanticChecks(contract, opts.dir, errors)
|
|
111
|
+
return { ok: errors.length === 0, errors }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { validateContract, SCHEMA_PATH }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const debug = require('debug')('mdk:worker:instance-loader')
|
|
5
|
+
const { createModuleContext } = require('./module-context')
|
|
6
|
+
|
|
7
|
+
const SECTIONS = ['telemetry', 'commands']
|
|
8
|
+
|
|
9
|
+
// Builtin telemetry entries synthesized into every instance rather than
|
|
10
|
+
// loaded from a contract-declared handler file. Registered only when the
|
|
11
|
+
// plugin's own contract did not already declare a channel of the same name
|
|
12
|
+
// (see createInstance below) — a real plugin handler always wins. Each
|
|
13
|
+
// closes over this instance's own frozen `device`, so calling it is genuine
|
|
14
|
+
// per-device dispatch (`entry.instance.telemetry.get('health')(...)`), not a
|
|
15
|
+
// runtime-level shortcut: it proves the addressed device's own instance
|
|
16
|
+
// answered, the same way any real handler would.
|
|
17
|
+
//
|
|
18
|
+
// The reply is deliberately limited to identifiers: `env` (secrets resolved
|
|
19
|
+
// from mdk.yaml), `opts` (device credentials) and `config` must never leave
|
|
20
|
+
// the process through a telemetry channel any caller can pull.
|
|
21
|
+
const BUILTIN_TELEMETRY = {
|
|
22
|
+
health: (device) => () => ({
|
|
23
|
+
status: 'OK',
|
|
24
|
+
id: device.id,
|
|
25
|
+
workerId: device.workerId
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Instantiate a directory-loaded Worker Plugin for one device.
|
|
31
|
+
*
|
|
32
|
+
* The plugin's own files get a private module registry (see module-context.js),
|
|
33
|
+
* so everything they do at load time (construct a client, open a store) happens
|
|
34
|
+
* once per device and their module-level state is that device's.
|
|
35
|
+
*
|
|
36
|
+
* createInstance({ dir, entries, device }) -> { telemetry: Map, commands: Map }
|
|
37
|
+
* of plain (params) functions. `entries` is loadContract().entries; `device` is
|
|
38
|
+
* what the plugin sees as '@tetherto/mdk-worker/device'. Per handlers HLD §3
|
|
39
|
+
* this aborts rather than degrades: a handler module that is missing, throws at
|
|
40
|
+
* load or does not export a function fails here, naming the device.
|
|
41
|
+
*
|
|
42
|
+
* `instance.telemetry` also always carries the builtin entries in
|
|
43
|
+
* BUILTIN_TELEMETRY (e.g. `health`) unless the contract declared a real
|
|
44
|
+
* channel of the same name — see BUILTIN_TELEMETRY above.
|
|
45
|
+
*/
|
|
46
|
+
function createInstance (opts) {
|
|
47
|
+
const { dir, entries, device } = opts || {}
|
|
48
|
+
if (typeof dir !== 'string' || !dir) throw new Error('ERR_INSTANCE_DIR_REQUIRED')
|
|
49
|
+
if (!entries || typeof entries !== 'object') throw new Error('ERR_INSTANCE_ENTRIES_REQUIRED')
|
|
50
|
+
if (!device || typeof device !== 'object') throw new Error('ERR_INSTANCE_DEVICE_REQUIRED')
|
|
51
|
+
if (typeof device.id !== 'string' || !device.id) throw new Error('ERR_INSTANCE_DEVICE_ID_REQUIRED')
|
|
52
|
+
|
|
53
|
+
// WorkerRuntimeV2 always sets device.logger (a debug() instance); a device
|
|
54
|
+
// built by hand (contract-level tests, say) may omit it, so default to a
|
|
55
|
+
// no-op rather than let a handler's unconditional logger(...) call throw.
|
|
56
|
+
const ambientDevice = device.logger ? device : { ...device, logger: _noopLogger }
|
|
57
|
+
|
|
58
|
+
const context = createModuleContext({
|
|
59
|
+
dir,
|
|
60
|
+
ambient: { '@tetherto/mdk-worker/device': ambientDevice },
|
|
61
|
+
label: `[mdk-instance:${device.id}]`
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const instance = { telemetry: new Map(), commands: new Map() }
|
|
65
|
+
for (const section of SECTIONS) {
|
|
66
|
+
for (const [name, handler] of _pairs(entries[section])) {
|
|
67
|
+
const where = `${device.id}: ${section}.${name}: ${handler}`
|
|
68
|
+
const request = path.resolve(context.root, handler)
|
|
69
|
+
|
|
70
|
+
let file
|
|
71
|
+
try {
|
|
72
|
+
file = context.resolve(request)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new Error(`ERR_INSTANCE_HANDLER_NOT_FOUND: ${where}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let fn
|
|
78
|
+
try {
|
|
79
|
+
fn = context.load(file)
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error(`ERR_INSTANCE_HANDLER_LOAD_FAILED: ${where}: ${err.message}`, { cause: err })
|
|
82
|
+
}
|
|
83
|
+
if (typeof fn !== 'function') throw new Error(`ERR_INSTANCE_HANDLER_NOT_FUNCTION: ${where}`)
|
|
84
|
+
|
|
85
|
+
instance[section].set(name, fn)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const [name, build] of Object.entries(BUILTIN_TELEMETRY)) {
|
|
90
|
+
if (instance.telemetry.has(name)) continue
|
|
91
|
+
instance.telemetry.set(name, build(device))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
debug('instance ready for %s (%d private modules)', device.id, context.size)
|
|
95
|
+
return instance
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// loadContract() hands over Maps; plain objects are accepted so a test can
|
|
99
|
+
// build a section by hand.
|
|
100
|
+
function _pairs (section) {
|
|
101
|
+
if (!section) return []
|
|
102
|
+
return section instanceof Map ? section : Object.entries(section)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _noopLogger () {}
|
|
106
|
+
|
|
107
|
+
module.exports = { createInstance }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const Module = require('module')
|
|
6
|
+
|
|
7
|
+
const PRIVATE_EXTENSIONS = new Set(['.js', '.cjs'])
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Private module registry for one plugin load — the interception mechanism
|
|
11
|
+
* behind the ambient context modules ('@tetherto/mdk-worker/device' for worker
|
|
12
|
+
* instances, and the gateway/MCP equivalents).
|
|
13
|
+
*
|
|
14
|
+
* Files under `dir` get their own registry, so module-level state (a client
|
|
15
|
+
* constructed at load time, say) belongs to this context alone. `node_modules`
|
|
16
|
+
* and `node:` builtins keep resolving through the shared cache — `debug` and
|
|
17
|
+
* `node:sqlite` are process singletons, not per-context copies.
|
|
18
|
+
*
|
|
19
|
+
* Isolation rests on Node's Module internals (the pattern proxyquire and
|
|
20
|
+
* friends use): a Module per in-package file, compiled by hand, with `require`
|
|
21
|
+
* routed back here so in-package requests stay private.
|
|
22
|
+
*
|
|
23
|
+
* createModuleContext({ dir, ambient, label }) -> { root, size, resolve, load }.
|
|
24
|
+
* `ambient` maps specifiers to values; both the bare and `.js` spellings are
|
|
25
|
+
* intercepted before resolution, so a plugin does not need the host package
|
|
26
|
+
* installed to require its context. `label` names the synthetic entry parent
|
|
27
|
+
* in Node's require stack when a resolution fails.
|
|
28
|
+
*/
|
|
29
|
+
function createModuleContext (opts) {
|
|
30
|
+
const { dir, ambient, label } = opts || {}
|
|
31
|
+
if (typeof dir !== 'string' || !dir) throw new Error('ERR_MODULE_CONTEXT_DIR_REQUIRED')
|
|
32
|
+
|
|
33
|
+
const requests = new Map()
|
|
34
|
+
for (const [specifier, value] of Object.entries(ambient || {})) {
|
|
35
|
+
requests.set(specifier, value)
|
|
36
|
+
requests.set(`${specifier}.js`, value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Realpath because Node resolves modules through symlinks: a package reached
|
|
40
|
+
// via a workspace link would otherwise look out-of-package and lose isolation.
|
|
41
|
+
const root = _realpath(path.resolve(dir))
|
|
42
|
+
const registry = new Map()
|
|
43
|
+
|
|
44
|
+
const isPrivate = (file) => {
|
|
45
|
+
if (!PRIVATE_EXTENSIONS.has(path.extname(file))) return false
|
|
46
|
+
const rel = path.relative(root, file)
|
|
47
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
48
|
+
return !rel.split(path.sep).includes('node_modules')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const requireFrom = (parent, request) => {
|
|
52
|
+
if (requests.has(request)) return requests.get(request)
|
|
53
|
+
if (Module.isBuiltin(request)) return Module._load(request, parent, false)
|
|
54
|
+
|
|
55
|
+
const file = Module._resolveFilename(request, parent, false)
|
|
56
|
+
if (!isPrivate(file)) return Module._load(request, parent, false)
|
|
57
|
+
return loadPrivate(file, parent)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const loadPrivate = (file, parent) => {
|
|
61
|
+
const cached = registry.get(file)
|
|
62
|
+
if (cached) return cached.exports
|
|
63
|
+
|
|
64
|
+
const mod = new Module(file, parent)
|
|
65
|
+
mod.filename = file
|
|
66
|
+
mod.paths = Module._nodeModulePaths(path.dirname(file))
|
|
67
|
+
mod.require = (request) => requireFrom(mod, request)
|
|
68
|
+
// Registered before compiling so a require cycle inside the package sees a
|
|
69
|
+
// partial exports object instead of recursing, as the shared loader does.
|
|
70
|
+
registry.set(file, mod)
|
|
71
|
+
try {
|
|
72
|
+
mod._compile(fs.readFileSync(file, 'utf8'), file)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
registry.delete(file)
|
|
75
|
+
throw err
|
|
76
|
+
}
|
|
77
|
+
mod.loaded = true
|
|
78
|
+
return mod.exports
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Synthetic parent for the context's entry files: it carries the package's
|
|
82
|
+
// node_modules paths, and names the context in Node's require stack when a
|
|
83
|
+
// resolution fails.
|
|
84
|
+
const entryParent = new Module(path.join(root, label || '[mdk-module-context]'), null)
|
|
85
|
+
entryParent.filename = entryParent.id
|
|
86
|
+
entryParent.paths = Module._nodeModulePaths(root)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
root,
|
|
90
|
+
get size () { return registry.size },
|
|
91
|
+
resolve: (request) => Module._resolveFilename(request, entryParent, false),
|
|
92
|
+
load: (request) => requireFrom(entryParent, request)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _realpath (dir) {
|
|
97
|
+
try {
|
|
98
|
+
return fs.realpathSync(dir)
|
|
99
|
+
} catch {
|
|
100
|
+
return dir
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { createModuleContext }
|