@deepseek-ai/cordis-plugin-include 1.0.5-rc.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/LICENSE +21 -0
- package/README.md +43 -0
- package/lib/index.js +297 -0
- package/lib/types/index.d.ts +105 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +40 -0
- package/src/index.ts +385 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021-present Shigma
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @cordisjs/plugin-include
|
|
2
|
+
|
|
3
|
+
File-backed loader tree for Cordis. The include plugin reads a YAML or JSON
|
|
4
|
+
file, turns it into loader entries, and writes updates back when the file is
|
|
5
|
+
writable.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Context } from 'cordis'
|
|
11
|
+
import Loader from '@cordisjs/plugin-loader'
|
|
12
|
+
import Include from '@cordisjs/plugin-include'
|
|
13
|
+
|
|
14
|
+
const root = new Context()
|
|
15
|
+
await root.plugin(Loader, { baseUrl: import.meta.url })
|
|
16
|
+
await root.plugin(Include, {
|
|
17
|
+
path: './cordis.yml',
|
|
18
|
+
initial: [],
|
|
19
|
+
enableLogs: true,
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Example `cordis.yml`:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
- id: timer
|
|
27
|
+
name: '@cordisjs/plugin-timer'
|
|
28
|
+
- id: app
|
|
29
|
+
name: ./plugins/app
|
|
30
|
+
config:
|
|
31
|
+
message: hello
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Config
|
|
35
|
+
|
|
36
|
+
| Field | Description |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `path` | YAML or JSON file path resolved from `ctx.baseUrl`. |
|
|
39
|
+
| `initial` | Entry list written when the file is missing. |
|
|
40
|
+
| `patches` | Runtime patches applied after reading the file. |
|
|
41
|
+
| `enableLogs` | Enables loader apply, reload, and unload logs. |
|
|
42
|
+
|
|
43
|
+
Patches can insert entries or override fields on entries with a matching `id`.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { EntryConfigResolver, EntryTree, interpolate, isJsExpr } from "@deepseek-ai/cordis-plugin-loader";
|
|
2
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
3
|
+
import { extname } from "node:path";
|
|
4
|
+
import { access, constants, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import * as yaml from "js-yaml";
|
|
8
|
+
//#region lib/types/index.js
|
|
9
|
+
var __rewriteRelativeImportExtension = function(path, preserveJsx) {
|
|
10
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function(m, tsx, d, ext, cm) {
|
|
11
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : d + ext + "." + cm.toLowerCase() + "js";
|
|
12
|
+
});
|
|
13
|
+
return path;
|
|
14
|
+
};
|
|
15
|
+
const JsExpr = new yaml.Type("tag:yaml.org,2002:js", {
|
|
16
|
+
kind: "scalar",
|
|
17
|
+
resolve: (data) => typeof data === "string",
|
|
18
|
+
construct: (data) => ({ __jsExpr: data }),
|
|
19
|
+
predicate: isJsExpr,
|
|
20
|
+
represent: (data) => data["__jsExpr"]
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
|
|
24
|
+
* the Loader evaluates at entry activation. Exported so config tooling
|
|
25
|
+
* (`dsh --dump-config`) parses and prints exactly the dialect this include
|
|
26
|
+
* mounts.
|
|
27
|
+
*/
|
|
28
|
+
const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr);
|
|
29
|
+
const schema = entryListSchema;
|
|
30
|
+
const writable = {
|
|
31
|
+
".json": "application/json",
|
|
32
|
+
".yaml": "application/yaml",
|
|
33
|
+
".yml": "application/yaml"
|
|
34
|
+
};
|
|
35
|
+
const supported = new Set(Object.keys(writable));
|
|
36
|
+
const WRITE_RETRY_LIMIT = 10;
|
|
37
|
+
const WRITE_RETRY_DELAY_MS = 50;
|
|
38
|
+
function retryableWriteError(error) {
|
|
39
|
+
const code = error?.code;
|
|
40
|
+
return code === "EACCES" || code === "EBUSY" || code === "EPERM";
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Apply patch lists to an entry list — THE patch semantics of this include,
|
|
44
|
+
* shared by mounting (`applyPatches`) and offline config tooling
|
|
45
|
+
* (`dsh --dump-config`) so a dump can never drift from what boots. The input
|
|
46
|
+
* is never mutated and the result is always detached from it (even with no
|
|
47
|
+
* patches): patching or mounting shared entry objects would bake earlier
|
|
48
|
+
* values into the cached parse, so repeated application (config hot-reloads)
|
|
49
|
+
* could never revert a removed or changed patch. Inserted entries are indexed
|
|
50
|
+
* as they are added, so a later patch in the same list can target a row an
|
|
51
|
+
* earlier patch inserted. A patch that matches nothing warns and is skipped.
|
|
52
|
+
* @param data - the parsed entry list (JSON-safe plain data).
|
|
53
|
+
* @param patches - the patch list to apply, in order.
|
|
54
|
+
* @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
|
|
55
|
+
* @returns a detached entry list with every applicable patch applied.
|
|
56
|
+
*/
|
|
57
|
+
function applyEntryPatches(data, patches, warn) {
|
|
58
|
+
data = structuredClone(data);
|
|
59
|
+
if (!patches?.length) return data;
|
|
60
|
+
const entryMap = /* @__PURE__ */ new Map();
|
|
61
|
+
const buildMap = (entries) => {
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry.id) entryMap.set(entry.id, entry);
|
|
64
|
+
if (entry.group && Array.isArray(entry.config)) buildMap(entry.config);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
buildMap(data);
|
|
68
|
+
for (const patch of patches) {
|
|
69
|
+
const { id, insert, name, ...overrides } = patch;
|
|
70
|
+
if (insert) {
|
|
71
|
+
if (id) {
|
|
72
|
+
const target = entryMap.get(id);
|
|
73
|
+
if (!target) {
|
|
74
|
+
warn("patch insert: entry %C not found", id);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!target.group) {
|
|
78
|
+
warn("patch insert: entry %C is not a group", id);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(target.config)) target.config = [];
|
|
82
|
+
target.config.push(...insert);
|
|
83
|
+
} else data.push(...insert);
|
|
84
|
+
buildMap(insert);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!id) {
|
|
88
|
+
warn("patch: id is required for non-insert patches");
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const target = entryMap.get(id);
|
|
92
|
+
if (!target) {
|
|
93
|
+
warn("patch: entry %C not found", id);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (name && name !== target.name) {
|
|
97
|
+
warn("patch: name mismatch for %C (expected %C, got %C), skipping", id, target.name, name);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
101
|
+
if (key === "id") continue;
|
|
102
|
+
target[key] = value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return data;
|
|
106
|
+
}
|
|
107
|
+
var ConfigFileError = class extends Error {
|
|
108
|
+
stage;
|
|
109
|
+
constructor(stage, path, cause) {
|
|
110
|
+
super(`failed to ${stage} config file ${path}`, { cause });
|
|
111
|
+
this.stage = stage;
|
|
112
|
+
this.name = "ConfigFileError";
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/** Loader entry tree backed by a YAML or JSON file. */
|
|
116
|
+
var Include = class extends EntryTree {
|
|
117
|
+
config;
|
|
118
|
+
static inject = ["loader"];
|
|
119
|
+
/**
|
|
120
|
+
* Resolve Include's own options while preserving nested entry expressions.
|
|
121
|
+
* @param ctx - the Include plugin context.
|
|
122
|
+
* @param config - the raw Include config.
|
|
123
|
+
* @returns resolved Include options with `initial` and `patches` untouched.
|
|
124
|
+
*/
|
|
125
|
+
static [EntryConfigResolver](ctx, config) {
|
|
126
|
+
const { initial, patches, ...own } = config;
|
|
127
|
+
return {
|
|
128
|
+
...interpolate(ctx, own),
|
|
129
|
+
...initial === void 0 ? {} : { initial },
|
|
130
|
+
...patches === void 0 ? {} : { patches }
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
filename;
|
|
134
|
+
type;
|
|
135
|
+
readonly;
|
|
136
|
+
content;
|
|
137
|
+
data;
|
|
138
|
+
writeTask;
|
|
139
|
+
pendingWrite;
|
|
140
|
+
writeQueue = Promise.resolve();
|
|
141
|
+
applyQueue = Promise.resolve();
|
|
142
|
+
constructor(ctx, config) {
|
|
143
|
+
super(ctx);
|
|
144
|
+
this.config = config;
|
|
145
|
+
this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false;
|
|
146
|
+
this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl));
|
|
147
|
+
const ext = extname(this.filename);
|
|
148
|
+
if (!supported.has(ext)) throw new Error(`extension "${ext}" not supported`);
|
|
149
|
+
this.type = writable[ext];
|
|
150
|
+
this.readonly = !this.type;
|
|
151
|
+
this.ctx.baseUrl = new URL(".", pathToFileURL(this.filename)).href;
|
|
152
|
+
ctx.on("internal/update", async (config, _, next) => {
|
|
153
|
+
if (config.path !== this.config.path) return next();
|
|
154
|
+
await this.enqueue(async () => {
|
|
155
|
+
const data = this.applyPatches(this.data, config.patches);
|
|
156
|
+
await this.root.update(data);
|
|
157
|
+
this.config = config;
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Serialize one child-tree mutation behind every earlier one. The group's
|
|
163
|
+
* transactional `update` is not reentrant: two concurrent applies (the init
|
|
164
|
+
* apply racing an HMR-triggered refresh from the watcher's initial scan)
|
|
165
|
+
* interleave create and rollback on the same entries and strand the include
|
|
166
|
+
* fiber without settling, so every apply path funnels through this queue.
|
|
167
|
+
* A predecessor's failure is its own caller's outcome and never gates the
|
|
168
|
+
* next task.
|
|
169
|
+
*/
|
|
170
|
+
enqueue(task) {
|
|
171
|
+
const run = this.applyQueue.then(task, task);
|
|
172
|
+
this.applyQueue = run.then(() => {}, () => {});
|
|
173
|
+
return run;
|
|
174
|
+
}
|
|
175
|
+
async checkAccess() {
|
|
176
|
+
if (!this.type) return;
|
|
177
|
+
try {
|
|
178
|
+
await access(this.filename, constants.W_OK);
|
|
179
|
+
} catch {
|
|
180
|
+
this.readonly = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async read(forced = false) {
|
|
184
|
+
let content;
|
|
185
|
+
try {
|
|
186
|
+
content = await readFile(this.filename, "utf8");
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw new ConfigFileError("read", this.filename, error);
|
|
189
|
+
}
|
|
190
|
+
if (!forced && this.content === content) return;
|
|
191
|
+
let data;
|
|
192
|
+
try {
|
|
193
|
+
if (this.type === "application/yaml") data = yaml.load(content, { schema });
|
|
194
|
+
else if (this.type === "application/json") data = JSON.parse(content);
|
|
195
|
+
else {
|
|
196
|
+
const module = await import(__rewriteRelativeImportExtension(
|
|
197
|
+
/* @vite-ignore */
|
|
198
|
+
this.filename
|
|
199
|
+
));
|
|
200
|
+
data = module.default || module;
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new ConfigFileError("parse", this.filename, error);
|
|
204
|
+
}
|
|
205
|
+
if (!Array.isArray(data)) throw new ConfigFileError("validate", this.filename, /* @__PURE__ */ new TypeError("config file must be a top-level array"));
|
|
206
|
+
return {
|
|
207
|
+
content,
|
|
208
|
+
data
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
applyPatches(data, patches) {
|
|
212
|
+
return applyEntryPatches(data, patches, (message, ...args) => {
|
|
213
|
+
this.ctx.root.logger?.("loader").warn(message, ...args);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async *[Service.init]() {
|
|
217
|
+
let candidate;
|
|
218
|
+
try {
|
|
219
|
+
candidate = await this.read(true);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (!(error instanceof ConfigFileError) || error.stage !== "read" || error.cause?.code !== "ENOENT") throw error;
|
|
222
|
+
if (this.config.initial) {
|
|
223
|
+
await this._writeFile(this.config.initial);
|
|
224
|
+
candidate = await this.read(true);
|
|
225
|
+
} else throw new Error(`config file not found: ${this.filename}`);
|
|
226
|
+
}
|
|
227
|
+
yield () => this.stop();
|
|
228
|
+
await this.apply(candidate);
|
|
229
|
+
}
|
|
230
|
+
async stop() {
|
|
231
|
+
await this.root.stop();
|
|
232
|
+
await this.flushWrite();
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Re-read the file and transactionally refresh child entries when content changed.
|
|
236
|
+
* @returns a promise resolving after the new tree commits, or immediately when unchanged.
|
|
237
|
+
* @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
|
|
238
|
+
*/
|
|
239
|
+
async refresh() {
|
|
240
|
+
await this.enqueue(async () => {
|
|
241
|
+
const candidate = await this.read();
|
|
242
|
+
if (!candidate) return;
|
|
243
|
+
await this._apply(candidate);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
apply(candidate) {
|
|
247
|
+
return this.enqueue(() => this._apply(candidate));
|
|
248
|
+
}
|
|
249
|
+
async _apply(candidate) {
|
|
250
|
+
const data = this.applyPatches(candidate.data, this.config.patches);
|
|
251
|
+
await this.root.update(data);
|
|
252
|
+
this.content = candidate.content;
|
|
253
|
+
this.data = candidate.data;
|
|
254
|
+
await this.checkAccess();
|
|
255
|
+
}
|
|
256
|
+
async _writeFile(config) {
|
|
257
|
+
if (this.readonly) throw new Error(`cannot overwrite readonly config`);
|
|
258
|
+
if (this.type === "application/yaml") this.content = yaml.dump(config, { schema });
|
|
259
|
+
else if (this.type === "application/json") this.content = JSON.stringify(config, null, 2);
|
|
260
|
+
await writeFile(this.filename + ".tmp", this.content);
|
|
261
|
+
for (let retry = 0;; retry++) try {
|
|
262
|
+
await rename(this.filename + ".tmp", this.filename);
|
|
263
|
+
return;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error;
|
|
266
|
+
await setTimeout$1((retry + 1) * WRITE_RETRY_DELAY_MS);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
writeFile(config) {
|
|
270
|
+
clearTimeout(this.writeTask);
|
|
271
|
+
this.pendingWrite = config;
|
|
272
|
+
this.writeTask = setTimeout(() => {
|
|
273
|
+
this.flushWrite();
|
|
274
|
+
}, 0);
|
|
275
|
+
}
|
|
276
|
+
flushWrite() {
|
|
277
|
+
clearTimeout(this.writeTask);
|
|
278
|
+
this.writeTask = void 0;
|
|
279
|
+
const config = this.pendingWrite;
|
|
280
|
+
this.pendingWrite = void 0;
|
|
281
|
+
if (config === void 0) return this.writeQueue;
|
|
282
|
+
const run = this.writeQueue.then(() => this._writeFile(config), () => this._writeFile(config));
|
|
283
|
+
this.writeQueue = run;
|
|
284
|
+
run.catch((error) => {
|
|
285
|
+
this.ctx.root.logger?.("loader").warn("failed to write config file %C", this.filename);
|
|
286
|
+
this.ctx.root.logger?.("loader").warn(error);
|
|
287
|
+
});
|
|
288
|
+
return run;
|
|
289
|
+
}
|
|
290
|
+
/** Schedule a write of the current root entry data. */
|
|
291
|
+
write() {
|
|
292
|
+
this.context.emit("loader/config-update");
|
|
293
|
+
return this.writeFile(this.root.data);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
//#endregion
|
|
297
|
+
export { Include, Include as default, applyEntryPatches, entryListSchema };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { EntryConfigResolver, EntryTree, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader';
|
|
2
|
+
import { Context, Service } from '@deepseek-ai/cordis';
|
|
3
|
+
import * as yaml from 'js-yaml';
|
|
4
|
+
/**
|
|
5
|
+
* The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
|
|
6
|
+
* the Loader evaluates at entry activation. Exported so config tooling
|
|
7
|
+
* (`dsh --dump-config`) parses and prints exactly the dialect this include
|
|
8
|
+
* mounts.
|
|
9
|
+
*/
|
|
10
|
+
export declare const entryListSchema: yaml.Schema;
|
|
11
|
+
/**
|
|
12
|
+
* Apply patch lists to an entry list — THE patch semantics of this include,
|
|
13
|
+
* shared by mounting (`applyPatches`) and offline config tooling
|
|
14
|
+
* (`dsh --dump-config`) so a dump can never drift from what boots. The input
|
|
15
|
+
* is never mutated and the result is always detached from it (even with no
|
|
16
|
+
* patches): patching or mounting shared entry objects would bake earlier
|
|
17
|
+
* values into the cached parse, so repeated application (config hot-reloads)
|
|
18
|
+
* could never revert a removed or changed patch. Inserted entries are indexed
|
|
19
|
+
* as they are added, so a later patch in the same list can target a row an
|
|
20
|
+
* earlier patch inserted. A patch that matches nothing warns and is skipped.
|
|
21
|
+
* @param data - the parsed entry list (JSON-safe plain data).
|
|
22
|
+
* @param patches - the patch list to apply, in order.
|
|
23
|
+
* @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
|
|
24
|
+
* @returns a detached entry list with every applicable patch applied.
|
|
25
|
+
*/
|
|
26
|
+
export declare function applyEntryPatches(data: EntryOptions[], patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void): EntryOptions[];
|
|
27
|
+
/** Runtime patch applied to entries loaded from an included config file. */
|
|
28
|
+
export interface PatchOptions {
|
|
29
|
+
id?: string;
|
|
30
|
+
insert?: EntryOptions[];
|
|
31
|
+
name?: string;
|
|
32
|
+
config?: any;
|
|
33
|
+
group?: boolean | null;
|
|
34
|
+
disabled?: boolean | null;
|
|
35
|
+
inject?: any;
|
|
36
|
+
intercept?: any;
|
|
37
|
+
isolate?: any;
|
|
38
|
+
[key: string]: any;
|
|
39
|
+
}
|
|
40
|
+
/** Config namespace for the file-backed include loader. */
|
|
41
|
+
export declare namespace Include {
|
|
42
|
+
/** Config for a file-backed loader subtree. */
|
|
43
|
+
interface Config {
|
|
44
|
+
/** YAML or JSON path resolved from `ctx.baseUrl`. */
|
|
45
|
+
path: string;
|
|
46
|
+
/** Entry list written when the file does not already exist. */
|
|
47
|
+
initial?: any[];
|
|
48
|
+
/** Runtime patches applied after reading the file. */
|
|
49
|
+
patches?: PatchOptions[];
|
|
50
|
+
/** Enables loader apply/reload/unload logs for this subtree. */
|
|
51
|
+
enableLogs?: boolean;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Loader entry tree backed by a YAML or JSON file. */
|
|
55
|
+
export declare class Include extends EntryTree {
|
|
56
|
+
config: Include.Config;
|
|
57
|
+
static inject: string[];
|
|
58
|
+
/**
|
|
59
|
+
* Resolve Include's own options while preserving nested entry expressions.
|
|
60
|
+
* @param ctx - the Include plugin context.
|
|
61
|
+
* @param config - the raw Include config.
|
|
62
|
+
* @returns resolved Include options with `initial` and `patches` untouched.
|
|
63
|
+
*/
|
|
64
|
+
static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config;
|
|
65
|
+
filename: string;
|
|
66
|
+
private type?;
|
|
67
|
+
private readonly;
|
|
68
|
+
private content?;
|
|
69
|
+
private data?;
|
|
70
|
+
private writeTask?;
|
|
71
|
+
private pendingWrite?;
|
|
72
|
+
private writeQueue;
|
|
73
|
+
private applyQueue;
|
|
74
|
+
constructor(ctx: Context, config: Include.Config);
|
|
75
|
+
/**
|
|
76
|
+
* Serialize one child-tree mutation behind every earlier one. The group's
|
|
77
|
+
* transactional `update` is not reentrant: two concurrent applies (the init
|
|
78
|
+
* apply racing an HMR-triggered refresh from the watcher's initial scan)
|
|
79
|
+
* interleave create and rollback on the same entries and strand the include
|
|
80
|
+
* fiber without settling, so every apply path funnels through this queue.
|
|
81
|
+
* A predecessor's failure is its own caller's outcome and never gates the
|
|
82
|
+
* next task.
|
|
83
|
+
*/
|
|
84
|
+
private enqueue;
|
|
85
|
+
private checkAccess;
|
|
86
|
+
private read;
|
|
87
|
+
private applyPatches;
|
|
88
|
+
[Service.init](): AsyncGenerator<() => Promise<void>, void, unknown>;
|
|
89
|
+
stop(): Promise<void>;
|
|
90
|
+
/**
|
|
91
|
+
* Re-read the file and transactionally refresh child entries when content changed.
|
|
92
|
+
* @returns a promise resolving after the new tree commits, or immediately when unchanged.
|
|
93
|
+
* @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
|
|
94
|
+
*/
|
|
95
|
+
refresh(): Promise<void>;
|
|
96
|
+
private apply;
|
|
97
|
+
private _apply;
|
|
98
|
+
private _writeFile;
|
|
99
|
+
private writeFile;
|
|
100
|
+
private flushWrite;
|
|
101
|
+
/** Schedule a write of the current root entry data. */
|
|
102
|
+
write(): void;
|
|
103
|
+
}
|
|
104
|
+
export default Include;
|
|
105
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAyB,KAAK,YAAY,EAAE,MAAM,mCAAmC,CAAA;AAC5H,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKtD,OAAO,KAAK,IAAI,MAAM,SAAS,CAAA;AAU/B;;;;;GAKG;AACH,eAAO,MAAM,eAAe,aAAkC,CAAA;AAoB9D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,YAAY,EAAE,EACpB,OAAO,EAAE,YAAY,EAAE,GAAG,SAAS,EACnC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAC9C,YAAY,EAAE,CAkEhB;AAgBD,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,MAAM,CAAC,EAAE,YAAY,EAAE,CAAA;IACvB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,GAAG,CAAA;IACZ,KAAK,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IACtB,QAAQ,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IACzB,MAAM,CAAC,EAAE,GAAG,CAAA;IACZ,SAAS,CAAC,EAAE,GAAG,CAAA;IACf,OAAO,CAAC,EAAE,GAAG,CAAA;IACb,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AAED,2DAA2D;AAC3D,yBAAiB,OAAO,CAAC;IACvB,+CAA+C;IAC/C,UAAiB,MAAM;QACrB,qDAAqD;QACrD,IAAI,EAAE,MAAM,CAAA;QACZ,+DAA+D;QAC/D,OAAO,CAAC,EAAE,GAAG,EAAE,CAAA;QACf,sDAAsD;QACtD,OAAO,CAAC,EAAE,YAAY,EAAE,CAAA;QACxB,gEAAgE;QAChE,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB;CACF;AAED,uDAAuD;AACvD,qBAAa,OAAQ,SAAQ,SAAS;IA4BH,MAAM,EAAE,OAAO,CAAC,MAAM;IA3BvD,MAAM,CAAC,MAAM,WAAa;IAE1B;;;;;OAKG;IACH,MAAM,CAAC,CAAC,mBAAmB,CAAC,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM;IAS3E,QAAQ,EAAE,MAAM,CAAA;IACvB,OAAO,CAAC,IAAI,CAAC,CAAQ;IACrB,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,OAAO,CAAC,CAAQ;IACxB,OAAO,CAAC,IAAI,CAAC,CAAgB;IAC7B,OAAO,CAAC,SAAS,CAAC,CAA4B;IAC9C,OAAO,CAAC,YAAY,CAAC,CAAgB;IACrC,OAAO,CAAC,UAAU,CAAmC;IACrD,OAAO,CAAC,UAAU,CAAsC;gBAE5C,GAAG,EAAE,OAAO,EAAS,MAAM,EAAE,OAAO,CAAC,MAAM;IAsBvD;;;;;;;;OAQG;IACH,OAAO,CAAC,OAAO;YAMD,WAAW;YASX,IAAI;IA2BlB,OAAO,CAAC,YAAY;IAMb,CAAC,OAAO,CAAC,IAAI,CAAC;IAkBf,IAAI;IAKV;;;;OAIG;IACG,OAAO;IAUb,OAAO,CAAC,KAAK;YAIC,MAAM;YAQN,UAAU;IAqBxB,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,UAAU;IAkBlB,uDAAuD;IACvD,KAAK;CAIN;AAED,eAAe,OAAO,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/cordis-plugin-include",
|
|
3
|
+
"description": "Include files in cordis configurations",
|
|
4
|
+
"version": "1.0.5-rc.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "restricted"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "vendor/include"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./src/*": "./src/*",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"lib/index.js",
|
|
26
|
+
"lib/types/**/*.d.ts",
|
|
27
|
+
"lib/types/**/*.d.ts.map",
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"author": "Shigma <shigma10826@gmail.com>",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@deepseek-ai/cordis-plugin-loader": "^1.0.1-rc.1",
|
|
34
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"js-yaml": "^4.1.0",
|
|
38
|
+
"@deepseek-ai/cosmokit": "^1.8.2-rc.1"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { EntryConfigResolver, EntryTree, interpolate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
|
2
|
+
import { Context, Service } from '@deepseek-ai/cordis'
|
|
3
|
+
import { extname } from 'node:path'
|
|
4
|
+
import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
|
+
import * as yaml from 'js-yaml'
|
|
8
|
+
|
|
9
|
+
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
|
|
10
|
+
kind: 'scalar',
|
|
11
|
+
resolve: (data) => typeof data === 'string',
|
|
12
|
+
construct: (data) => ({ __jsExpr: data }),
|
|
13
|
+
predicate: isJsExpr,
|
|
14
|
+
represent: (data) => data['__jsExpr'],
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes
|
|
19
|
+
* the Loader evaluates at entry activation. Exported so config tooling
|
|
20
|
+
* (`dsh --dump-config`) parses and prints exactly the dialect this include
|
|
21
|
+
* mounts.
|
|
22
|
+
*/
|
|
23
|
+
export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr)
|
|
24
|
+
|
|
25
|
+
const schema = entryListSchema
|
|
26
|
+
|
|
27
|
+
const writable: Record<string, string> = {
|
|
28
|
+
'.json': 'application/json',
|
|
29
|
+
'.yaml': 'application/yaml',
|
|
30
|
+
'.yml': 'application/yaml',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const supported = new Set(Object.keys(writable))
|
|
34
|
+
|
|
35
|
+
const WRITE_RETRY_LIMIT = 10
|
|
36
|
+
const WRITE_RETRY_DELAY_MS = 50
|
|
37
|
+
|
|
38
|
+
function retryableWriteError(error: unknown): boolean {
|
|
39
|
+
const code = (error as NodeJS.ErrnoException | null)?.code
|
|
40
|
+
return code === 'EACCES' || code === 'EBUSY' || code === 'EPERM'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Apply patch lists to an entry list — THE patch semantics of this include,
|
|
45
|
+
* shared by mounting (`applyPatches`) and offline config tooling
|
|
46
|
+
* (`dsh --dump-config`) so a dump can never drift from what boots. The input
|
|
47
|
+
* is never mutated and the result is always detached from it (even with no
|
|
48
|
+
* patches): patching or mounting shared entry objects would bake earlier
|
|
49
|
+
* values into the cached parse, so repeated application (config hot-reloads)
|
|
50
|
+
* could never revert a removed or changed patch. Inserted entries are indexed
|
|
51
|
+
* as they are added, so a later patch in the same list can target a row an
|
|
52
|
+
* earlier patch inserted. A patch that matches nothing warns and is skipped.
|
|
53
|
+
* @param data - the parsed entry list (JSON-safe plain data).
|
|
54
|
+
* @param patches - the patch list to apply, in order.
|
|
55
|
+
* @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code).
|
|
56
|
+
* @returns a detached entry list with every applicable patch applied.
|
|
57
|
+
*/
|
|
58
|
+
export function applyEntryPatches(
|
|
59
|
+
data: EntryOptions[],
|
|
60
|
+
patches: PatchOptions[] | undefined,
|
|
61
|
+
warn: (message: string, ...args: any[]) => void,
|
|
62
|
+
): EntryOptions[] {
|
|
63
|
+
data = structuredClone(data)
|
|
64
|
+
if (!patches?.length) return data
|
|
65
|
+
|
|
66
|
+
const entryMap = new Map<string, EntryOptions>()
|
|
67
|
+
const buildMap = (entries: EntryOptions[]) => {
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (entry.id) entryMap.set(entry.id, entry)
|
|
70
|
+
if (entry.group && Array.isArray(entry.config)) {
|
|
71
|
+
buildMap(entry.config)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
buildMap(data)
|
|
76
|
+
|
|
77
|
+
for (const patch of patches) {
|
|
78
|
+
const { id, insert, name, ...overrides } = patch
|
|
79
|
+
|
|
80
|
+
if (insert) {
|
|
81
|
+
if (id) {
|
|
82
|
+
const target = entryMap.get(id)
|
|
83
|
+
if (!target) {
|
|
84
|
+
warn('patch insert: entry %C not found', id)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (!target.group) {
|
|
88
|
+
warn('patch insert: entry %C is not a group', id)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
if (!Array.isArray(target.config)) target.config = []
|
|
92
|
+
target.config.push(...insert)
|
|
93
|
+
} else {
|
|
94
|
+
data.push(...insert)
|
|
95
|
+
}
|
|
96
|
+
// Index what this patch added so a LATER patch in the same list can
|
|
97
|
+
// target it. Patch lists compose one layer per source (each bundle
|
|
98
|
+
// layer, then the user's, then `--patch` overlays), and a layer must be
|
|
99
|
+
// able to configure or disable a row an earlier layer inserted; without
|
|
100
|
+
// this, inserted rows were silently unpatchable.
|
|
101
|
+
buildMap(insert)
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!id) {
|
|
106
|
+
warn('patch: id is required for non-insert patches')
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const target = entryMap.get(id)
|
|
111
|
+
if (!target) {
|
|
112
|
+
warn('patch: entry %C not found', id)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (name && name !== target.name) {
|
|
117
|
+
warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
122
|
+
if (key === 'id') continue
|
|
123
|
+
target[key] = value
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return data
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type ConfigUpdateStage = 'read' | 'parse' | 'validate'
|
|
131
|
+
|
|
132
|
+
interface ReadCandidate {
|
|
133
|
+
content: string
|
|
134
|
+
data: EntryOptions[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
class ConfigFileError extends Error {
|
|
138
|
+
constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) {
|
|
139
|
+
super(`failed to ${stage} config file ${path}`, { cause })
|
|
140
|
+
this.name = 'ConfigFileError'
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Runtime patch applied to entries loaded from an included config file. */
|
|
145
|
+
export interface PatchOptions {
|
|
146
|
+
id?: string
|
|
147
|
+
insert?: EntryOptions[]
|
|
148
|
+
name?: string
|
|
149
|
+
config?: any
|
|
150
|
+
group?: boolean | null
|
|
151
|
+
disabled?: boolean | null
|
|
152
|
+
inject?: any
|
|
153
|
+
intercept?: any
|
|
154
|
+
isolate?: any
|
|
155
|
+
[key: string]: any
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Config namespace for the file-backed include loader. */
|
|
159
|
+
export namespace Include {
|
|
160
|
+
/** Config for a file-backed loader subtree. */
|
|
161
|
+
export interface Config {
|
|
162
|
+
/** YAML or JSON path resolved from `ctx.baseUrl`. */
|
|
163
|
+
path: string
|
|
164
|
+
/** Entry list written when the file does not already exist. */
|
|
165
|
+
initial?: any[]
|
|
166
|
+
/** Runtime patches applied after reading the file. */
|
|
167
|
+
patches?: PatchOptions[]
|
|
168
|
+
/** Enables loader apply/reload/unload logs for this subtree. */
|
|
169
|
+
enableLogs?: boolean
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Loader entry tree backed by a YAML or JSON file. */
|
|
174
|
+
export class Include extends EntryTree {
|
|
175
|
+
static inject = ['loader']
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve Include's own options while preserving nested entry expressions.
|
|
179
|
+
* @param ctx - the Include plugin context.
|
|
180
|
+
* @param config - the raw Include config.
|
|
181
|
+
* @returns resolved Include options with `initial` and `patches` untouched.
|
|
182
|
+
*/
|
|
183
|
+
static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config {
|
|
184
|
+
const { initial, patches, ...own } = config
|
|
185
|
+
return {
|
|
186
|
+
...interpolate(ctx, own),
|
|
187
|
+
...(initial === undefined ? {} : { initial }),
|
|
188
|
+
...(patches === undefined ? {} : { patches }),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public filename: string
|
|
193
|
+
private type?: string
|
|
194
|
+
private readonly: boolean
|
|
195
|
+
private content?: string
|
|
196
|
+
private data?: EntryOptions[]
|
|
197
|
+
private writeTask?: NodeJS.Timeout | undefined
|
|
198
|
+
private pendingWrite?: EntryOptions[]
|
|
199
|
+
private writeQueue: Promise<void> = Promise.resolve()
|
|
200
|
+
private applyQueue: Promise<unknown> = Promise.resolve()
|
|
201
|
+
|
|
202
|
+
constructor(ctx: Context, public config: Include.Config) {
|
|
203
|
+
super(ctx)
|
|
204
|
+
this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false
|
|
205
|
+
this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl))
|
|
206
|
+
const ext = extname(this.filename)
|
|
207
|
+
if (!supported.has(ext)) {
|
|
208
|
+
throw new Error(`extension "${ext}" not supported`)
|
|
209
|
+
}
|
|
210
|
+
this.type = writable[ext]
|
|
211
|
+
this.readonly = !this.type
|
|
212
|
+
this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href
|
|
213
|
+
|
|
214
|
+
ctx.on('internal/update', async (config, _, next) => {
|
|
215
|
+
if (config.path !== this.config.path) return next()
|
|
216
|
+
await this.enqueue(async () => {
|
|
217
|
+
const data = this.applyPatches(this.data!, config.patches)
|
|
218
|
+
await this.root.update(data)
|
|
219
|
+
this.config = config
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Serialize one child-tree mutation behind every earlier one. The group's
|
|
226
|
+
* transactional `update` is not reentrant: two concurrent applies (the init
|
|
227
|
+
* apply racing an HMR-triggered refresh from the watcher's initial scan)
|
|
228
|
+
* interleave create and rollback on the same entries and strand the include
|
|
229
|
+
* fiber without settling, so every apply path funnels through this queue.
|
|
230
|
+
* A predecessor's failure is its own caller's outcome and never gates the
|
|
231
|
+
* next task.
|
|
232
|
+
*/
|
|
233
|
+
private enqueue<T>(task: () => Promise<T>): Promise<T> {
|
|
234
|
+
const run = this.applyQueue.then(task, task)
|
|
235
|
+
this.applyQueue = run.then(() => {}, () => {})
|
|
236
|
+
return run
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async checkAccess() {
|
|
240
|
+
if (!this.type) return
|
|
241
|
+
try {
|
|
242
|
+
await access(this.filename, constants.W_OK)
|
|
243
|
+
} catch {
|
|
244
|
+
this.readonly = true
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async read(forced = false): Promise<ReadCandidate | undefined> {
|
|
249
|
+
let content: string
|
|
250
|
+
try {
|
|
251
|
+
content = await readFile(this.filename, 'utf8')
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new ConfigFileError('read', this.filename, error)
|
|
254
|
+
}
|
|
255
|
+
if (!forced && this.content === content) return
|
|
256
|
+
let data: any
|
|
257
|
+
try {
|
|
258
|
+
if (this.type === 'application/yaml') {
|
|
259
|
+
data = yaml.load(content, { schema })
|
|
260
|
+
} else if (this.type === 'application/json') {
|
|
261
|
+
data = JSON.parse(content)
|
|
262
|
+
} else {
|
|
263
|
+
const module = await import(/* @vite-ignore */ this.filename)
|
|
264
|
+
data = module.default || module
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
throw new ConfigFileError('parse', this.filename, error)
|
|
268
|
+
}
|
|
269
|
+
if (!Array.isArray(data)) {
|
|
270
|
+
throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array'))
|
|
271
|
+
}
|
|
272
|
+
return { content, data }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] {
|
|
276
|
+
return applyEntryPatches(data, patches, (message, ...args) => {
|
|
277
|
+
this.ctx.root.logger?.('loader').warn(message, ...args)
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async* [Service.init]() {
|
|
282
|
+
let candidate: ReadCandidate
|
|
283
|
+
try {
|
|
284
|
+
candidate = (await this.read(true))!
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error
|
|
287
|
+
if (this.config.initial) {
|
|
288
|
+
await this._writeFile(this.config.initial as any)
|
|
289
|
+
candidate = (await this.read(true))!
|
|
290
|
+
} else {
|
|
291
|
+
throw new Error(`config file not found: ${this.filename}`)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
yield () => this.stop()
|
|
296
|
+
await this.apply(candidate)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async stop() {
|
|
300
|
+
await this.root.stop()
|
|
301
|
+
await this.flushWrite()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Re-read the file and transactionally refresh child entries when content changed.
|
|
306
|
+
* @returns a promise resolving after the new tree commits, or immediately when unchanged.
|
|
307
|
+
* @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds.
|
|
308
|
+
*/
|
|
309
|
+
async refresh() {
|
|
310
|
+
// Read inside the queue so the changed-content check compares against the
|
|
311
|
+
// predecessor's committed state, not a mid-apply snapshot.
|
|
312
|
+
await this.enqueue(async () => {
|
|
313
|
+
const candidate = await this.read()
|
|
314
|
+
if (!candidate) return
|
|
315
|
+
await this._apply(candidate)
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private apply(candidate: ReadCandidate) {
|
|
320
|
+
return this.enqueue(() => this._apply(candidate))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async _apply(candidate: ReadCandidate) {
|
|
324
|
+
const data = this.applyPatches(candidate.data, this.config.patches)
|
|
325
|
+
await this.root.update(data)
|
|
326
|
+
this.content = candidate.content
|
|
327
|
+
this.data = candidate.data
|
|
328
|
+
await this.checkAccess()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async _writeFile(config: EntryOptions[]) {
|
|
332
|
+
if (this.readonly) {
|
|
333
|
+
throw new Error(`cannot overwrite readonly config`)
|
|
334
|
+
}
|
|
335
|
+
if (this.type === 'application/yaml') {
|
|
336
|
+
this.content = yaml.dump(config, { schema })
|
|
337
|
+
} else if (this.type === 'application/json') {
|
|
338
|
+
this.content = JSON.stringify(config, null, 2)
|
|
339
|
+
}
|
|
340
|
+
await writeFile(this.filename + '.tmp', this.content!)
|
|
341
|
+
for (let retry = 0; ; retry++) {
|
|
342
|
+
try {
|
|
343
|
+
await rename(this.filename + '.tmp', this.filename)
|
|
344
|
+
return
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (!retryableWriteError(error) || retry >= WRITE_RETRY_LIMIT) throw error
|
|
347
|
+
await delay((retry + 1) * WRITE_RETRY_DELAY_MS)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private writeFile(config: EntryOptions[]) {
|
|
353
|
+
clearTimeout(this.writeTask)
|
|
354
|
+
this.pendingWrite = config
|
|
355
|
+
this.writeTask = setTimeout(() => {
|
|
356
|
+
void this.flushWrite()
|
|
357
|
+
}, 0)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private flushWrite(): Promise<void> {
|
|
361
|
+
clearTimeout(this.writeTask)
|
|
362
|
+
this.writeTask = undefined
|
|
363
|
+
const config = this.pendingWrite
|
|
364
|
+
this.pendingWrite = undefined
|
|
365
|
+
if (config === undefined) return this.writeQueue
|
|
366
|
+
const run = this.writeQueue.then(
|
|
367
|
+
() => this._writeFile(config),
|
|
368
|
+
() => this._writeFile(config),
|
|
369
|
+
)
|
|
370
|
+
this.writeQueue = run
|
|
371
|
+
void run.catch((error) => {
|
|
372
|
+
this.ctx.root.logger?.('loader').warn('failed to write config file %C', this.filename)
|
|
373
|
+
this.ctx.root.logger?.('loader').warn(error)
|
|
374
|
+
})
|
|
375
|
+
return run
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Schedule a write of the current root entry data. */
|
|
379
|
+
write() {
|
|
380
|
+
this.context.emit('loader/config-update')
|
|
381
|
+
return this.writeFile(this.root.data)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export default Include
|