@zhin.js/config-yaml 1.0.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 +21 -0
- package/README.md +66 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/yaml-config-document.d.ts +19 -0
- package/lib/yaml-config-document.js +170 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 凉菜
|
|
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,66 @@
|
|
|
1
|
+
# @zhin.js/config-yaml
|
|
2
|
+
|
|
3
|
+
下一代 Runtime 的可选 YAML `ConfigDocumentPort`。它在 YAML AST 上应用结构化 patch,保留未触及节点的注释、引号、anchor/alias、`${ENV}` 字符串、键顺序、缩进和换行风格,并通过同目录临时文件加原子 rename 持久化。
|
|
4
|
+
|
|
5
|
+
> 当前包属于 `feature/next` 绿地实现,版本仍为 `0.0.0` 且未作为稳定 API 发布。
|
|
6
|
+
|
|
7
|
+
## 为什么独立成包
|
|
8
|
+
|
|
9
|
+
`@zhin.js/runtime` 只定义 `ConfigDocumentPort`,默认生产闭包不包含 YAML parser。需要 `config.yml` 的 Root 单独安装本包;只使用内存配置或其他配置存储时无需承担这项依赖。
|
|
10
|
+
|
|
11
|
+
本包只增加 `yaml@2.9.0`,不依赖 Vite、编译器、CSS 工具或 native/wasm 模块。包含 Runtime 的完整生产安装仍受 5MB 门禁约束。
|
|
12
|
+
|
|
13
|
+
## 使用
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { YamlConfigDocument } from '@zhin.js/config-yaml';
|
|
17
|
+
import { RootRuntime } from '@zhin.js/runtime';
|
|
18
|
+
|
|
19
|
+
const runtime = new RootRuntime({
|
|
20
|
+
projectRoot: process.cwd(),
|
|
21
|
+
modules,
|
|
22
|
+
environment: { name: 'production', mode: 'production', platform: 'node' },
|
|
23
|
+
config: new YamlConfigDocument('config.yml'),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await runtime.start();
|
|
27
|
+
await runtime.patchConfig([{
|
|
28
|
+
op: 'set',
|
|
29
|
+
path: ['plugins', 'reports', 'retries'],
|
|
30
|
+
value: 5,
|
|
31
|
+
}]);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Root 先用组合后的 JSON Schema 校验候选文档,再执行受影响 Plugin forest 的 shadow setup。只有两步都成功,YAML transaction 才作为 generation handoff 的最后一个 participant 提交;CAS 前失败会恢复原始文件并撤销新 generation Resource。
|
|
35
|
+
|
|
36
|
+
## 文档事务
|
|
37
|
+
|
|
38
|
+
`read()` 返回原始对象和基于文件字节的 SHA-256 revision。`prepare()` 只构造候选 AST,不写文件。`commit()` 在写入前再次检查 revision,随后使用同目录临时文件和原子 rename;`rollback()` 也先确认文件仍是本事务写入的 revision,避免覆盖外部编辑。
|
|
39
|
+
|
|
40
|
+
以下情况抛出明确错误:
|
|
41
|
+
|
|
42
|
+
- `ConfigDocumentParseError`:YAML 无法解析,或根节点不是 mapping。
|
|
43
|
+
- `ConfigDocumentConflictError`:read/prepare/commit/rollback 之间文件被其他写入者修改。
|
|
44
|
+
- `ConfigPatchPathError`:删除文档根或使用 `__proto__` 等不安全路径。
|
|
45
|
+
|
|
46
|
+
环境变量表达式目前按普通字符串保留。环境 overlay 和 secret 解析属于独立 Root Resource,不由 YAML adapter 隐式展开。
|
|
47
|
+
|
|
48
|
+
## 保真边界
|
|
49
|
+
|
|
50
|
+
未触及 AST 节点会保留 comment、scalar style、anchor/alias 与顺序。被替换 scalar 沿用 `yaml` AST 节点已有的样式;新增节点使用 `yaml` 的稳定默认输出。整个文档沿用原文件检测到的缩进宽度与 LF/CRLF 风格。
|
|
51
|
+
|
|
52
|
+
原子 rename 防止读取到半写文件,但不替代跨主机分布式锁。多个进程写同一文件时,revision 冲突会拒绝后写者,调用方应重新读取后再规划 patch。
|
|
53
|
+
|
|
54
|
+
## 开发验证
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pnpm --filter @zhin.js/config-yaml test
|
|
58
|
+
pnpm --filter @zhin.js/config-yaml build
|
|
59
|
+
pnpm --filter @zhin.js/config-yaml check:size
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 相关文档
|
|
63
|
+
|
|
64
|
+
- [Plugin Runtime](../runtime/README.md)
|
|
65
|
+
- [Config、Discovery 与 HMR](../../../docs/architecture/target-implementation/config-discovery-hmr.md)
|
|
66
|
+
- [Greenfield Bootstrap 状态](../../../docs/architecture/target-implementation/greenfield-bootstrap.md)
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './yaml-config-document.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './yaml-config-document.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ConfigDocumentPort, type ConfigDocumentSnapshot, type ConfigPatch, type PreparedConfigDocument } from '@zhin.js/runtime';
|
|
2
|
+
export declare class YamlConfigDocumentError extends Error {
|
|
3
|
+
constructor(message: string, options?: ErrorOptions);
|
|
4
|
+
}
|
|
5
|
+
export declare class ConfigDocumentParseError extends YamlConfigDocumentError {
|
|
6
|
+
readonly file: string;
|
|
7
|
+
readonly issues: readonly string[];
|
|
8
|
+
constructor(file: string, issues: readonly string[]);
|
|
9
|
+
}
|
|
10
|
+
export declare class ConfigDocumentConflictError extends YamlConfigDocumentError {
|
|
11
|
+
readonly file: string;
|
|
12
|
+
constructor(file: string);
|
|
13
|
+
}
|
|
14
|
+
export declare class YamlConfigDocument implements ConfigDocumentPort {
|
|
15
|
+
readonly file: string;
|
|
16
|
+
constructor(file: string);
|
|
17
|
+
read(): Promise<ConfigDocumentSnapshot>;
|
|
18
|
+
prepare(current: ConfigDocumentSnapshot, patches: readonly ConfigPatch[]): Promise<PreparedConfigDocument>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
4
|
+
import { ConfigPatchPathError, } from '@zhin.js/runtime';
|
|
5
|
+
import { parseDocument } from 'yaml';
|
|
6
|
+
export class YamlConfigDocumentError extends Error {
|
|
7
|
+
constructor(message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = 'YamlConfigDocumentError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class ConfigDocumentParseError extends YamlConfigDocumentError {
|
|
13
|
+
file;
|
|
14
|
+
issues;
|
|
15
|
+
constructor(file, issues) {
|
|
16
|
+
super(`Cannot parse YAML config ${file}:\n${issues.map((issue) => `- ${issue}`).join('\n')}`);
|
|
17
|
+
this.file = file;
|
|
18
|
+
this.issues = issues;
|
|
19
|
+
this.name = 'ConfigDocumentParseError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class ConfigDocumentConflictError extends YamlConfigDocumentError {
|
|
23
|
+
file;
|
|
24
|
+
constructor(file) {
|
|
25
|
+
super(`YAML config changed since it was read: ${file}`);
|
|
26
|
+
this.file = file;
|
|
27
|
+
this.name = 'ConfigDocumentConflictError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class YamlConfigDocument {
|
|
31
|
+
file;
|
|
32
|
+
constructor(file) {
|
|
33
|
+
this.file = resolve(file);
|
|
34
|
+
}
|
|
35
|
+
async read() {
|
|
36
|
+
const source = await readFile(this.file, 'utf8');
|
|
37
|
+
return snapshot(parse(this.file, source), source);
|
|
38
|
+
}
|
|
39
|
+
async prepare(current, patches) {
|
|
40
|
+
const source = await readFile(this.file, 'utf8');
|
|
41
|
+
assertRevision(this.file, source, current.revision);
|
|
42
|
+
const document = parseYaml(this.file, source);
|
|
43
|
+
for (const patch of patches)
|
|
44
|
+
applyPatch(document, patch);
|
|
45
|
+
const candidateSource = stringify(document, source);
|
|
46
|
+
const candidate = parse(this.file, candidateSource);
|
|
47
|
+
const fileMode = (await stat(this.file)).mode;
|
|
48
|
+
return new PreparedYamlConfigDocument(this.file, source, current.revision, candidateSource, candidate, fileMode);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
class PreparedYamlConfigDocument {
|
|
52
|
+
document;
|
|
53
|
+
#file;
|
|
54
|
+
#previousSource;
|
|
55
|
+
#previousRevision;
|
|
56
|
+
#candidateSource;
|
|
57
|
+
#mode;
|
|
58
|
+
#candidateRevision;
|
|
59
|
+
#state = 'prepared';
|
|
60
|
+
#committedSnapshot;
|
|
61
|
+
constructor(file, previousSource, previousRevision, candidateSource, document, mode) {
|
|
62
|
+
this.#file = file;
|
|
63
|
+
this.#previousSource = previousSource;
|
|
64
|
+
this.#previousRevision = previousRevision;
|
|
65
|
+
this.#candidateSource = candidateSource;
|
|
66
|
+
this.#mode = mode;
|
|
67
|
+
this.document = document;
|
|
68
|
+
this.#candidateRevision = revision(candidateSource);
|
|
69
|
+
}
|
|
70
|
+
async commit() {
|
|
71
|
+
if (this.#state === 'committed')
|
|
72
|
+
return requireSnapshot(this.#committedSnapshot);
|
|
73
|
+
if (this.#state === 'rolled-back') {
|
|
74
|
+
throw new YamlConfigDocumentError('A rolled-back YAML config transaction cannot commit');
|
|
75
|
+
}
|
|
76
|
+
const source = await readFile(this.#file, 'utf8');
|
|
77
|
+
assertRevision(this.#file, source, this.#previousRevision);
|
|
78
|
+
// Build every result before rename so successful replacement is the last
|
|
79
|
+
// fallible step visible to callers.
|
|
80
|
+
const committed = Object.freeze({
|
|
81
|
+
document: this.document,
|
|
82
|
+
revision: this.#candidateRevision,
|
|
83
|
+
});
|
|
84
|
+
await atomicReplace(this.#file, this.#candidateSource, this.#mode);
|
|
85
|
+
this.#committedSnapshot = committed;
|
|
86
|
+
this.#state = 'committed';
|
|
87
|
+
return committed;
|
|
88
|
+
}
|
|
89
|
+
async rollback() {
|
|
90
|
+
if (this.#state !== 'committed') {
|
|
91
|
+
if (this.#state === 'prepared')
|
|
92
|
+
this.#state = 'rolled-back';
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const source = await readFile(this.#file, 'utf8');
|
|
96
|
+
assertRevision(this.#file, source, this.#candidateRevision);
|
|
97
|
+
await atomicReplace(this.#file, this.#previousSource, this.#mode);
|
|
98
|
+
this.#state = 'rolled-back';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function parseYaml(file, source) {
|
|
102
|
+
const document = parseDocument(source, { prettyErrors: true, strict: true });
|
|
103
|
+
if (document.errors.length > 0) {
|
|
104
|
+
throw new ConfigDocumentParseError(file, Object.freeze(document.errors.map((error) => error.message)));
|
|
105
|
+
}
|
|
106
|
+
return document;
|
|
107
|
+
}
|
|
108
|
+
function parse(file, source) {
|
|
109
|
+
const value = parseYaml(file, source).toJS({ maxAliasCount: 100 });
|
|
110
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
111
|
+
throw new ConfigDocumentParseError(file, ['The document root must be a mapping']);
|
|
112
|
+
}
|
|
113
|
+
return Object.freeze(value);
|
|
114
|
+
}
|
|
115
|
+
function applyPatch(document, patch) {
|
|
116
|
+
assertPath(patch.path);
|
|
117
|
+
if (patch.path.length === 0) {
|
|
118
|
+
if (patch.op === 'remove') {
|
|
119
|
+
throw new ConfigPatchPathError('The config document root cannot be removed');
|
|
120
|
+
}
|
|
121
|
+
if (!patch.value || typeof patch.value !== 'object' || Array.isArray(patch.value)) {
|
|
122
|
+
throw new ConfigPatchPathError('The config document root must be an object');
|
|
123
|
+
}
|
|
124
|
+
document.contents = document.createNode(structuredClone(patch.value));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (patch.op === 'set')
|
|
128
|
+
document.setIn(patch.path, structuredClone(patch.value));
|
|
129
|
+
else
|
|
130
|
+
document.deleteIn(patch.path);
|
|
131
|
+
}
|
|
132
|
+
function assertPath(path) {
|
|
133
|
+
for (const segment of path) {
|
|
134
|
+
if (!segment || segment === '__proto__' || segment === 'prototype' || segment === 'constructor') {
|
|
135
|
+
throw new ConfigPatchPathError(`Unsafe config path segment: ${segment || '<empty>'}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function stringify(document, original) {
|
|
140
|
+
const indentation = original.match(/^( +)\S/mu)?.[1].length ?? 2;
|
|
141
|
+
const source = document.toString({ indent: indentation, lineWidth: 0 });
|
|
142
|
+
return original.includes('\r\n') ? source.replaceAll('\n', '\r\n') : source;
|
|
143
|
+
}
|
|
144
|
+
function snapshot(document, source) {
|
|
145
|
+
return Object.freeze({ document, revision: revision(source) });
|
|
146
|
+
}
|
|
147
|
+
function revision(source) {
|
|
148
|
+
return createHash('sha256').update(source).digest('hex');
|
|
149
|
+
}
|
|
150
|
+
function assertRevision(file, source, expected) {
|
|
151
|
+
if (revision(source) !== expected)
|
|
152
|
+
throw new ConfigDocumentConflictError(file);
|
|
153
|
+
}
|
|
154
|
+
async function atomicReplace(file, source, mode) {
|
|
155
|
+
const temporary = `${basename(file)}.${process.pid}.${randomUUID()}.tmp`;
|
|
156
|
+
const target = resolve(dirname(file), temporary);
|
|
157
|
+
try {
|
|
158
|
+
await writeFile(target, source, { mode });
|
|
159
|
+
await rename(target, file);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
await rm(target, { force: true }).catch(() => undefined);
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function requireSnapshot(value) {
|
|
167
|
+
if (!value)
|
|
168
|
+
throw new Error('Committed YAML transaction has no snapshot');
|
|
169
|
+
return value;
|
|
170
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhin.js/config-yaml",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Comment-preserving YAML ConfigDocument adapter for the Zhin Plugin Runtime",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"development": "./src/index.ts",
|
|
12
|
+
"import": "./lib/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"yaml": "2.9.0",
|
|
20
|
+
"@zhin.js/runtime": "1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^26.1.0",
|
|
24
|
+
"typescript": "^6.0.3",
|
|
25
|
+
"@zhin.js/plugin-runtime": "1.0.0"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/zhinjs/zhin.git",
|
|
34
|
+
"directory": "packages/im/config-yaml"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"registry": "https://registry.npmjs.org"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"clean": "rimraf lib",
|
|
43
|
+
"test": "vitest run --root ../../.. packages/im/config-yaml/tests",
|
|
44
|
+
"check:size": "node ../../../scripts/check-plugin-runtime-install-size.mjs @zhin.js/config-yaml"
|
|
45
|
+
}
|
|
46
|
+
}
|