@validation-engineering/cordis-loader 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/NOTICE +49 -0
- package/README.md +224 -2
- package/artifact.js +80 -0
- package/config.js +102 -0
- package/domain-entry.js +85 -0
- package/harness.d.ts +57 -0
- package/harness.js +394 -0
- package/index.d.ts +83 -0
- package/index.js +384 -0
- package/module-graph.js +150 -0
- package/module-host.js +51 -0
- package/official-hmr.js +304 -0
- package/package.json +58 -4
- package/process-entry.js +10 -0
- package/process.d.ts +42 -0
- package/process.js +155 -0
- package/rust-module-plugin.d.ts +35 -0
- package/rust-module-plugin.js +195 -0
- package/rust-module-state.js +66 -0
- package/rust-module.d.ts +74 -0
- package/rust-module.js +269 -0
- package/worker-entry.js +7 -0
- package/worker.d.ts +53 -0
- package/worker.js +233 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cordis-verus contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/NOTICE
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
This is an independent Rust reimplementation of a subset of Cordis semantics.
|
|
2
|
+
It is not an official Cordis, DeepSeek, or Verus distribution.
|
|
3
|
+
|
|
4
|
+
Design and behavior references:
|
|
5
|
+
- Cordis, https://github.com/cordiverse/cordis (MIT)
|
|
6
|
+
- DeepSeek Harness, https://github.com/deepseek-ai/deepseek-harness (MIT)
|
|
7
|
+
- A Programming Paradigm for Spatiotemporal Composability,
|
|
8
|
+
https://arxiv.org/abs/2608.25512v1 (https://github.com/validation-engineering/cordis-verus/blob/main/reference/README.md)
|
|
9
|
+
|
|
10
|
+
The ignored upstream checkouts retain their own license and copyright files.
|
|
11
|
+
The reference paper is not covered by this project's MIT license.
|
|
12
|
+
Verus binaries and vstd retain their upstream licenses. Binary archives are
|
|
13
|
+
downloaded into the ignored .tools directory. Project-managed toolchain archives
|
|
14
|
+
preserve original upstream bytes with a source/notices companion; see
|
|
15
|
+
https://github.com/validation-engineering/cordis-verus/blob/main/docs/toolchains/README.md.
|
|
16
|
+
They are separate from Cordis runtime releases.
|
|
17
|
+
|
|
18
|
+
The experimental JavaScript compatibility package adapts language-layer code
|
|
19
|
+
from Cordis (Copyright (c) 2021-present Shigma, MIT). The original MIT notice
|
|
20
|
+
is preserved in packages/compat-cordis/LICENSE.upstream. It does not embed the
|
|
21
|
+
upstream Fiber lifecycle scheduler. Node-API dependencies retain their own licenses.
|
|
22
|
+
|
|
23
|
+
The compatibility logger in packages/compat-cordis/logger.js is adapted from
|
|
24
|
+
Cordis packages/core/src/logger.ts at the locked Cordis revision. Its formatting,
|
|
25
|
+
exporter and traceable-caller behavior are ordinary JavaScript, outside the
|
|
26
|
+
Verus proof boundary, and retain the same MIT attribution above.
|
|
27
|
+
|
|
28
|
+
Windows configuration persistence uses atomicwrites 0.4.4
|
|
29
|
+
(https://github.com/untitaker/rust-atomicwrites), under the MIT license:
|
|
30
|
+
|
|
31
|
+
Copyright (c) 2015 Markus Unterwaditzer
|
|
32
|
+
|
|
33
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
34
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
35
|
+
the Software without restriction, including without limitation the rights to
|
|
36
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
37
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
38
|
+
so, subject to the following conditions:
|
|
39
|
+
|
|
40
|
+
The above copyright notice and this permission notice shall be included in all
|
|
41
|
+
copies or substantial portions of the Software.
|
|
42
|
+
|
|
43
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
44
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
45
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
46
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
47
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
48
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
49
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,225 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Native Cordis JSON Loader
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
This experimental package loads real JS Cordis plugins through `compat-cordis` and the Rust native driver. It adds a **JSON configuration API** and an explicit **whole-Worker code replacement API**. It does not yet implement the upstream Loader/Include classes or Harness configuration language. A Loader can use either native Context profile; the Worker bootstrap selects the Cordis profile.
|
|
4
|
+
|
|
5
|
+
Use the matching `@validation-engineering/cordis` package, which includes the precompiled native addon. No compiler or additional runtime dependencies are required beyond that package and Node 22.22+. Source contributors can rebuild it with `npm run build:native`.
|
|
6
|
+
|
|
7
|
+
## Load a configuration in the current environment
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { Context } from '@validation-engineering/cordis';
|
|
11
|
+
import { Loader } from '@validation-engineering/cordis-loader';
|
|
12
|
+
|
|
13
|
+
const context = new Context();
|
|
14
|
+
const loader = new Loader(context);
|
|
15
|
+
await loader.loadFile('./cordis.json');
|
|
16
|
+
console.log(loader.diagnostics());
|
|
17
|
+
await loader.update('greeting', { config: { prefix: 'Welcome' } });
|
|
18
|
+
await loader.dispose();
|
|
19
|
+
await context.dispose();
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Start the program with `--import @validation-engineering/cordis/register` when plugin sources import the original `cordis` package name. The registered facade is still the only lifecycle implementation.
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
[
|
|
26
|
+
{
|
|
27
|
+
"id": "greeting",
|
|
28
|
+
"name": "./plugins/greeting.mjs",
|
|
29
|
+
"config": { "prefix": "Hello" }
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "tools",
|
|
33
|
+
"include": "./tools.json",
|
|
34
|
+
"isolate": ["tools"]
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
An input is an entry array or `{ "entries": [...] }`. Every entry has an explicit stable `id`. Module and include paths resolve relative to the configuration file that contains them. `include` adds a group; `group: true` with `entries` creates an inline group. Nested entry IDs use `/`, for example `tools/search`. IDs may contain letters, digits, `_`, `-` and `.`. Include cycles, duplicate IDs, unsupported fields and invalid JSON values fail before the active graph is stopped.
|
|
40
|
+
|
|
41
|
+
Supported options are `id`, `name`, `config`, `disabled`, `group`, `entries`, `include`, `inject`, `isolate` and `intercept`. `inject` accepts service names or a name-to-config mapping. `isolate: ["service"]` creates an entry-specific realm; `isolate: {"service": "shared-label"}` intentionally shares that label between entries in this Loader. Groups own their child fibers. Disabled groups retain entry records while skipping module imports and activation for their descendants.
|
|
42
|
+
|
|
43
|
+
Entries returned by `resolve()` keep their object identity across successful updates and restoration. A persistent Loader owner contains the configured tree. Updates compare each stable ID's canonical module URL, prepared plugin object identity and optional revision, JSON config, injection, isolation, interception, source base URL and parent. Unchanged fibers remain mounted; changed entries and descendants of changed parents are retired and recreated. Adding or removing children does not restart an unchanged group. JSON object key order and sibling reordering do not restart instances; sibling order governs newly mounted entries and diagnostics, while retained instances keep their original effect registration order.
|
|
44
|
+
|
|
45
|
+
A preserved consumer can still run cleanup and setup again when its dependency changes: the shared native driver owns that transition, and the consumer keeps its Fiber ID while its episode changes. A group's dependency restart recreates its owned children from the latest committed recipe. Changing a group's isolation/injection/interception replaces its entire subtree. Config changes currently replace the changed plugin rather than invoke upstream in-place update hooks. Explicit entry inject metadata uses a forwarding plugin callback and is not an exact upstream registry-identity implementation. The application Context and unrelated plugins are not disposed by `loader.dispose()`.
|
|
46
|
+
|
|
47
|
+
### Readiness, serialization and restoration
|
|
48
|
+
|
|
49
|
+
`await ctx.plugin()` can settle Pending. The Loader therefore checks every enabled configured entry after settlement. By default a missing dependency rejects activation with `DEPENDENCIES_UNAVAILABLE` and structured `details.entries` diagnostics. `allowPending: true` accepts a partially available tree. Diagnostics cover configured entries; arbitrary plugin-created children retain their own lifecycle API.
|
|
50
|
+
|
|
51
|
+
External `apply`, `loadFile`, `update`, `setEnabled`, `reload` and `dispose` calls enter the runtime domain's shared mutation queue at admission. All Loaders over the same Context domain share that queue with external Fiber updates, restarts, disposal and domain shutdown. Preparing a candidate is part of the transaction, so another Loader cannot activate a competing revision while its modules are being prepared. Independent Context domains keep separate queues.
|
|
52
|
+
|
|
53
|
+
Plugin setup can immediately create and await `ctx.plugin()` children; effect registration and native completion continue during a transaction. Loader retirement, recovery and cleanup use scoped transaction steps, without queueing behind their own operation. Calling a Loader mutation from an active lifecycle callback or another transaction, including a different Loader's callback or module preparation, is rejected as `REENTRANT_MUTATION`. This also applies when the requested close is already queued; joining that Promise would wait on the action currently blocking it.
|
|
54
|
+
|
|
55
|
+
`loader.dispose()` closes only that Loader and joins repeated external requests. `context.dispose()` closes admission for new domain revisions as soon as shutdown is requested, waits for previously admitted transactions, and then drains the graph. An admitted transaction can still finish plugin setup and cleanup. A cleanup failure anywhere in the domain blocks subsequent ordinary revisions; explicit cleanup retry and disposal remain available. This does not make module evaluation or external side effects atomic, add configuration write-back, or invalidate Node's module caches.
|
|
56
|
+
|
|
57
|
+
The Loader imports modules and validates candidate JSON inputs before disturbing the old tree. Runtime plugin schema validation still occurs when the replacement is mounted. It retains the old module objects and an immutable JSON recipe. Each activation gets fresh configuration objects. The changed old subtrees and their dependent cleanup drain before replacements mount. If candidate setup fails, every candidate-created fiber and its children must be removed before the replaced part of the old recipe can restart. Unaffected siblings and parents remain mounted. Group controllers are restored to the old recipe as well, so a later dependency notification cannot reactivate rejected configuration. A retained consumer that failed while observing the candidate is explicitly restarted against the restored dependencies. A cleanup failure leaves the Loader `blocked`; it does not activate the old recipe or a queued replacement. After fixing the external cause, `retryCleanup()` retries failed inverses and drains the whole Loader, including retained siblings, returning to an empty state. The caller can then explicitly load a configuration. Successful inverses are not repeated.
|
|
58
|
+
|
|
59
|
+
`RELOAD_FAILED` with `details.restored: true` means the replaced part of the old recipe was instantiated again; replaced live objects do not survive. Unchanged entries can keep their instances. Module evaluation, arbitrary global state and external file/network/database effects are not rolled back.
|
|
60
|
+
|
|
61
|
+
`loader.reload()` re-reads its last file and included JSON files, then reconciles the changed subtrees. With unchanged configuration and factory identities, it does not remount the graph. After a programmatic `update()` the source is an in-memory recipe; subsequent `reload()` uses that recipe and asks the ModuleHost to prepare its factories again. There is no implicit write-back to the original configuration files and no background file watcher.
|
|
62
|
+
|
|
63
|
+
## Explicit transactions for the official Harness Loader
|
|
64
|
+
|
|
65
|
+
The separate `@validation-engineering/cordis-loader/harness` entry adapts the **installed,
|
|
66
|
+
unchanged** official Harness `Loader` and `Include` classes. It does not replace
|
|
67
|
+
this package's JSON API. `LoaderTransactions` offers explicit host operations;
|
|
68
|
+
`installOfficialTransactions` additionally connects the official configuration UI
|
|
69
|
+
and refresh/HMR queues when installed by the application before boot.
|
|
70
|
+
The tested contract is Harness Cordis 4.0.4, Loader 1.0.5 and Include 1.0.9.
|
|
71
|
+
Use the native Harness profile and a live, fully initialized tree:
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
import { LoaderTransactions } from '@validation-engineering/cordis-loader/harness';
|
|
75
|
+
|
|
76
|
+
// ctx and ctx.loader already belong to the booted official application.
|
|
77
|
+
// Select an Include file owned by this host, not an unrelated profile overlay.
|
|
78
|
+
const edits = new LoaderTransactions(ctx, ctx.loader.resolve('custom').subtree);
|
|
79
|
+
await edits.update('greeting', { config: { prefix: 'Welcome' } });
|
|
80
|
+
const id = await edits.create({ name: './another-plugin.mjs', config: {} });
|
|
81
|
+
// create preserves the official fully qualified id; resolve it from the root.
|
|
82
|
+
const rootEdits = new LoaderTransactions(ctx, ctx.loader);
|
|
83
|
+
await rootEdits.remove(id);
|
|
84
|
+
await edits.close(); // closes this adapter's admission; does not dispose the tree
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`create`, `update` and `remove` enter the same domain FIFO as Fiber revisions,
|
|
88
|
+
JSON Loader updates and Context shutdown **before** changing options or files.
|
|
89
|
+
Cloneable options are snapshotted at admission, including `undefined` deletion
|
|
90
|
+
values; functions and other non-cloneable values reject before admission.
|
|
91
|
+
The adapter retains old and new Fibers, waits for official Loader and native
|
|
92
|
+
lifecycle work, and observes the actual Include write queues, including writes
|
|
93
|
+
scheduled by timers or teardown. This is conservatively a **whole Loader graph
|
|
94
|
+
barrier**: sibling Includes and existing work may delay it or report failure.
|
|
95
|
+
Use it from an external host operation, outside plugin setup/cleanup actions.
|
|
96
|
+
|
|
97
|
+
Success means that the observed lifecycle and persistence work completed. It is
|
|
98
|
+
not an atomic rollback mechanism or a guarantee of physical-disk durability after
|
|
99
|
+
power loss. An `OFFICIAL_PERSISTENCE_FAILED` error names affected files and retains
|
|
100
|
+
causes; running plugin changes may already have happened. A subsequent explicit
|
|
101
|
+
revision can retry the official write queue. Cleanup failures still block new
|
|
102
|
+
revisions until actual cleanup recovery succeeds. Pending dependencies retain
|
|
103
|
+
upstream Pending semantics; completion does not certify every plugin Active.
|
|
104
|
+
`revision` counts successful operations, `requestedRevision` counts admitted
|
|
105
|
+
requests, and `lastFailure` retains the last failed request's operation and error.
|
|
106
|
+
|
|
107
|
+
A cached adapter belongs to its tree owner's current episode. If that Include
|
|
108
|
+
is removed or restarted, `STALE_LOADER` rejects old requests before mutation;
|
|
109
|
+
obtain a new adapter for the new tree. Foreign domains, incompatible class shapes
|
|
110
|
+
and methods replaced after construction are rejected. Nested removal selects the
|
|
111
|
+
entry's owning tree and local id before invoking the unchanged official method.
|
|
112
|
+
|
|
113
|
+
The bridge uses `MutationSteps.capture(callback)`, whose authority lasts only
|
|
114
|
+
through the callback's synchronous call stack in that domain. A returned Promise
|
|
115
|
+
does not extend that authority; plugin lifecycle callbacks and async update
|
|
116
|
+
continuations cannot borrow it. Ignored lifecycle Promises are still joined.
|
|
117
|
+
|
|
118
|
+
The explicit adapter does not intercept arbitrary direct object or file mutations.
|
|
119
|
+
Applications can additionally call `installOfficialTransactions` with the pinned
|
|
120
|
+
`Entry`, `EntryGroup`, `EntryTree`, `Hmr` and `ConfigEditor` classes **before boot**.
|
|
121
|
+
The companion Harness application already installs it in its default preload.
|
|
122
|
+
That host bridge admits ConfigEditor edits before acquiring the profile file lock,
|
|
123
|
+
then keeps write/reconcile/rollback and Include refresh in the same domain slot.
|
|
124
|
+
It also orders `hmr.runExclusive` with configuration and shutdown work. The original
|
|
125
|
+
methods still implement the configuration semantics; no upstream source files
|
|
126
|
+
are rewritten. See [official configuration transactions](../../docs/official-config-transactions.md)
|
|
127
|
+
for setup, failure and callback contracts.
|
|
128
|
+
|
|
129
|
+
A failed inverse is not automatically retried by rollback. Readiness and restart
|
|
130
|
+
report the retained native barrier even when a transient JavaScript error has
|
|
131
|
+
already been observed; explicit cleanup recovery is required. Event and edit
|
|
132
|
+
callbacks cannot borrow transaction steps. `steps.observe(fiber, callback)` retains
|
|
133
|
+
the callback's episode identity, and `steps.isCurrent()` lets a trusted bridge
|
|
134
|
+
check its exact coordinator origin before capturing a synchronous continuation.
|
|
135
|
+
These are host APIs, not a sandbox for arbitrary application code.
|
|
136
|
+
|
|
137
|
+
Official main-thread `partialReload()` now performs dependency-closed ESM/CJS
|
|
138
|
+
replacement in the existing Context. It waits for old cleanup before changing
|
|
139
|
+
caches, preserves unrelated identities and retains a recovery journal when
|
|
140
|
+
candidate cleanup or old restoration fails. The official watcher includes pure
|
|
141
|
+
CJS leaves. See [in-place HMR](../../docs/official-in-place-hmr.md) for observed
|
|
142
|
+
graph coverage, concurrency, failure and native-addon boundaries.
|
|
143
|
+
[Worker module graphs](../../docs/module-graph-reloads.md) replace a complete
|
|
144
|
+
captured environment; application native addons use `ProcessDomain` below.
|
|
145
|
+
Runtime shape checks do not certify a different upstream version or arbitrary
|
|
146
|
+
EntryTree subclass.
|
|
147
|
+
|
|
148
|
+
The core gate checks admission contracts, callback isolation and type declarations;
|
|
149
|
+
the companion `cordis-harness` gate executes installed official classes, default
|
|
150
|
+
boot, real JSON/YAML edits, write/rollback failures, cleanup barriers and stale
|
|
151
|
+
owner rejection. These are behavioral checks, not proofs of filesystem durability
|
|
152
|
+
or arbitrary callback effects.
|
|
153
|
+
|
|
154
|
+
## Replace code in a fresh Worker
|
|
155
|
+
|
|
156
|
+
`ModuleHost` imports canonical file URLs. It **does not invalidate Node's ESM or CommonJS caches**. Changing a source file and calling `loader.reload()` is not code HMR. `ModuleHost.reset()` explicitly rejects with `DOMAIN_RESTART_REQUIRED`. A file edit at an already imported URL does not change the cached factory identity and is not a request for code replacement.
|
|
157
|
+
|
|
158
|
+
An embedding host can supply an adapter for **already prepared factories**:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
import { ModuleHost } from '@validation-engineering/cordis-loader';
|
|
162
|
+
|
|
163
|
+
const moduleHost = new ModuleHost({
|
|
164
|
+
loadModule: async canonicalURL => preparedFactories.get(canonicalURL),
|
|
165
|
+
});
|
|
166
|
+
// Each value is { plugin, namespace?, revision?: string | number }.
|
|
167
|
+
const loader = new Loader(context, { moduleHost });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Resolution still requires an explicit existing file path and applies `rootDirectory` confinement. The adapter replaces only module loading; it does not bypass JSON validation or lifecycle cleanup. It must return the same plugin object and revision while the executable factory is unchanged. A changed identity or revision requests replacement of entries using that module. Revision is a comparison token, not a claim that Node's dependency cache was reset. Publish immutable factory snapshots: the retained old plugin object must remain executable for rollback. Building source, evaluating it in another environment, deciding the dependency closure and maintaining those factory snapshots remain the embedding host's responsibility. Use `WorkerDomain` or `ProcessDomain` when an ESM/CommonJS environment and its transitive dependencies must actually be replaced.
|
|
171
|
+
|
|
172
|
+
Use a `WorkerDomain` for real code replacement, including changed transitive imports:
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
import { WorkerDomain } from '@validation-engineering/cordis-loader/worker';
|
|
176
|
+
|
|
177
|
+
const domain = new WorkerDomain({ entry: 'cordis.json' });
|
|
178
|
+
await domain.load('./plugin-project');
|
|
179
|
+
console.log(await domain.call('greeting', 'hello', 'Alice'));
|
|
180
|
+
// Build/publish the candidate project directory first.
|
|
181
|
+
await domain.reload('./plugin-project');
|
|
182
|
+
await domain.dispose();
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Each load captures the local project directory and its dependencies/assets into a retained artifact, with content hashes and permissions. Each Worker receives a separate launch copy. Mutating launch files cannot change the retained recovery recipe. A new Worker owns a new Node environment, native Driver and real module cache. No query suffix is used to simulate dependency-graph reload.
|
|
186
|
+
|
|
187
|
+
Replacement follows this order:
|
|
188
|
+
|
|
189
|
+
1. Capture the candidate artifact while the old Worker is available.
|
|
190
|
+
2. Stop new requests and drain admitted service calls, then normally dispose the old native graph and exit the Worker.
|
|
191
|
+
3. Start the candidate Worker and require configured entries to become ready.
|
|
192
|
+
4. On startup failure, normally drain and exit that candidate; only then restart the retained old artifact in another fresh Worker.
|
|
193
|
+
|
|
194
|
+
A cleanup failure or unknown timeout blocks replacement. An unexpected Worker exit is `abandoned`, not a successful shutdown. `abandon()` explicitly terminates remaining Workers and returns `{ abandoned: true, cleanupConfirmed: false }`; it never automatically restores the old artifact. The default operation timeout is 30 seconds and is only a failure detector, not a cancellation or cleanup proof.
|
|
195
|
+
|
|
196
|
+
Objects/functions stay ordinary JS objects **inside** each Worker. `WorkerDomain.call(service, method, ...args)` is an explicit asynchronous JSON boundary between environments. It has no transparent callback/stream/object transport. A Worker is not an operating-system process or a security sandbox; a native crash can affect the whole process.
|
|
197
|
+
|
|
198
|
+
### Replace an OS process
|
|
199
|
+
|
|
200
|
+
Use `ProcessDomain` from `@validation-engineering/cordis-loader/process` for application
|
|
201
|
+
native addons or a whole application process. It has the same captured-artifact
|
|
202
|
+
load/reload/call workflow and adds a read-only PID. A successful replacement needs
|
|
203
|
+
both a cleanup acknowledgement and exit code zero; a timeout or crash is not
|
|
204
|
+
normal cleanup. An optional trusted `hostModule` can host the official Harness
|
|
205
|
+
without creating another Context. See the [process contract and example](../../docs/module-graph-reloads.md#processdomain-application-native-addons-and-external-hosts)
|
|
206
|
+
for adapter, provenance and failure boundaries. The companion Harness exposes an
|
|
207
|
+
opt-in [official Web supervisor](https://github.com/validation-engineering/cordis-harness/blob/main/docs/process-supervisor.md).
|
|
208
|
+
|
|
209
|
+
### Artifact contract
|
|
210
|
+
|
|
211
|
+
Publish a complete, stable input directory before capture. Include all relative imports, installed dependencies, resources and configuration. The directory must not change during capture. `.git` is excluded; symlinks, special files and artifacts exceeding configured limits are rejected. Defaults are 256 MiB and 20,000 files. Materialize symlinked package dependencies before capture. The facade/addon and Node runtime come from the host installation, not the captured project.
|
|
212
|
+
|
|
213
|
+
Keep persistent writable data outside the captured directory and pass its path explicitly in JSON config. Per-launch writable changes are discarded after shutdown. Entry modules and all JSON Include files must be inside the artifact. Arbitrary imports made by plugin code can still escape that directory; they are not a captured dependency or a sandboxed operation. Complete dependency discovery, package installation and signed release manifests remain future work.
|
|
214
|
+
|
|
215
|
+
## Current compatibility boundary
|
|
216
|
+
|
|
217
|
+
This package supports explicit file module paths and Node's normal execution of built JS/CJS. Bare npm plugin entry names, source TS transformation, YAML/custom tags, JS expressions, upstream Loader event/API compatibility, automatic config write-back, transparent in-process code HMR and Harness lazy/volatile configuration are not implemented. Plugin dependencies may use Node's normal package imports once installed in the project. A `cordis` import is routed by the existing facade bootstrap; embedded/private Cordis copies are not detected by this package.
|
|
218
|
+
|
|
219
|
+
The implementation is ordinary JS host code. Its tests establish the listed behavior; they do not add Verus proofs for module loading, arbitrary plugin callbacks, Worker teardown or filesystem operations.
|
|
220
|
+
|
|
221
|
+
Run `node --test --test-timeout=30000 tests/node-loader/*.test.mjs` from the repository root. The tests execute actual plugin files, nested JSON Include and isolated groups, candidate children, explicit readiness checks, code changes to a transitive import, snapshot recovery, cleanup failure, queued/reentrant updates, Worker crash and TypeScript usage.
|
|
222
|
+
|
|
223
|
+
## Local distribution check
|
|
224
|
+
|
|
225
|
+
`node scripts/check-npm-package.mjs --offline` stages the facade, Loader and Harness profile packages, includes the freshly recorded host-native addon and notices, runs `npm pack --ignore-scripts`, and installs the tarballs in a separate temporary project. It verifies that no workspace path or symlink supplies the packages, then runs native, Loader, Worker, Process (including an application native addon) and Harness profile examples from that installation. The report and local tarballs are written to `target/release-artifacts/npm`. Only the tested OS/architecture/Node combination is recorded as validated; this check uploads nothing and does not establish a prebuilt distribution matrix or registry publish eligibility.
|
package/artifact.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile, chmod } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve, relative, isAbsolute } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { LoaderError } from './config.js';
|
|
6
|
+
|
|
7
|
+
const managedLaunches = new Set();
|
|
8
|
+
/** Release only a private directory allocated by Artifact.launch in this process. */
|
|
9
|
+
export async function releaseLaunch(directory) {
|
|
10
|
+
if (!managedLaunches.has(directory)) throw new LoaderError('ARTIFACT_CLEANUP_TARGET', 'Only a registered private artifact launch may be released');
|
|
11
|
+
await rm(directory, { recursive: true, force: true });
|
|
12
|
+
managedLaunches.delete(directory);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Each isolated environment gets a private copy, never the retained restoration recipe. */
|
|
16
|
+
export class Artifact {
|
|
17
|
+
static async capture(directory, options = {}) {
|
|
18
|
+
const source = resolve(directory);
|
|
19
|
+
if (!(await lstat(source)).isDirectory()) throw new LoaderError('ARTIFACT_DIRECTORY', 'Artifact source must be a directory');
|
|
20
|
+
const storage = await mkdtemp(join(tmpdir(), 'cordis-artifact-'));
|
|
21
|
+
const root = join(storage, 'project');
|
|
22
|
+
await mkdir(root);
|
|
23
|
+
const manifest = [];
|
|
24
|
+
const directories = [];
|
|
25
|
+
let bytes = 0;
|
|
26
|
+
const maxBytes = options.maxBytes ?? 256 * 1024 * 1024;
|
|
27
|
+
const maxFiles = options.maxFiles ?? 20000;
|
|
28
|
+
async function copy(folder = '') {
|
|
29
|
+
const entries = (await readdir(join(source, folder), { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (entry.name === '.git') continue;
|
|
32
|
+
const name = join(folder, entry.name);
|
|
33
|
+
const stat = await lstat(join(source, name));
|
|
34
|
+
if (stat.isSymbolicLink()) throw new LoaderError('ARTIFACT_SYMLINK', `Artifact symlinks are not supported: ${name}; materialize dependencies first`);
|
|
35
|
+
if (stat.isDirectory()) { directories.push(name); await mkdir(join(root, name)); await copy(name); continue; }
|
|
36
|
+
if (!stat.isFile()) throw new LoaderError('ARTIFACT_FILE_TYPE', `Unsupported artifact file ${name}`);
|
|
37
|
+
if (bytes + stat.size > maxBytes || manifest.length >= maxFiles) throw new LoaderError('ARTIFACT_LIMIT', 'Artifact exceeds configured byte/file limit');
|
|
38
|
+
const content = await readFile(join(source, name));
|
|
39
|
+
bytes += content.length;
|
|
40
|
+
if (bytes > maxBytes) throw new LoaderError('ARTIFACT_LIMIT', 'Artifact changed beyond its byte limit during capture');
|
|
41
|
+
const mode = stat.mode & 0o777;
|
|
42
|
+
await writeFile(join(root, name), content);
|
|
43
|
+
await chmod(join(root, name), mode);
|
|
44
|
+
manifest.push({ name, mode, bytes: content.length, sha256: createHash('sha256').update(content).digest('hex') });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
await copy();
|
|
49
|
+
const digest = createHash('sha256').update(JSON.stringify({ files: manifest, directories })).digest('hex');
|
|
50
|
+
return new Artifact(storage, root, source, manifest, directories, digest);
|
|
51
|
+
} catch (error) { await rm(storage, { recursive: true, force: true }); throw error; }
|
|
52
|
+
}
|
|
53
|
+
constructor(storage, root, source, manifest, directories, digest) {
|
|
54
|
+
this.storage = storage; this.root = root; this.source = source;
|
|
55
|
+
this.manifest = Object.freeze(manifest.map(item => Object.freeze(item)));
|
|
56
|
+
this.directories = Object.freeze(directories);
|
|
57
|
+
this.digest = digest; this.closed = false;
|
|
58
|
+
}
|
|
59
|
+
async launch(entry) {
|
|
60
|
+
if (this.closed) throw new LoaderError('ARTIFACT_CLOSED', 'The retained artifact has been released');
|
|
61
|
+
const location = relative(this.root, resolve(this.root, entry));
|
|
62
|
+
if (!location || location === '..' || location.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(location)) throw new LoaderError('ARTIFACT_ENTRY', 'entry must be a file inside the artifact');
|
|
63
|
+
const directory = await mkdtemp(join(tmpdir(), 'cordis-worker-'));
|
|
64
|
+
managedLaunches.add(directory);
|
|
65
|
+
try {
|
|
66
|
+
// Only captured entries are copied; later additions to retained storage
|
|
67
|
+
// cannot enter an artifact under its original digest.
|
|
68
|
+
for (const name of this.directories) await mkdir(join(directory, name));
|
|
69
|
+
for (const item of this.manifest) {
|
|
70
|
+
const content = await readFile(join(this.root, item.name));
|
|
71
|
+
if (createHash('sha256').update(content).digest('hex') !== item.sha256) throw new LoaderError('ARTIFACT_CHANGED', `Retained artifact changed: ${item.name}`);
|
|
72
|
+
await writeFile(join(directory, item.name), content);
|
|
73
|
+
await chmod(join(directory, item.name), item.mode);
|
|
74
|
+
}
|
|
75
|
+
return { directory, entry: location };
|
|
76
|
+
} catch (error) { await releaseLaunch(directory); throw error; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async dispose() { if (!this.closed) { await rm(this.storage, { recursive: true, force: true }); this.closed = true; } }
|
|
80
|
+
}
|
package/config.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, resolve, relative } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
export class LoaderError extends Error {
|
|
6
|
+
constructor(code, message, details = {}, cause) {
|
|
7
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
8
|
+
this.name = 'LoaderError';
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function jsonValue(value, path = '$', seen = new Set()) {
|
|
15
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
16
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
17
|
+
if (typeof value !== 'object' || seen.has(value)) throw new LoaderError('INVALID_CONFIG', `${path} must contain finite JSON values only`);
|
|
18
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
|
|
19
|
+
throw new LoaderError('INVALID_CONFIG', `${path} must be a plain JSON object`);
|
|
20
|
+
}
|
|
21
|
+
seen.add(value);
|
|
22
|
+
const result = Array.isArray(value) ? value.map((item, index) => jsonValue(item, `${path}[${index}]`, seen)) : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, jsonValue(item, `${path}.${key}`, seen)]));
|
|
23
|
+
seen.delete(value);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function freezeJSON(value) {
|
|
28
|
+
if (value && typeof value === 'object') { for (const item of Object.values(value)) freezeJSON(item); Object.freeze(value); }
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function fileURL(input, baseURL = pathToFileURL(`${process.cwd()}/`).href) {
|
|
33
|
+
if (input instanceof URL) input = input.href;
|
|
34
|
+
if (typeof input !== 'string' || !input) throw new LoaderError('INVALID_PATH', 'Expected a file path or file URL');
|
|
35
|
+
const url = input.startsWith('file:') ? new URL(input) : isAbsolute(input) ? pathToFileURL(input) : new URL(input, baseURL);
|
|
36
|
+
if (url.protocol !== 'file:' || url.search || url.hash) throw new LoaderError('INVALID_PATH', 'Only ordinary file paths without query or fragment are supported');
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function object(value, at) {
|
|
41
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new LoaderError('INVALID_CONFIG', `${at} must be an object`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function readTree(input, options = {}) {
|
|
45
|
+
const baseURL = options.baseURL ?? pathToFileURL(`${resolve('.')}/`).href;
|
|
46
|
+
const root = options.rootDirectory ? await realpath(options.rootDirectory) : undefined;
|
|
47
|
+
const files = new Set();
|
|
48
|
+
const ids = new Set();
|
|
49
|
+
const stack = [];
|
|
50
|
+
async function fromFile(filename, parent, base) {
|
|
51
|
+
const url = fileURL(filename, base);
|
|
52
|
+
const canonicalPath = await realpath(fileURLToPath(url));
|
|
53
|
+
if (root) {
|
|
54
|
+
const location = relative(root, canonicalPath);
|
|
55
|
+
if (location === '..' || location.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(location)) throw new LoaderError('CONFIG_OUTSIDE_ARTIFACT', `${canonicalPath} is outside artifact ${root}`);
|
|
56
|
+
}
|
|
57
|
+
const canonical = pathToFileURL(canonicalPath).href;
|
|
58
|
+
if (stack.includes(canonical)) throw new LoaderError('INCLUDE_CYCLE', `Include cycle: ${[...stack, canonical].join(' -> ')}`);
|
|
59
|
+
if (stack.length >= 64) throw new LoaderError('INCLUDE_DEPTH', 'Include nesting exceeds 64 files');
|
|
60
|
+
stack.push(canonical);
|
|
61
|
+
files.add(canonical);
|
|
62
|
+
let raw;
|
|
63
|
+
try { raw = JSON.parse(await readFile(new URL(canonical), 'utf8')); }
|
|
64
|
+
catch (cause) { throw new LoaderError('CONFIG_READ', `Cannot read JSON configuration ${canonical}`, { file: canonical }, cause); }
|
|
65
|
+
try { return await entries(raw, parent, canonical); } finally { stack.pop(); }
|
|
66
|
+
}
|
|
67
|
+
async function entries(tree, parent, base) {
|
|
68
|
+
if (!Array.isArray(tree)) {
|
|
69
|
+
object(tree, 'configuration');
|
|
70
|
+
if (Object.keys(tree).some(key => key !== 'entries')) throw new LoaderError('INVALID_CONFIG', 'The config object supports only entries');
|
|
71
|
+
tree = tree.entries;
|
|
72
|
+
}
|
|
73
|
+
if (!Array.isArray(tree)) throw new LoaderError('INVALID_CONFIG', 'Configuration must be an entries array');
|
|
74
|
+
const result = [];
|
|
75
|
+
for (const raw of tree) {
|
|
76
|
+
object(raw, 'entry');
|
|
77
|
+
const item = freezeJSON(jsonValue(raw));
|
|
78
|
+
const allowed = ['id', 'name', 'config', 'inject', 'isolate', 'intercept', 'disabled', 'group', 'entries', 'include'];
|
|
79
|
+
const unknown = Object.keys(item).filter(key => !allowed.includes(key));
|
|
80
|
+
if (unknown.length) throw new LoaderError('INVALID_CONFIG', `Unknown entry fields: ${unknown.join(', ')}`);
|
|
81
|
+
if (typeof item.id !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(item.id)) throw new LoaderError('INVALID_CONFIG', 'Every entry needs a stable id containing letters, digits, _, - or .');
|
|
82
|
+
const id = parent ? `${parent}/${item.id}` : item.id;
|
|
83
|
+
if (ids.has(id)) throw new LoaderError('DUPLICATE_ENTRY', `Duplicate entry id ${id}`);
|
|
84
|
+
ids.add(id);
|
|
85
|
+
if (item.disabled !== undefined && typeof item.disabled !== 'boolean') throw new LoaderError('INVALID_CONFIG', `${id}.disabled must be boolean`);
|
|
86
|
+
if (item.group !== undefined && typeof item.group !== 'boolean') throw new LoaderError('INVALID_CONFIG', `${id}.group must be boolean`);
|
|
87
|
+
if (item.inject !== undefined && !(Array.isArray(item.inject) ? item.inject.every(name => typeof name === 'string' && name.length) : item.inject && typeof item.inject === 'object')) throw new LoaderError('INVALID_CONFIG', `${id}.inject must be service names or a config object`);
|
|
88
|
+
if (item.intercept !== undefined) object(item.intercept, `${id}.intercept`);
|
|
89
|
+
if (item.isolate !== undefined && !(Array.isArray(item.isolate) ? item.isolate.every(name => typeof name === 'string' && name.length) : item.isolate && typeof item.isolate === 'object' && Object.values(item.isolate).every(label => label === true || typeof label === 'string'))) throw new LoaderError('INVALID_CONFIG', `${id}.isolate must be service names or name-to-label mapping`);
|
|
90
|
+
const group = item.group === true || item.include !== undefined;
|
|
91
|
+
if (group) {
|
|
92
|
+
if (item.name !== undefined || item.config !== undefined) throw new LoaderError('INVALID_CONFIG', `${id}: a group cannot have name or config`);
|
|
93
|
+
if (item.include !== undefined && item.entries !== undefined) throw new LoaderError('INVALID_CONFIG', `${id}: include and entries are mutually exclusive`);
|
|
94
|
+
} else if (typeof item.name !== 'string' || !item.name || item.entries !== undefined) throw new LoaderError('INVALID_CONFIG', `${id}: a plugin requires name; children require group: true`);
|
|
95
|
+
const children = group ? item.include !== undefined ? await fromFile(item.include, id, base) : await entries(item.entries ?? [], id, base) : [];
|
|
96
|
+
result.push({ id, options: item, baseURL: base, group, children });
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
const tree = typeof input === 'string' || input instanceof URL ? await fromFile(input, '', baseURL) : await entries(jsonValue(input), '', baseURL);
|
|
101
|
+
return { tree, files: [...files] };
|
|
102
|
+
}
|
package/domain-entry.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { LoaderError, jsonValue } from './config.js';
|
|
5
|
+
import { ModuleGraph } from './module-graph.js';
|
|
6
|
+
|
|
7
|
+
const serialize = (error, depth = 0) => ({ name: error?.name ?? 'Error', message: error?.message ?? String(error), code: error?.code, details: error?.details, cause: depth < 8 && error?.cause ? serialize(error.cause, depth + 1) : undefined });
|
|
8
|
+
|
|
9
|
+
/** One runtime per child. The transport has no lifecycle scheduler of its own. */
|
|
10
|
+
export function startEndpoint(channel, launch, options = {}) {
|
|
11
|
+
let graph;
|
|
12
|
+
let host, startupError, closing = false, shutdown;
|
|
13
|
+
const calls = new Set();
|
|
14
|
+
const startup = (async () => {
|
|
15
|
+
if (options.hostModule) {
|
|
16
|
+
const { url, sha256 } = options.hostModule;
|
|
17
|
+
const verify = async () => {
|
|
18
|
+
if (createHash('sha256').update(await readFile(new URL(url))).digest('hex') !== sha256) throw new LoaderError('HOST_CHANGED', 'The trusted host module changed after it was pinned');
|
|
19
|
+
};
|
|
20
|
+
await verify();
|
|
21
|
+
const module = await import(url);
|
|
22
|
+
await verify();
|
|
23
|
+
if (typeof module.createHost !== 'function') throw new LoaderError('HOST_CONTRACT', 'Trusted host must export synchronous createHost(options)');
|
|
24
|
+
graph = new ModuleGraph(launch.directory, options).install();
|
|
25
|
+
const candidate = module.createHost({ directory: launch.directory, entry: launch.entry, allowPending: launch.allowPending });
|
|
26
|
+
if (!candidate || typeof candidate.then === 'function' || typeof candidate.ready?.then !== 'function' || ['call', 'diagnostics', 'close'].some(key => typeof candidate[key] !== 'function')) throw new LoaderError('HOST_CONTRACT', 'createHost must immediately expose call(), diagnostics(), close(), and a ready Promise');
|
|
27
|
+
host = candidate;
|
|
28
|
+
await host.ready;
|
|
29
|
+
} else {
|
|
30
|
+
await import('@validation-engineering/cordis/register');
|
|
31
|
+
graph = new ModuleGraph(launch.directory, options).install();
|
|
32
|
+
const { Context } = await import('@validation-engineering/cordis');
|
|
33
|
+
const { Loader, ModuleHost } = await import('./index.js');
|
|
34
|
+
const ctx = new Context();
|
|
35
|
+
const loader = new Loader(ctx, { moduleHost: new ModuleHost({ rootDirectory: launch.directory }), allowPending: launch.allowPending });
|
|
36
|
+
host = {
|
|
37
|
+
diagnostics: () => JSON.parse(JSON.stringify(loader.diagnostics())),
|
|
38
|
+
call: async (service, member, args) => {
|
|
39
|
+
const value = ctx.get(service);
|
|
40
|
+
if (value === undefined || typeof value[member] !== 'function') throw new LoaderError('SERVICE_METHOD', `Missing service method ${service}.${member}`);
|
|
41
|
+
return Reflect.apply(value[member], value, args);
|
|
42
|
+
},
|
|
43
|
+
close: async () => { await loader.dispose(); await ctx.dispose(); },
|
|
44
|
+
};
|
|
45
|
+
await loader.loadFile(join(launch.directory, launch.entry));
|
|
46
|
+
}
|
|
47
|
+
await channel.send({ type: 'ready', diagnostics: jsonValue(await host.diagnostics()) });
|
|
48
|
+
})().catch(async error => {
|
|
49
|
+
startupError = error;
|
|
50
|
+
await channel.send({ type: 'startup-error', error: serialize(error) });
|
|
51
|
+
});
|
|
52
|
+
startup.catch(() => {});
|
|
53
|
+
channel.listen(request => {
|
|
54
|
+
if (!request || typeof request !== 'object' || Array.isArray(request) || !Number.isSafeInteger(request.id) || typeof request.method !== 'string') return;
|
|
55
|
+
const execute = async () => {
|
|
56
|
+
if (request.method === 'shutdown') {
|
|
57
|
+
closing = true;
|
|
58
|
+
shutdown ??= (async () => {
|
|
59
|
+
await startup;
|
|
60
|
+
await Promise.allSettled([...calls]);
|
|
61
|
+
if (!host) throw new LoaderError('CLEANUP_BLOCKED', 'Startup did not expose a host cleanup controller; explicit abandonment is required', {}, startupError);
|
|
62
|
+
await host.close();
|
|
63
|
+
return { disposed: true };
|
|
64
|
+
})();
|
|
65
|
+
try { return await shutdown; } catch (error) { shutdown = undefined; throw error; }
|
|
66
|
+
}
|
|
67
|
+
if (closing) throw new LoaderError('DOMAIN_CLOSING', 'The isolated domain is closing');
|
|
68
|
+
await startup;
|
|
69
|
+
if (startupError) throw startupError;
|
|
70
|
+
if (request.method === 'diagnostics') return jsonValue(await host.diagnostics());
|
|
71
|
+
if (request.method === 'moduleGraph') return graph.snapshot();
|
|
72
|
+
if (request.method !== 'call') throw new LoaderError('DOMAIN_METHOD', 'Unsupported isolated domain request');
|
|
73
|
+
const { service, member, args } = request;
|
|
74
|
+
if (['constructor', '__proto__', 'prototype'].includes(member)) throw new LoaderError('SERVICE_METHOD', 'Reserved service method');
|
|
75
|
+
return jsonValue(await host.call(service, member, args));
|
|
76
|
+
};
|
|
77
|
+
const task = execute();
|
|
78
|
+
if (request.method !== 'shutdown') calls.add(task);
|
|
79
|
+
task.then(async result => {
|
|
80
|
+
await channel.send({ type: 'reply', id: request.id, result });
|
|
81
|
+
if (request.method === 'shutdown') channel.close();
|
|
82
|
+
}, error => channel.send({ type: 'reply', id: request.id, error: serialize(error) }))
|
|
83
|
+
.catch(() => {}).finally(() => calls.delete(task));
|
|
84
|
+
});
|
|
85
|
+
}
|
package/harness.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Context } from '@validation-engineering/cordis';
|
|
2
|
+
|
|
3
|
+
/** Structural surface of the pinned official Harness Loader/Include classes. */
|
|
4
|
+
export interface OfficialEntryTree {
|
|
5
|
+
ctx: object;
|
|
6
|
+
root: { tree: OfficialEntryTree; data: unknown[] };
|
|
7
|
+
store: Record<string, { fiber?: object; subtree?: OfficialEntryTree }>;
|
|
8
|
+
entries(): Iterable<unknown>;
|
|
9
|
+
getTasks(): Promise<unknown>[];
|
|
10
|
+
await(): Promise<void>;
|
|
11
|
+
resolve(id: string): unknown;
|
|
12
|
+
resolveGroup(id: string | null): unknown;
|
|
13
|
+
create(options: { name: string; [key: string]: any }, parent?: string | null, position?: number): Promise<string>;
|
|
14
|
+
update(id: string, options: Record<string, any>, parent?: string | null, position?: number): Promise<void>;
|
|
15
|
+
remove(id: string): void;
|
|
16
|
+
write(): void;
|
|
17
|
+
}
|
|
18
|
+
export class LoaderTransactions {
|
|
19
|
+
constructor(ctx: Context, tree: OfficialEntryTree);
|
|
20
|
+
readonly ctx: Context;
|
|
21
|
+
readonly tree: OfficialEntryTree;
|
|
22
|
+
/** Number of successful create/update/remove operations. */
|
|
23
|
+
readonly revision: number;
|
|
24
|
+
/** Number of admitted operations, including queued or failed operations. */
|
|
25
|
+
readonly requestedRevision: number;
|
|
26
|
+
readonly lastFailure: { revision: number; operation: 'create' | 'update' | 'remove'; error: unknown } | undefined;
|
|
27
|
+
readonly closed: boolean;
|
|
28
|
+
/** Options are structured-cloned at admission. */
|
|
29
|
+
create(options: { name: string; [key: string]: any }, parent?: string | null, position?: number): Promise<string>;
|
|
30
|
+
update(id: string, options: Record<string, any>, parent?: string | null, position?: number): Promise<void>;
|
|
31
|
+
remove(id: string): Promise<void>;
|
|
32
|
+
/** Closes this adapter's admission and drains earlier work; does not dispose the tree. */
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Installs transparent host-only domain coordination before mounting pinned official classes. */
|
|
37
|
+
export function installOfficialTransactions(classes: {
|
|
38
|
+
Entry: Function; EntryGroup: Function; EntryTree: Function; Hmr: Function; ConfigEditor: Function;
|
|
39
|
+
}): void;
|
|
40
|
+
|
|
41
|
+
/** Additional structural HMR surface installed on the pinned official service. */
|
|
42
|
+
export interface OfficialHmrTransactions {
|
|
43
|
+
/** Current retained cleanup/restoration failure; cleared when its journal recovers. */
|
|
44
|
+
readonly lastReloadFailure: (Error & { code: string; details: { restored?: boolean; cacheChanged?: boolean } }) | undefined;
|
|
45
|
+
/** Canonical file URLs changed since the last admitted batch. */
|
|
46
|
+
readonly stashed: Set<string>;
|
|
47
|
+
/** Joins an existing official transaction or admits through the domain FIFO. */
|
|
48
|
+
partialReload(): Promise<void>;
|
|
49
|
+
runExclusive<T>(operation: () => T | Promise<T>): Promise<T>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Admit one host revision around official ConfigEditor/HMR calls and readiness
|
|
53
|
+
* checks. Official methods join this exact coordinator after awaits; lifecycle
|
|
54
|
+
* and config-change callbacks remain observers and cannot borrow its authority.
|
|
55
|
+
* The callback must perform its own application-level restoration on failure.
|
|
56
|
+
*/
|
|
57
|
+
export function officialTransaction<T>(context: Context, execute: () => T | PromiseLike<T>): Promise<Awaited<T>>;
|